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}