Skip to main content

io_harness/
containment.rs

1//! The containment boundary for a tree of agents.
2//!
3//! A [`Containment`] is handed in once, at the root, and caps the whole tree
4//! with limits no spawned [`crate::TaskContract`] can raise: how many agents may
5//! exist, how many may run at once, how deep they may nest, and an aggregate
6//! spend ceiling the entire tree draws down *together*. It is serde-serializable
7//! like [`crate::Policy`], so io-cli and io-studio load it from config rather
8//! than hand-build it.
9//!
10//! The [`Ledger`] is the runtime accounting for one tree: a single shared point
11//! that every agent draws its token spend and its right-to-exist from. It is the
12//! one place spend is serialized, so a hundred concurrent agents cannot overspend
13//! past the ceiling through a race — the critical section is a plain lock held
14//! only for the arithmetic.
15
16use std::sync::Mutex;
17use std::time::Duration;
18
19use serde::{Deserialize, Serialize};
20
21/// The caps a whole agent tree runs under. Tokens are the hard spend ceiling
22/// (no price telemetry exists, so spend is counted in tokens); an optional cost
23/// and duration are carried for callers that supply them.
24///
25/// ```
26/// use io_harness::Containment;
27///
28/// // Twelve agents in the tree, four running at once, two levels of nesting, and
29/// // 200k tokens for all of them together. A spawned contract can tighten any of
30/// // these and can raise none of them.
31/// let containment = Containment::new(12, 4, 2, 200_000);
32///
33/// // The ceiling actually enforced is the token one: a provider reports tokens
34/// // and never money, so `max_total_cost` is inert and stays `None`.
35/// assert_eq!(containment.max_total_tokens, 200_000);
36/// assert_eq!(containment.max_total_cost, None);
37///
38/// // Serde, so an operator's config file is the source of the caps rather than a
39/// // recompile.
40/// let stored = serde_json::to_string(&containment).unwrap();
41/// let loaded: Containment = serde_json::from_str(&stored).unwrap();
42/// assert_eq!(loaded, containment);
43/// ```
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct Containment {
46    /// Maximum number of agents that may exist in the tree, root included.
47    pub max_total_agents: u32,
48    /// Maximum number of agents that may run at once.
49    pub max_concurrent: u32,
50    /// Maximum nesting depth, counted from the root (the root is depth 0).
51    pub max_depth: u32,
52    /// Aggregate token ceiling drawn down by the entire tree together.
53    pub max_total_tokens: u64,
54    /// Optional aggregate cost ceiling, in whatever unit the caller supplies
55    /// (there is no price telemetry, so the crate never derives this itself).
56    #[serde(default)]
57    /// **Reserved, and not enforced.** Setting it has no effect.
58    ///
59    /// Enforcing a cost ceiling needs a price per token, and the crate has no
60    /// price telemetry — a provider reports tokens, never money, so any figure
61    /// the harness compared against would be one it invented. The field is kept
62    /// rather than removed because it serialises in callers' stored configuration
63    /// and deleting it would break their deserialisation for no gain; it is
64    /// documented as inert instead, which is the honest state.
65    ///
66    /// Spend that *is* enforced is [`Self::max_total_tokens`]. To bound money,
67    /// convert your budget to tokens at your provider's rate and set that.
68    pub max_total_cost: Option<u64>,
69    /// Optional wall-clock ceiling for the whole tree, measured from when the
70    /// ROOT run started — so it counts a 24-hour tree's whole life, including
71    /// time the process was down, not the age of whichever agent notices.
72    ///
73    /// Crossing it halts the tree with
74    /// [`RunOutcome::BudgetCeilingReached`](crate::RunOutcome::BudgetCeilingReached),
75    /// the same way the token ceiling does, and a child's own contract cannot
76    /// raise it. Declared in 0.5.0 and not actually enforced until 0.12.0.
77    #[serde(default)]
78    pub max_total_duration: Option<Duration>,
79}
80
81impl Containment {
82    /// A containment with token, agent, concurrency, and depth caps and no
83    /// cost/duration ceiling.
84    pub fn new(
85        max_total_agents: u32,
86        max_concurrent: u32,
87        max_depth: u32,
88        max_total_tokens: u64,
89    ) -> Self {
90        Self {
91            max_total_agents,
92            max_concurrent,
93            max_depth,
94            max_total_tokens,
95            max_total_cost: None,
96            max_total_duration: None,
97        }
98    }
99}
100
101/// Why a spawn was refused by the containment boundary. Returned to the
102/// requesting agent as a typed tool result it can adapt to, never a panic.
103///
104/// ```
105/// use io_harness::{Containment, Ledger, SpawnRefusal};
106///
107/// // Room for two agents in the whole tree, and the root is already one of them.
108/// let ledger = Ledger::new(&Containment::new(2, 2, 1, 1_000));
109/// ledger.register_agent(1).expect("the first child fits");
110///
111/// // The second child does not. The parent agent is told, in a form it can act
112/// // on — do the work itself, or narrow what it was going to delegate.
113/// let refusal = ledger.register_agent(1).unwrap_err();
114/// assert_eq!(refusal, SpawnRefusal::AgentCap { max: 2 });
115/// assert_eq!(refusal.to_string(), "agent cap reached (2 agents)");
116///
117/// // `cap` is the short label the trace records, so an audit can count refusals
118/// // by which boundary produced them without parsing the English above.
119/// assert_eq!(refusal.cap(), "agents");
120/// assert_eq!(SpawnRefusal::DepthCap { max: 1, requested: 2 }.cap(), "depth");
121/// ```
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub enum SpawnRefusal {
124    /// The tree already holds `max_total_agents`.
125    AgentCap { max: u32 },
126    /// The child would nest past `max_depth` (counted from the root).
127    DepthCap { max: u32, requested: u32 },
128    /// The aggregate spend ceiling is already exhausted, so a new agent has no
129    /// budget to run under.
130    BudgetExhausted,
131}
132
133impl SpawnRefusal {
134    /// Which cap this refusal breached, for the trace.
135    pub fn cap(&self) -> &'static str {
136        match self {
137            SpawnRefusal::AgentCap { .. } => "agents",
138            SpawnRefusal::DepthCap { .. } => "depth",
139            SpawnRefusal::BudgetExhausted => "budget",
140        }
141    }
142}
143
144impl std::fmt::Display for SpawnRefusal {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        match self {
147            SpawnRefusal::AgentCap { max } => {
148                write!(f, "agent cap reached ({max} agents)")
149            }
150            SpawnRefusal::DepthCap { max, requested } => {
151                write!(f, "depth cap reached (max {max}, requested {requested})")
152            }
153            SpawnRefusal::BudgetExhausted => write!(f, "the tree's spend ceiling is exhausted"),
154        }
155    }
156}
157
158/// The outcome of drawing token spend against the ledger.
159///
160/// ```
161/// use io_harness::{Containment, Draw, Ledger, Usage};
162///
163/// let ledger = Ledger::new(&Containment::new(4, 2, 1, 5_000));
164/// let usage = Usage {
165///     prompt_tokens: 4_000,
166///     completion_tokens: 800,
167///     total_tokens: 4_800,
168///     ..Default::default()
169/// };
170///
171/// // What the run loop does with a completion's usage: draw it, then branch. This
172/// // is the only place the tree's ceiling is checked, so ignoring the return is
173/// // how a tree overspends.
174/// match ledger.draw_tokens(usage.total_tokens) {
175///     Draw::Ok => { /* take another step */ }
176///     Draw::Halted => unreachable!("4,800 fits under 5,000"),
177/// }
178///
179/// // The next step does not fit. `Halted` stops the whole tree, not just this
180/// // agent — and the rejected draw is not recorded, so the total never drifts
181/// // above the ceiling even though the provider did charge for that step.
182/// assert_eq!(ledger.draw_tokens(usage.total_tokens), Draw::Halted);
183/// assert_eq!(ledger.spent_tokens(), 4_800);
184/// ```
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub enum Draw {
187    /// The draw was within the ceiling; the tree may continue.
188    Ok,
189    /// This draw crossed the aggregate ceiling. The spend is still recorded (a
190    /// step already happened and its tokens were spent), but the tree must halt
191    /// as a whole — no agent gets another step.
192    Halted,
193}
194
195/// Shared accounting for one agent tree: the aggregate spend and the agent
196/// count, behind a single lock so concurrent draws cannot overspend.
197///
198/// Wrap in an [`std::sync::Arc`] and hand a clone of the arc to every agent in
199/// the tree; they all draw on the one ledger.
200///
201/// ```
202/// use std::sync::Arc;
203///
204/// use io_harness::{Containment, Draw, Ledger};
205///
206/// // One ledger for the tree; every agent holds a clone of the same arc.
207/// let ledger = Arc::new(Ledger::new(&Containment::new(10, 4, 3, 100)));
208/// let child = Arc::clone(&ledger);
209///
210/// // A child's contract can ask for 500 tokens and still only get what the tree
211/// // has left. This is the containment property: budgets narrow, never widen.
212/// assert_eq!(child.effective_token_budget(Some(500)), 100);
213/// assert_eq!(child.effective_token_budget(Some(20)), 20);
214///
215/// // Two agents, each well inside its own budget, still halt the tree between
216/// // them — the ceiling is aggregate, and the second draw is rejected rather
217/// // than letting the recorded total pass 100.
218/// assert_eq!(child.draw_tokens(60), Draw::Ok);
219/// assert_eq!(ledger.draw_tokens(60), Draw::Halted);
220/// assert_eq!(ledger.spent_tokens(), 60);
221/// ```
222#[derive(Debug)]
223pub struct Ledger {
224    max_total_tokens: u64,
225    max_total_agents: u32,
226    max_depth: u32,
227    state: Mutex<State>,
228}
229
230#[derive(Debug)]
231struct State {
232    spent_tokens: u64,
233    agents: u32,
234}
235
236impl Ledger {
237    /// A fresh ledger for a tree running under `c`. The root counts as the first
238    /// agent, so the ledger starts with one agent registered.
239    pub fn new(c: &Containment) -> Self {
240        Self {
241            max_total_tokens: c.max_total_tokens,
242            max_total_agents: c.max_total_agents,
243            max_depth: c.max_depth,
244            state: Mutex::new(State {
245                spent_tokens: 0,
246                agents: 1, // the root
247            }),
248        }
249    }
250
251    /// A ledger restored from durable state, for resuming a crashed tree: the
252    /// spend and agent count are the totals already recorded in the store, so
253    /// the resumed tree draws against the same continuous ceiling instead of
254    /// restarting the budget at zero. `agents` already includes the root and
255    /// every child previously spawned, so an adopted (already-registered) child
256    /// is not re-counted on resume.
257    pub fn from_state(c: &Containment, spent_tokens: u64, agents: u32) -> Self {
258        Self {
259            max_total_tokens: c.max_total_tokens,
260            max_total_agents: c.max_total_agents,
261            max_depth: c.max_depth,
262            state: Mutex::new(State {
263                spent_tokens,
264                agents: agents.max(1),
265            }),
266        }
267    }
268
269    /// Tokens still available to the whole tree.
270    pub fn remaining_tokens(&self) -> u64 {
271        let s = self.state.lock().unwrap();
272        self.max_total_tokens.saturating_sub(s.spent_tokens)
273    }
274
275    /// The budget an agent actually runs under, given the budget its own
276    /// contract asked for. It is the smaller of what the contract wanted and
277    /// what the tree has left — so a contract can tighten the budget but can
278    /// never raise it above the tree's remaining ceiling.
279    pub fn effective_token_budget(&self, contract_max: Option<u64>) -> u64 {
280        let remaining = self.remaining_tokens();
281        remaining.min(contract_max.unwrap_or(u64::MAX))
282    }
283
284    /// Record `tokens` of spend against the tree. A draw that would cross the
285    /// aggregate ceiling is *rejected* — it is not added — and returns
286    /// [`Draw::Halted`], so recorded spend never exceeds the ceiling however many
287    /// agents draw concurrently. The single lock is what makes that hold under a
288    /// hundred concurrent draws: the check-and-add is atomic, so no race can slip
289    /// spend past the ceiling.
290    ///
291    /// (The model tokens of the halting step were still spent by the provider;
292    /// the ledger declines to count them and stops the tree rather than letting
293    /// the recorded total drift over the ceiling.)
294    pub fn draw_tokens(&self, tokens: u64) -> Draw {
295        let mut s = self.state.lock().unwrap();
296        let next = s.spent_tokens.saturating_add(tokens);
297        if next > self.max_total_tokens {
298            Draw::Halted
299        } else {
300            s.spent_tokens = next;
301            Draw::Ok
302        }
303    }
304
305    /// Total tokens the tree has spent so far.
306    pub fn spent_tokens(&self) -> u64 {
307        self.state.lock().unwrap().spent_tokens
308    }
309
310    /// Register one new child agent at `depth` (the root is depth 0, so a
311    /// child's depth is its parent's depth + 1). Fails, without registering,
312    /// if the agent or depth cap would be breached or the budget is exhausted.
313    pub fn register_agent(&self, depth: u32) -> std::result::Result<(), SpawnRefusal> {
314        if depth > self.max_depth {
315            return Err(SpawnRefusal::DepthCap {
316                max: self.max_depth,
317                requested: depth,
318            });
319        }
320        if self.remaining_tokens() == 0 {
321            return Err(SpawnRefusal::BudgetExhausted);
322        }
323        let mut s = self.state.lock().unwrap();
324        if s.agents >= self.max_total_agents {
325            return Err(SpawnRefusal::AgentCap {
326                max: self.max_total_agents,
327            });
328        }
329        s.agents += 1;
330        Ok(())
331    }
332
333    /// How many agents the tree currently holds.
334    pub fn agents(&self) -> u32 {
335        self.state.lock().unwrap().agents
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    fn containment() -> Containment {
344        // 10 agents, 4 concurrent, depth 3, 100 tokens for the whole tree.
345        Containment::new(10, 4, 3, 100)
346    }
347
348    #[test]
349    fn the_ceiling_is_tree_wide_not_per_agent() {
350        // Three children each draw 40 tokens — none exceeds its own 50-token
351        // contract budget, yet the tree halts because their *combined* draw
352        // crosses the aggregate ceiling of 100.
353        let led = Ledger::new(&containment());
354        let per_child_contract_budget = 50;
355        assert!(40 < per_child_contract_budget);
356
357        assert_eq!(led.draw_tokens(40), Draw::Ok); // 40
358        assert_eq!(led.draw_tokens(40), Draw::Ok); // 80
359        assert_eq!(led.draw_tokens(40), Draw::Halted); // 80+40 > 100: rejected, tree halts
360                                                       // Recorded spend never crosses the ceiling — the over-draw is not counted.
361        assert_eq!(led.spent_tokens(), 80);
362        assert!(led.spent_tokens() <= 100);
363    }
364
365    #[test]
366    fn a_contract_cannot_raise_the_ceiling() {
367        let led = Ledger::new(&containment()); // 100 remaining
368                                               // A child asking for 500 tokens is capped at what the tree has left.
369        assert_eq!(led.effective_token_budget(Some(500)), 100);
370        // After the tree spends 70, a greedy child is capped at the remaining 30.
371        led.draw_tokens(70);
372        assert_eq!(led.remaining_tokens(), 30);
373        assert_eq!(led.effective_token_budget(Some(500)), 30);
374        // A child that asks for *less* than remaining keeps its tighter budget.
375        assert_eq!(led.effective_token_budget(Some(10)), 10);
376        // A child with no budget of its own inherits the tree's remaining.
377        assert_eq!(led.effective_token_budget(None), 30);
378    }
379
380    #[test]
381    fn concurrent_draws_never_overspend_the_ceiling() {
382        // Many threads hammer one ledger; the single lock keeps recorded spend
383        // from ever crossing the ceiling, and the successful draws sum to exactly
384        // what was recorded — no double-count, no slip-past.
385        use std::sync::Arc;
386        use std::thread;
387
388        let led = Arc::new(Ledger::new(&Containment::new(10_000, 64, 3, 1_000)));
389        let mut handles = Vec::new();
390        for _ in 0..64 {
391            let l = Arc::clone(&led);
392            handles.push(thread::spawn(move || {
393                let mut ok = 0u64;
394                for _ in 0..100 {
395                    if l.draw_tokens(10) == Draw::Ok {
396                        ok += 10;
397                    }
398                }
399                ok
400            }));
401        }
402        let granted: u64 = handles.into_iter().map(|h| h.join().unwrap()).sum();
403        assert!(led.spent_tokens() <= 1_000, "never exceeds the ceiling");
404        // Every Ok draw is accounted for exactly once.
405        assert_eq!(granted, led.spent_tokens());
406    }
407
408    #[test]
409    fn serde_roundtrips_and_is_stable() {
410        let c = Containment {
411            max_total_cost: Some(500),
412            max_total_duration: Some(Duration::from_secs(3600)),
413            ..containment()
414        };
415        let json = serde_json::to_string(&c).unwrap();
416        let back: Containment = serde_json::from_str(&json).unwrap();
417        assert_eq!(c, back);
418    }
419}