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}