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 { prompt_tokens: 4_000, completion_tokens: 800, total_tokens: 4_800 };
165///
166/// // What the run loop does with a completion's usage: draw it, then branch. This
167/// // is the only place the tree's ceiling is checked, so ignoring the return is
168/// // how a tree overspends.
169/// match ledger.draw_tokens(usage.total_tokens) {
170///     Draw::Ok => { /* take another step */ }
171///     Draw::Halted => unreachable!("4,800 fits under 5,000"),
172/// }
173///
174/// // The next step does not fit. `Halted` stops the whole tree, not just this
175/// // agent — and the rejected draw is not recorded, so the total never drifts
176/// // above the ceiling even though the provider did charge for that step.
177/// assert_eq!(ledger.draw_tokens(usage.total_tokens), Draw::Halted);
178/// assert_eq!(ledger.spent_tokens(), 4_800);
179/// ```
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub enum Draw {
182    /// The draw was within the ceiling; the tree may continue.
183    Ok,
184    /// This draw crossed the aggregate ceiling. The spend is still recorded (a
185    /// step already happened and its tokens were spent), but the tree must halt
186    /// as a whole — no agent gets another step.
187    Halted,
188}
189
190/// Shared accounting for one agent tree: the aggregate spend and the agent
191/// count, behind a single lock so concurrent draws cannot overspend.
192///
193/// Wrap in an [`std::sync::Arc`] and hand a clone of the arc to every agent in
194/// the tree; they all draw on the one ledger.
195///
196/// ```
197/// use std::sync::Arc;
198///
199/// use io_harness::{Containment, Draw, Ledger};
200///
201/// // One ledger for the tree; every agent holds a clone of the same arc.
202/// let ledger = Arc::new(Ledger::new(&Containment::new(10, 4, 3, 100)));
203/// let child = Arc::clone(&ledger);
204///
205/// // A child's contract can ask for 500 tokens and still only get what the tree
206/// // has left. This is the containment property: budgets narrow, never widen.
207/// assert_eq!(child.effective_token_budget(Some(500)), 100);
208/// assert_eq!(child.effective_token_budget(Some(20)), 20);
209///
210/// // Two agents, each well inside its own budget, still halt the tree between
211/// // them — the ceiling is aggregate, and the second draw is rejected rather
212/// // than letting the recorded total pass 100.
213/// assert_eq!(child.draw_tokens(60), Draw::Ok);
214/// assert_eq!(ledger.draw_tokens(60), Draw::Halted);
215/// assert_eq!(ledger.spent_tokens(), 60);
216/// ```
217#[derive(Debug)]
218pub struct Ledger {
219    max_total_tokens: u64,
220    max_total_agents: u32,
221    max_depth: u32,
222    state: Mutex<State>,
223}
224
225#[derive(Debug)]
226struct State {
227    spent_tokens: u64,
228    agents: u32,
229}
230
231impl Ledger {
232    /// A fresh ledger for a tree running under `c`. The root counts as the first
233    /// agent, so the ledger starts with one agent registered.
234    pub fn new(c: &Containment) -> Self {
235        Self {
236            max_total_tokens: c.max_total_tokens,
237            max_total_agents: c.max_total_agents,
238            max_depth: c.max_depth,
239            state: Mutex::new(State {
240                spent_tokens: 0,
241                agents: 1, // the root
242            }),
243        }
244    }
245
246    /// A ledger restored from durable state, for resuming a crashed tree: the
247    /// spend and agent count are the totals already recorded in the store, so
248    /// the resumed tree draws against the same continuous ceiling instead of
249    /// restarting the budget at zero. `agents` already includes the root and
250    /// every child previously spawned, so an adopted (already-registered) child
251    /// is not re-counted on resume.
252    pub fn from_state(c: &Containment, spent_tokens: u64, agents: u32) -> Self {
253        Self {
254            max_total_tokens: c.max_total_tokens,
255            max_total_agents: c.max_total_agents,
256            max_depth: c.max_depth,
257            state: Mutex::new(State {
258                spent_tokens,
259                agents: agents.max(1),
260            }),
261        }
262    }
263
264    /// Tokens still available to the whole tree.
265    pub fn remaining_tokens(&self) -> u64 {
266        let s = self.state.lock().unwrap();
267        self.max_total_tokens.saturating_sub(s.spent_tokens)
268    }
269
270    /// The budget an agent actually runs under, given the budget its own
271    /// contract asked for. It is the smaller of what the contract wanted and
272    /// what the tree has left — so a contract can tighten the budget but can
273    /// never raise it above the tree's remaining ceiling.
274    pub fn effective_token_budget(&self, contract_max: Option<u64>) -> u64 {
275        let remaining = self.remaining_tokens();
276        remaining.min(contract_max.unwrap_or(u64::MAX))
277    }
278
279    /// Record `tokens` of spend against the tree. A draw that would cross the
280    /// aggregate ceiling is *rejected* — it is not added — and returns
281    /// [`Draw::Halted`], so recorded spend never exceeds the ceiling however many
282    /// agents draw concurrently. The single lock is what makes that hold under a
283    /// hundred concurrent draws: the check-and-add is atomic, so no race can slip
284    /// spend past the ceiling.
285    ///
286    /// (The model tokens of the halting step were still spent by the provider;
287    /// the ledger declines to count them and stops the tree rather than letting
288    /// the recorded total drift over the ceiling.)
289    pub fn draw_tokens(&self, tokens: u64) -> Draw {
290        let mut s = self.state.lock().unwrap();
291        let next = s.spent_tokens.saturating_add(tokens);
292        if next > self.max_total_tokens {
293            Draw::Halted
294        } else {
295            s.spent_tokens = next;
296            Draw::Ok
297        }
298    }
299
300    /// Total tokens the tree has spent so far.
301    pub fn spent_tokens(&self) -> u64 {
302        self.state.lock().unwrap().spent_tokens
303    }
304
305    /// Register one new child agent at `depth` (the root is depth 0, so a
306    /// child's depth is its parent's depth + 1). Fails, without registering,
307    /// if the agent or depth cap would be breached or the budget is exhausted.
308    pub fn register_agent(&self, depth: u32) -> std::result::Result<(), SpawnRefusal> {
309        if depth > self.max_depth {
310            return Err(SpawnRefusal::DepthCap {
311                max: self.max_depth,
312                requested: depth,
313            });
314        }
315        if self.remaining_tokens() == 0 {
316            return Err(SpawnRefusal::BudgetExhausted);
317        }
318        let mut s = self.state.lock().unwrap();
319        if s.agents >= self.max_total_agents {
320            return Err(SpawnRefusal::AgentCap {
321                max: self.max_total_agents,
322            });
323        }
324        s.agents += 1;
325        Ok(())
326    }
327
328    /// How many agents the tree currently holds.
329    pub fn agents(&self) -> u32 {
330        self.state.lock().unwrap().agents
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    fn containment() -> Containment {
339        // 10 agents, 4 concurrent, depth 3, 100 tokens for the whole tree.
340        Containment::new(10, 4, 3, 100)
341    }
342
343    #[test]
344    fn the_ceiling_is_tree_wide_not_per_agent() {
345        // Three children each draw 40 tokens — none exceeds its own 50-token
346        // contract budget, yet the tree halts because their *combined* draw
347        // crosses the aggregate ceiling of 100.
348        let led = Ledger::new(&containment());
349        let per_child_contract_budget = 50;
350        assert!(40 < per_child_contract_budget);
351
352        assert_eq!(led.draw_tokens(40), Draw::Ok); // 40
353        assert_eq!(led.draw_tokens(40), Draw::Ok); // 80
354        assert_eq!(led.draw_tokens(40), Draw::Halted); // 80+40 > 100: rejected, tree halts
355                                                       // Recorded spend never crosses the ceiling — the over-draw is not counted.
356        assert_eq!(led.spent_tokens(), 80);
357        assert!(led.spent_tokens() <= 100);
358    }
359
360    #[test]
361    fn a_contract_cannot_raise_the_ceiling() {
362        let led = Ledger::new(&containment()); // 100 remaining
363                                               // A child asking for 500 tokens is capped at what the tree has left.
364        assert_eq!(led.effective_token_budget(Some(500)), 100);
365        // After the tree spends 70, a greedy child is capped at the remaining 30.
366        led.draw_tokens(70);
367        assert_eq!(led.remaining_tokens(), 30);
368        assert_eq!(led.effective_token_budget(Some(500)), 30);
369        // A child that asks for *less* than remaining keeps its tighter budget.
370        assert_eq!(led.effective_token_budget(Some(10)), 10);
371        // A child with no budget of its own inherits the tree's remaining.
372        assert_eq!(led.effective_token_budget(None), 30);
373    }
374
375    #[test]
376    fn concurrent_draws_never_overspend_the_ceiling() {
377        // Many threads hammer one ledger; the single lock keeps recorded spend
378        // from ever crossing the ceiling, and the successful draws sum to exactly
379        // what was recorded — no double-count, no slip-past.
380        use std::sync::Arc;
381        use std::thread;
382
383        let led = Arc::new(Ledger::new(&Containment::new(10_000, 64, 3, 1_000)));
384        let mut handles = Vec::new();
385        for _ in 0..64 {
386            let l = Arc::clone(&led);
387            handles.push(thread::spawn(move || {
388                let mut ok = 0u64;
389                for _ in 0..100 {
390                    if l.draw_tokens(10) == Draw::Ok {
391                        ok += 10;
392                    }
393                }
394                ok
395            }));
396        }
397        let granted: u64 = handles.into_iter().map(|h| h.join().unwrap()).sum();
398        assert!(led.spent_tokens() <= 1_000, "never exceeds the ceiling");
399        // Every Ok draw is accounted for exactly once.
400        assert_eq!(granted, led.spent_tokens());
401    }
402
403    #[test]
404    fn serde_roundtrips_and_is_stable() {
405        let c = Containment {
406            max_total_cost: Some(500),
407            max_total_duration: Some(Duration::from_secs(3600)),
408            ..containment()
409        };
410        let json = serde_json::to_string(&c).unwrap();
411        let back: Containment = serde_json::from_str(&json).unwrap();
412        assert_eq!(c, back);
413    }
414}