Skip to main content

agent_engine/runtime/
budget.rs

1//! Task 23 — per-turn budgets (spec §8.1).
2//!
3//! Every stream session runs under one [`TurnBudget`]: provider rounds,
4//! tool calls, elapsed wall-clock, accumulated tool-result bytes, and the
5//! optional context-token / USD-cost dimensions. Budgets differ by role
6//! (foreground, event, worker) but share ONE enforcement
7//! mechanism — the [`TurnBudgetMeter`] consulted by the stream loop.
8//!
9//! Composition note: the chat frontend's reactor auto-turn cap
10//! (`events.auto_turn_cap`, default `engine::reactor::AUTO_TURN_CAP`) limits consecutive auto-triggered
11//! turns ACROSS turns; this budget bounds work WITHIN one turn. They
12//! compose — neither replaces nor duplicates the other.
13
14use std::time::{Duration, Instant};
15
16use agent_core::BudgetDimension;
17
18/// Start of every synthetic tool_result for a call the engine did not run
19/// because a turn budget was used up.
20pub const TOOL_CALL_NOT_EXECUTED: &str = "Tool call not executed";
21
22/// The synthetic tool_result for a call refused by the turn's tool-call
23/// allowance.
24pub const TOOL_CALL_BUDGET_REFUSAL: &str =
25    "Tool call not executed: turn tool-call budget exhausted";
26
27/// Per-turn limits (spec §8.1).
28#[derive(Debug, Clone, PartialEq)]
29pub struct TurnBudget {
30    /// Provider rounds per turn (per renewal). `None` means no limit — the
31    /// foreground default. Event (24) and worker (64, x3 with renewals)
32    /// turns are bounded by default. Set with
33    /// `turn_budget.<role>.max_provider_rounds`.
34    pub max_provider_rounds: Option<u32>,
35    /// Tool calls per turn. `None` means no limit — the foreground and worker
36    /// default; event turns default to 96. Set with
37    /// `turn_budget.<role>.max_tool_calls`.
38    pub max_tool_calls: Option<u32>,
39    /// Wall-clock limit for one turn. `None` (the default for every role)
40    /// means no limit: a turn that is still making progress is never cut off
41    /// for taking long. Opt in with `turn_budget.<role>.max_elapsed_secs` or
42    /// `/budget time`.
43    pub max_elapsed: Option<Duration>,
44    /// Accumulated tool-result bytes per turn. `None` means no limit — the
45    /// foreground and worker default; event turns default to 8 MiB. Set
46    /// with `turn_budget.<role>.max_accumulated_tool_result_bytes`.
47    pub max_accumulated_tool_result_bytes: Option<usize>,
48    pub max_context_tokens: Option<u64>,
49    pub max_cost_usd: Option<f64>,
50    /// Bounded auto-renewals of the provider-round allowance (spec §8.1).
51    /// On exhaustion the stream loop may reset the round counter this many
52    /// times so a long, legitimate task continues instead of hard-failing.
53    /// Round renewal never resets wall-clock (durable context successors do); a
54    /// value of `0` means no graceful continuation (immediate hard stop). Only
55    /// matters when `max_provider_rounds` is configured.
56    pub max_round_renewals: u32,
57}
58
59/// Execution role a turn runs under.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum TurnRole {
62    /// Interactive foreground session.
63    Foreground,
64    /// Turns a session starts by itself because events arrived (inbox
65    /// messages, subagent completions). Nobody typed them.
66    Event,
67    /// Delegated worker/subagent sessions.
68    Worker,
69}
70
71impl TurnBudget {
72    /// Compiled per-role defaults. No role has a wall-clock limit unless
73    /// configured. Foreground (a human is watching and can interrupt) has no
74    /// round, tool-call or tool-result-byte limit either. Unattended roles
75    /// stay bounded: event-started turns hard-stop
76    /// at 24 rounds / 96 tool calls / 8 MiB (nobody is watching), and a
77    /// worker stops after 64 rounds x (1 + 2 renewals) = 192 rounds per run
78    /// (a `timeout: 0` worker has no other bound).
79    pub fn for_role(role: TurnRole) -> Self {
80        match role {
81            TurnRole::Foreground => Self {
82                max_provider_rounds: None,
83                max_tool_calls: None,
84                max_elapsed: None,
85                max_accumulated_tool_result_bytes: None,
86                max_context_tokens: None,
87                max_cost_usd: None,
88                // A human is watching and can interrupt; let long agentic
89                // tasks continue through several checkpoints.
90                max_round_renewals: 8,
91            },
92            TurnRole::Event => Self {
93                max_provider_rounds: Some(24),
94                max_tool_calls: Some(96),
95                max_elapsed: None,
96                max_accumulated_tool_result_bytes: Some(8 * 1024 * 1024),
97                max_context_tokens: None,
98                max_cost_usd: None,
99                // Nobody is watching: no graceful extension — the round cap
100                // is a hard stop.
101                max_round_renewals: 0,
102            },
103            TurnRole::Worker => Self {
104                max_provider_rounds: Some(64),
105                max_tool_calls: None,
106                max_elapsed: None,
107                max_accumulated_tool_result_bytes: None,
108                max_context_tokens: None,
109                max_cost_usd: None,
110                // Bounded delegation under a supervising turn: 64 x (1 + 2)
111                // = 192 rounds.
112                max_round_renewals: 2,
113            },
114        }
115    }
116
117    /// The stricter of two budgets, dimension by dimension (`None` = no
118    /// limit loses to any limit). Used to combine a session's budget with
119    /// the event role's ([`Self::for_event_turn`]).
120    pub fn tighter(&self, other: &TurnBudget) -> TurnBudget {
121        fn min<T: PartialOrd + Copy>(a: Option<T>, b: Option<T>) -> Option<T> {
122            match (a, b) {
123                (Some(a), Some(b)) => Some(if b < a { b } else { a }),
124                (a, None) => a,
125                (None, b) => b,
126            }
127        }
128        TurnBudget {
129            max_provider_rounds: min(self.max_provider_rounds, other.max_provider_rounds),
130            max_tool_calls: min(self.max_tool_calls, other.max_tool_calls),
131            max_elapsed: min(self.max_elapsed, other.max_elapsed),
132            max_accumulated_tool_result_bytes: min(
133                self.max_accumulated_tool_result_bytes,
134                other.max_accumulated_tool_result_bytes,
135            ),
136            max_context_tokens: min(self.max_context_tokens, other.max_context_tokens),
137            max_cost_usd: min(self.max_cost_usd, other.max_cost_usd),
138            max_round_renewals: self.max_round_renewals.min(other.max_round_renewals),
139        }
140    }
141
142    /// The budget for a turn the session starts by itself because events
143    /// arrived: this (session) budget narrowed by the event role's
144    /// (`turn_budget.event.*`), so a `/budget` the user set still applies.
145    pub fn for_event_turn(&self, config: &agent_core::config::TurnBudgetsConfig) -> TurnBudget {
146        self.tighter(&TurnBudget::from_config(TurnRole::Event, config))
147    }
148
149    /// Whether a wall-clock limit can end (or checkpoint) a turn. A zero
150    /// limit counts as exhausted at once and never as a time checkpoint.
151    pub fn has_time_limit(&self) -> bool {
152        self.max_elapsed.is_some_and(|d| !d.is_zero())
153    }
154
155    /// The wall-clock limit in seconds, for diagnostics (`None` = no limit).
156    pub fn max_elapsed_secs(&self) -> Option<u64> {
157        self.max_elapsed.map(|d| d.as_secs())
158    }
159
160    /// Role defaults overlaid with the typed config. Unset fields keep the
161    /// compiled defaults; `unlimited` clears a round, tool-call or
162    /// tool-result-byte limit (`0` means none allowed, deprecated); `0`
163    /// seconds means no wall-clock limit.
164    pub fn from_config(role: TurnRole, config: &agent_core::config::TurnBudgetsConfig) -> Self {
165        let overrides = match role {
166            TurnRole::Foreground => &config.foreground,
167            TurnRole::Event => &config.event,
168            TurnRole::Worker => &config.worker,
169        };
170        let mut budget = Self::for_role(role);
171        if let Some(v) = overrides.max_provider_rounds {
172            budget.max_provider_rounds = v.limit();
173        }
174        if let Some(v) = overrides.max_tool_calls {
175            budget.max_tool_calls = v.limit();
176        }
177        if let Some(v) = overrides.max_elapsed_secs {
178            budget.max_elapsed = (v > 0).then(|| Duration::from_secs(v));
179        }
180        if let Some(v) = overrides.max_accumulated_tool_result_bytes {
181            budget.max_accumulated_tool_result_bytes = v.limit();
182        }
183        if overrides.max_context_tokens.is_some() {
184            budget.max_context_tokens = overrides.max_context_tokens;
185        }
186        if overrides.max_cost_usd.is_some() {
187            budget.max_cost_usd = overrides.max_cost_usd;
188        }
189        if let Some(v) = overrides.max_round_renewals {
190            budget.max_round_renewals = v;
191        }
192        budget
193    }
194}
195
196/// Moves an event-started turn onto the session's budget once the owner's
197/// own message is delivered into it (a steer): from then on someone is
198/// watching. Shared by the session actor (which sends the owner's steer) and
199/// the turn's stream (which drains steering). Sending an owner steer and
200/// draining steering hold the same lock, so the stream takes the budget in
201/// the very drain that delivers the owner's message, never before it and
202/// never for an event or note steered into the same channel.
203#[derive(Debug, Default)]
204pub struct OwnerSteerBudget {
205    pending: std::sync::Mutex<Option<TurnBudget>>,
206}
207
208impl OwnerSteerBudget {
209    /// Session side: `send` the owner's message into the turn; if it went in,
210    /// the turn takes `budget` when the stream delivers it.
211    pub fn send_owner_steer(&self, budget: TurnBudget, send: impl FnOnce() -> bool) -> bool {
212        let mut pending = self.pending.lock().unwrap_or_else(|p| p.into_inner());
213        let sent = send();
214        if sent {
215            *pending = Some(budget);
216        }
217        sent
218    }
219
220    /// Stream side: run `drain` (the steering drain) and return the budget
221    /// an owner message delivered by it, or by an earlier drain, carries.
222    pub fn drain<R>(&self, drain: impl FnOnce() -> R) -> (R, Option<TurnBudget>) {
223        let mut pending = self.pending.lock().unwrap_or_else(|p| p.into_inner());
224        let drained = drain();
225        (drained, pending.take())
226    }
227}
228
229/// Shared provider-usage counters, updated at the single authoritative
230/// Usage emission sites in the transport and read by the stream loop for
231/// the optional token/cost dimensions. Metadata only.
232#[derive(Debug, Default)]
233pub struct UsageCounters {
234    /// Sum of individually priced requests, rounded UP to nanodollars.
235    /// Saturation is fail-closed for any finite configured budget.
236    cost_nanodollars: std::sync::atomic::AtomicU64,
237    input_tokens: std::sync::atomic::AtomicU64,
238    output_tokens: std::sync::atomic::AtomicU64,
239    cache_read_tokens: std::sync::atomic::AtomicU64,
240    cache_write_tokens: std::sync::atomic::AtomicU64,
241    /// Latest request's context footprint (input + cache read + cache
242    /// write) — the context-token dimension compares against this.
243    latest_context_tokens: std::sync::atomic::AtomicU64,
244    /// Number of requests booked; lets a caller tell whether the latest
245    /// footprint belongs to the request it just sent.
246    records: std::sync::atomic::AtomicU64,
247}
248
249impl UsageCounters {
250    /// Book exactly one request, using its model and optional cache TTL split.
251    /// Unknown models retain the pricing table's nonzero Sonnet fallback.
252    #[allow(clippy::too_many_arguments)]
253    pub fn record_priced(
254        &self,
255        model: &str,
256        input: u64,
257        output: u64,
258        cache_read: u64,
259        cache_write: u64,
260        cache_write_5m: Option<u64>,
261        cache_write_1h: Option<u64>,
262    ) {
263        use std::sync::atomic::Ordering;
264        let cost = agent_core::pricing::calculate_cost_optional_split(
265            model,
266            input,
267            output,
268            cache_read,
269            cache_write,
270            cache_write_5m,
271            cache_write_1h,
272        );
273        let units = cost * 1_000_000_000.0;
274        let units = if !units.is_finite() || units < 0.0 {
275            u64::MAX
276        } else {
277            units.ceil() as u64
278        };
279        Self::saturating_add(&self.cost_nanodollars, units);
280        Self::saturating_add(&self.input_tokens, input);
281        Self::saturating_add(&self.output_tokens, output);
282        Self::saturating_add(&self.cache_read_tokens, cache_read);
283        Self::saturating_add(&self.cache_write_tokens, cache_write);
284        self.latest_context_tokens.store(
285            input.saturating_add(cache_read).saturating_add(cache_write),
286            Ordering::Relaxed,
287        );
288        Self::saturating_add(&self.records, 1);
289    }
290
291    pub fn records(&self) -> u64 {
292        self.records.load(std::sync::atomic::Ordering::Relaxed)
293    }
294
295    fn saturating_add(counter: &std::sync::atomic::AtomicU64, value: u64) {
296        use std::sync::atomic::Ordering;
297        let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |old| {
298            Some(old.saturating_add(value))
299        });
300    }
301
302    pub fn cost_usd(&self) -> f64 {
303        let units = self
304            .cost_nanodollars
305            .load(std::sync::atomic::Ordering::Relaxed);
306        if units == u64::MAX {
307            f64::MAX
308        } else {
309            units as f64 / 1_000_000_000.0
310        }
311    }
312
313    pub fn latest_context_tokens(&self) -> u64 {
314        self.latest_context_tokens
315            .load(std::sync::atomic::Ordering::Relaxed)
316    }
317
318    /// Accumulated (input, output, cache_read, cache_write) totals.
319    pub fn totals(&self) -> (u64, u64, u64, u64) {
320        use std::sync::atomic::Ordering;
321        (
322            self.input_tokens.load(Ordering::Relaxed),
323            self.output_tokens.load(Ordering::Relaxed),
324            self.cache_read_tokens.load(Ordering::Relaxed),
325            self.cache_write_tokens.load(Ordering::Relaxed),
326        )
327    }
328}
329
330/// One turn's running budget meter. NOT thread-shared: owned by the
331/// stream loop; the only cross-task input is the [`UsageCounters`] cell.
332#[derive(Debug)]
333pub struct TurnBudgetMeter {
334    budget: TurnBudget,
335    started: Instant,
336    rounds: u32,
337    tool_calls: u32,
338    tool_result_bytes: usize,
339    round_renewals: u32,
340}
341
342impl TurnBudgetMeter {
343    pub fn new(budget: TurnBudget) -> Self {
344        Self {
345            budget,
346            started: Instant::now(),
347            rounds: 0,
348            tool_calls: 0,
349            tool_result_bytes: 0,
350            round_renewals: 0,
351        }
352    }
353
354    pub fn budget(&self) -> &TurnBudget {
355        &self.budget
356    }
357
358    /// Switch the turn to `budget` mid-turn. Usage so far (rounds, tool
359    /// calls, tool-result bytes, renewals, elapsed time) keeps counting.
360    pub fn switch_budget(&mut self, budget: TurnBudget) {
361        self.budget = budget;
362    }
363
364    pub(crate) fn wall_clock_exceeded(&self) -> bool {
365        self.budget
366            .max_elapsed
367            .is_some_and(|max| self.started.elapsed() >= max)
368    }
369
370    /// A successfully committed durable context successor starts a new time
371    /// segment. Never call on an attempted/failed save or a model checkpoint.
372    /// Cumulative resource/cost limits and round renewals remain unchanged.
373    pub(crate) fn start_context_segment(&mut self) {
374        self.started = Instant::now();
375    }
376
377    /// Charge one provider round. Checked BEFORE the provider call:
378    /// wall-clock first (a stale turn must not spend another request),
379    /// then the exact round cap (when one is configured).
380    pub fn begin_round(&mut self) -> Result<(), BudgetDimension> {
381        if self.wall_clock_exceeded() {
382            return Err(BudgetDimension::WallClock);
383        }
384        if self
385            .budget
386            .max_provider_rounds
387            .is_some_and(|max| self.rounds >= max)
388        {
389            return Err(BudgetDimension::ProviderRounds);
390        }
391        self.rounds = self.rounds.saturating_add(1);
392        Ok(())
393    }
394
395    /// Attempt a bounded graceful extension after the provider-round cap is
396    /// hit. Resets the round counter and returns the number of renewals still
397    /// remaining, or `None` when the renewal budget is exhausted. Wall-clock
398    /// is deliberately untouched — [`Self::begin_round`] re-checks it, so a
399    /// stale turn cannot extend past `max_elapsed`. Only meaningful right
400    /// after `begin_round` returned [`BudgetDimension::ProviderRounds`].
401    pub fn try_renew_rounds(&mut self) -> Option<u32> {
402        if self.round_renewals >= self.budget.max_round_renewals {
403            return None;
404        }
405        self.round_renewals += 1;
406        self.rounds = 0;
407        Some(self.budget.max_round_renewals - self.round_renewals)
408    }
409
410    /// Number of graceful round renewals already consumed this turn.
411    pub fn round_renewals_used(&self) -> u32 {
412        self.round_renewals
413    }
414
415    /// Wall-clock consumed by this turn so far. Diagnostics only — the
416    /// enforcement path uses [`Self::wall_clock_exceeded`].
417    pub fn elapsed(&self) -> Duration {
418        self.started.elapsed()
419    }
420
421    /// Metadata-only diagnostic; preserve the typed outcome for every frontend
422    /// and autonomous driver. Exhaustion is a local stop, not provider fallback.
423    pub fn exhaustion_error(&self, dimension: BudgetDimension) -> agent_core::TurnError {
424        let mut error = agent_core::TurnError::budget(dimension);
425        if dimension == BudgetDimension::WallClock {
426            error.message = format!(
427                "turn stopped at its {}s time limit (ran {}s). History is kept; send a prompt to continue. \
428                 The limit comes from turn_budget.<role>.max_elapsed_secs or /budget time; /budget time off removes it.",
429                self.budget.max_elapsed_secs().unwrap_or(0),
430                self.elapsed().as_secs(),
431            );
432        }
433        error
434    }
435
436    /// Provider rounds charged since the last renewal (reset by
437    /// [`Self::try_renew_rounds`]). Diagnostics only.
438    pub fn rounds_used(&self) -> u32 {
439        self.rounds
440    }
441
442    /// Tool calls charged this turn. Diagnostics only.
443    pub fn tool_calls_used(&self) -> u32 {
444        self.tool_calls
445    }
446
447    /// Accumulated tool-result bytes charged this turn. Diagnostics only.
448    pub fn tool_result_bytes_used(&self) -> usize {
449        self.tool_result_bytes
450    }
451
452    /// Remaining tool-call allowance (for exact-cap batch splitting);
453    /// `u32::MAX` when no tool-call limit is configured.
454    pub fn remaining_tool_calls(&self) -> u32 {
455        self.budget
456            .max_tool_calls
457            .map_or(u32::MAX, |max| max.saturating_sub(self.tool_calls))
458    }
459
460    /// Charge `n` executed tool calls (the caller split the batch against
461    /// [`Self::remaining_tool_calls`], so this never over-charges).
462    pub fn charge_tool_calls(&mut self, n: u32) {
463        self.tool_calls = self.tool_calls.saturating_add(n);
464    }
465
466    /// Charge the bytes of one round's tool results; exceeding a configured
467    /// accumulated cap exhausts the ToolResultBytes dimension.
468    pub fn charge_tool_result_bytes(&mut self, bytes: usize) -> Result<(), BudgetDimension> {
469        self.tool_result_bytes = self.tool_result_bytes.saturating_add(bytes);
470        if self
471            .budget
472            .max_accumulated_tool_result_bytes
473            .is_some_and(|max| self.tool_result_bytes > max)
474        {
475            return Err(BudgetDimension::ToolResultBytes);
476        }
477        Ok(())
478    }
479
480    /// Optional dimensions, consulted after each provider round from the
481    /// shared usage counters. Costs are summed per request, never repriced
482    /// from token totals; both checks are no-ops when unconfigured.
483    pub fn check_usage(&self, usage: &UsageCounters) -> Result<(), BudgetDimension> {
484        if let Some(max_context) = self.budget.max_context_tokens {
485            if usage.latest_context_tokens() > max_context {
486                return Err(BudgetDimension::InputTokens);
487            }
488        }
489        if let Some(max_cost) = self.budget.max_cost_usd {
490            if usage.cost_usd() > max_cost {
491                return Err(BudgetDimension::CostUsd);
492            }
493        }
494        Ok(())
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501    use agent_core::config::TurnLimit;
502
503    #[test]
504    fn an_owner_steer_switches_the_budget_and_keeps_the_usage() {
505        let event = TurnBudget::from_config(TurnRole::Event, &Default::default());
506        let session = TurnBudget::for_role(TurnRole::Foreground);
507        let mut meter = TurnBudgetMeter::new(TurnBudget {
508            max_provider_rounds: Some(2),
509            ..event
510        });
511        meter.begin_round().unwrap();
512        meter.begin_round().unwrap();
513        meter.charge_tool_calls(3);
514        assert_eq!(meter.begin_round(), Err(BudgetDimension::ProviderRounds));
515
516        let lift = OwnerSteerBudget::default();
517        // Nothing sent: the drain carries no budget.
518        assert_eq!(lift.drain(|| ()).1, None);
519        // A send that fails (turn over) arms nothing.
520        assert!(!lift.send_owner_steer(session.clone(), || false));
521        assert_eq!(lift.drain(|| ()).1, None);
522        assert!(lift.send_owner_steer(session.clone(), || true));
523        let (_, budget) = lift.drain(|| ());
524        meter.switch_budget(budget.expect("the owner's steer carries the session budget"));
525        assert_eq!(lift.drain(|| ()).1, None, "taken once");
526
527        assert_eq!(meter.budget(), &session);
528        assert_eq!(meter.rounds_used(), 2, "usage keeps counting");
529        assert_eq!(meter.tool_calls_used(), 3);
530        meter.begin_round().unwrap();
531        assert_eq!(meter.rounds_used(), 3);
532    }
533
534    #[test]
535    fn partial_ttl_metadata_cannot_drop_cost_or_lower_tier() {
536        for (input, aggregate, c5, c1, expected) in [
537            (0, 100_001, Some(0), None, 0.100001),
538            (0, 100_001, None, Some(0), 0.100001),
539            (60_000, 40_000, Some(10_000), None, 0.01325),
540            (60_000, 40_001, Some(10_000), None, 0.066251),
541            (60_000, 40_001, None, Some(10_000), 0.070001),
542            (60_000, 40_001, Some(10_000), Some(10_000), 0.066251),
543            (60_000, 1, Some(20_000), Some(20_001), 0.062501),
544            (60_000, 1, Some(40_001), None, 0.055000625),
545            (60_000, 40_001, Some(40_001), None, 0.055000625),
546            (60_000, 40_001, None, Some(40_001), 0.070001),
547            (60_000, 40_001, Some(10_000), Some(30_001), 0.066251),
548            (60_000, 40_001, None, None, 0.055000625),
549        ] {
550            let usage = UsageCounters::default();
551            usage.record_priced("claude-haiku-5.5", input, 0, 0, aggregate, c5, c1);
552            assert!(
553                (usage.cost_usd() - expected).abs() < 2e-9,
554                "input={input}, aggregate={aggregate}, split={c5:?}/{c1:?}"
555            );
556            let mut budget = TurnBudget::for_role(TurnRole::Foreground);
557            budget.max_cost_usd = Some(expected / 2.0);
558            assert_eq!(
559                TurnBudgetMeter::new(budget).check_usage(&usage),
560                Err(BudgetDimension::CostUsd)
561            );
562        }
563        for (aggregate, c5, c1) in [
564            (u64::MAX, Some(1), None),
565            (1, Some(u64::MAX), Some(u64::MAX)),
566        ] {
567            let usage = UsageCounters::default();
568            usage.record_priced("claude-haiku-5.5", 0, 0, 0, aggregate, c5, c1);
569            assert_eq!(usage.cost_usd(), f64::MAX);
570        }
571    }
572
573    #[test]
574    fn costs_are_per_request_not_cumulative_tiers_or_current_model() {
575        let usage = UsageCounters::default();
576        for _ in 0..2 {
577            usage.record_priced("claude-haiku-5.5", 60_000, 1_000, 0, 0, None, None);
578        }
579        assert!((usage.cost_usd() - 0.013).abs() < 3e-9);
580        let mut budget = TurnBudget::for_role(TurnRole::Foreground);
581        budget.max_cost_usd = Some(0.03);
582        let meter = TurnBudgetMeter::new(budget);
583        assert_eq!(meter.check_usage(&usage), Ok(()));
584        usage.record_priced("claude-haiku-5-5", 100_001, 1_000, 0, 0, None, None);
585        assert!((usage.cost_usd() - 0.0655005).abs() < 4e-9);
586        assert_eq!(meter.check_usage(&usage), Err(BudgetDimension::CostUsd));
587        usage.record_priced("claude-opus-4.1", 1_000_000, 1_000_000, 0, 0, None, None);
588        usage.record_priced("claude-opus-4.8", 1_000_000, 1_000_000, 0, 0, None, None);
589        assert!((usage.cost_usd() - 120.0655005).abs() < 6e-9);
590        assert_eq!(usage.latest_context_tokens(), 1_000_000);
591    }
592
593    #[test]
594    fn request_tier_includes_reads_and_both_write_ttls() {
595        for extra in [0, 1] {
596            let usage = UsageCounters::default();
597            usage.record_priced(
598                "claude-haiku-5-5",
599                60_000,
600                1_000,
601                20_000 + extra,
602                20_000,
603                Some(5_000),
604                Some(15_000),
605            );
606            let expected = (0.006 + 0.0002 + 0.000625 + 0.003 + 0.0005 + extra as f64 * 0.00000001)
607                * if extra == 0 { 1.0 } else { 5.0 };
608            assert!((usage.cost_usd() - expected).abs() < 2e-9);
609            assert_eq!(usage.latest_context_tokens(), 100_000 + extra);
610        }
611    }
612
613    #[test]
614    fn unknown_models_and_saturation_cannot_silently_zero_cost() {
615        let usage = UsageCounters::default();
616        usage.record_priced("unknown-model", 1_000_000, 0, 0, 0, None, None);
617        assert!((usage.cost_usd() - 3.0).abs() < 2e-9);
618        for _ in 0..2 {
619            usage.record_priced(
620                "claude-opus-4.1",
621                u64::MAX,
622                u64::MAX,
623                u64::MAX,
624                u64::MAX,
625                None,
626                None,
627            );
628        }
629        assert_eq!(usage.totals(), (u64::MAX, u64::MAX, u64::MAX, u64::MAX));
630        assert_eq!(usage.cost_usd(), f64::MAX);
631        assert_eq!(usage.latest_context_tokens(), u64::MAX);
632    }
633
634    #[test]
635    fn role_defaults_are_typed_and_only_foreground_is_unlimited() {
636        let fg = TurnBudget::for_role(TurnRole::Foreground);
637        let event = TurnBudget::for_role(TurnRole::Event);
638        let worker = TurnBudget::for_role(TurnRole::Worker);
639        // Interactive turns have no round, tool-call or tool-result-byte
640        // limit unless one is configured (#219).
641        assert_eq!(fg.max_provider_rounds, None);
642        assert_eq!(fg.max_tool_calls, None);
643        assert_eq!(fg.max_accumulated_tool_result_bytes, None);
644        // No role has a wall-clock limit unless one is configured.
645        for budget in [&fg, &event, &worker] {
646            assert_eq!(budget.max_elapsed, None);
647            assert!(!budget.has_time_limit());
648        }
649        assert!(fg.max_context_tokens.is_none());
650        assert!(fg.max_cost_usd.is_none());
651        // Graceful continuation is most generous for foreground (human
652        // watching) and disabled for event turns (nobody watching).
653        assert_eq!(event.max_round_renewals, 0);
654        assert!(worker.max_round_renewals > 0);
655        assert!(fg.max_round_renewals >= worker.max_round_renewals);
656    }
657
658    #[test]
659    fn event_role_keeps_finite_per_turn_defaults() {
660        // Nobody typed an event-started turn, so one turn must stay bounded
661        // by default.
662        let event = TurnBudget::for_role(TurnRole::Event);
663        assert_eq!(event.max_provider_rounds, Some(24));
664        assert_eq!(event.max_tool_calls, Some(96));
665        assert_eq!(
666            event.max_accumulated_tool_result_bytes,
667            Some(8 * 1024 * 1024)
668        );
669        assert_eq!(event.max_round_renewals, 0);
670
671        let mut meter = TurnBudgetMeter::new(event);
672        for _ in 0..24 {
673            assert_eq!(meter.begin_round(), Ok(()));
674        }
675        assert_eq!(meter.begin_round(), Err(BudgetDimension::ProviderRounds));
676        assert_eq!(meter.try_renew_rounds(), None);
677        assert_eq!(meter.remaining_tool_calls(), 96);
678    }
679
680    #[test]
681    fn event_role_has_its_own_config_and_ignores_the_removed_autonomous_role() {
682        // Turns started by events are configured under turn_budget.event;
683        // turn_budget.autonomous.* (the removed `synaps agent`) is ignored.
684        let cfg = agent_core::config::load_config_from_str(
685            "turn_budget.event.max_provider_rounds = unlimited\n\
686             turn_budget.event.max_tool_calls = 300\n\
687             turn_budget.autonomous.max_tool_calls = 5\n",
688        )
689        .turn_budgets;
690        let event = TurnBudget::from_config(TurnRole::Event, &cfg);
691        assert_eq!(event.max_provider_rounds, None);
692        assert_eq!(event.max_tool_calls, Some(300));
693        assert_eq!(
694            event.max_accumulated_tool_result_bytes,
695            Some(8 * 1024 * 1024)
696        );
697    }
698
699    #[test]
700    fn tighter_takes_the_smaller_limit_per_dimension() {
701        let session = TurnBudget {
702            max_elapsed: Some(Duration::from_secs(600)),
703            max_cost_usd: Some(2.0),
704            max_tool_calls: Some(500),
705            ..TurnBudget::for_role(TurnRole::Foreground)
706        };
707        let event = TurnBudget {
708            max_cost_usd: Some(5.0),
709            ..TurnBudget::for_role(TurnRole::Event)
710        };
711        let combined = session.tighter(&event);
712        assert_eq!(combined.max_provider_rounds, Some(24));
713        assert_eq!(combined.max_tool_calls, Some(96));
714        assert_eq!(
715            combined.max_accumulated_tool_result_bytes,
716            Some(8 * 1024 * 1024)
717        );
718        assert_eq!(combined.max_elapsed, Some(Duration::from_secs(600)));
719        assert_eq!(combined.max_cost_usd, Some(2.0));
720        assert_eq!(combined.max_context_tokens, None);
721        assert_eq!(combined.max_round_renewals, 0);
722        assert_eq!(combined, event.tighter(&session));
723    }
724
725    #[test]
726    fn worker_role_keeps_a_finite_round_cap_of_192() {
727        // 64 rounds per allowance x (1 + 2 renewals) = the pre-#219 cap.
728        let worker = TurnBudget::for_role(TurnRole::Worker);
729        assert_eq!(worker.max_provider_rounds, Some(64));
730        assert_eq!(worker.max_round_renewals, 2);
731        let mut meter = TurnBudgetMeter::new(worker);
732        let mut rounds = 0u32;
733        loop {
734            match meter.begin_round() {
735                Ok(()) => rounds += 1,
736                Err(BudgetDimension::ProviderRounds) => {
737                    if meter.try_renew_rounds().is_none() {
738                        break;
739                    }
740                }
741                Err(other) => panic!("unexpected dimension {other:?}"),
742            }
743            assert!(rounds <= 10_000, "worker round cap must be finite");
744        }
745        assert_eq!(rounds, 192);
746    }
747
748    #[test]
749    fn config_overlays_only_set_fields() {
750        let mut cfg = agent_core::config::TurnBudgetsConfig::default();
751        cfg.event.max_provider_rounds = Some(TurnLimit::Max(2));
752        cfg.event.max_elapsed_secs = Some(9);
753        cfg.event.max_round_renewals = Some(3);
754        let budget = TurnBudget::from_config(TurnRole::Event, &cfg);
755        assert_eq!(budget.max_provider_rounds, Some(2));
756        assert_eq!(budget.max_elapsed, Some(Duration::from_secs(9)));
757        assert_eq!(budget.max_round_renewals, 3);
758        assert_eq!(
759            budget.max_tool_calls,
760            TurnBudget::for_role(TurnRole::Event).max_tool_calls
761        );
762    }
763
764    #[test]
765    fn zero_seconds_in_config_means_no_time_limit() {
766        let mut cfg = agent_core::config::TurnBudgetsConfig::default();
767        cfg.foreground.max_elapsed_secs = Some(0);
768        let budget = TurnBudget::from_config(TurnRole::Foreground, &cfg);
769        assert_eq!(budget.max_elapsed, None);
770    }
771
772    #[test]
773    fn config_sets_round_tool_call_and_byte_limits_and_unlimited_clears_them() {
774        for role in [
775            TurnRole::Foreground,
776            TurnRole::Event,
777            TurnRole::Worker,
778        ] {
779            let mut cfg = agent_core::config::TurnBudgetsConfig::default();
780            let overrides = match role {
781                TurnRole::Foreground => &mut cfg.foreground,
782                TurnRole::Event => &mut cfg.event,
783                TurnRole::Worker => &mut cfg.worker,
784            };
785            overrides.max_provider_rounds = Some(TurnLimit::Max(5));
786            overrides.max_tool_calls = Some(TurnLimit::Max(7));
787            overrides.max_accumulated_tool_result_bytes = Some(TurnLimit::Max(11));
788            let budget = TurnBudget::from_config(role, &cfg);
789            assert_eq!(budget.max_provider_rounds, Some(5));
790            assert_eq!(budget.max_tool_calls, Some(7));
791            assert_eq!(budget.max_accumulated_tool_result_bytes, Some(11));
792
793            let overrides = match role {
794                TurnRole::Foreground => &mut cfg.foreground,
795                TurnRole::Event => &mut cfg.event,
796                TurnRole::Worker => &mut cfg.worker,
797            };
798            overrides.max_provider_rounds = Some(TurnLimit::Unlimited);
799            overrides.max_tool_calls = Some(TurnLimit::Unlimited);
800            overrides.max_accumulated_tool_result_bytes = Some(TurnLimit::Unlimited);
801            let budget = TurnBudget::from_config(role, &cfg);
802            assert_eq!(budget.max_provider_rounds, None);
803            assert_eq!(budget.max_tool_calls, None);
804            assert_eq!(budget.max_accumulated_tool_result_bytes, None);
805        }
806    }
807
808    #[test]
809    fn configured_limits_are_enforced_exactly() {
810        let mut cfg = agent_core::config::TurnBudgetsConfig::default();
811        cfg.worker.max_provider_rounds = Some(TurnLimit::Max(2));
812        cfg.worker.max_round_renewals = Some(1);
813        cfg.worker.max_tool_calls = Some(TurnLimit::Max(3));
814        cfg.worker.max_accumulated_tool_result_bytes = Some(TurnLimit::Max(100));
815        let mut meter = TurnBudgetMeter::new(TurnBudget::from_config(TurnRole::Worker, &cfg));
816        for _ in 0..2 {
817            assert_eq!(meter.begin_round(), Ok(()));
818        }
819        assert_eq!(meter.begin_round(), Err(BudgetDimension::ProviderRounds));
820        assert_eq!(meter.try_renew_rounds(), Some(0));
821        for _ in 0..2 {
822            assert_eq!(meter.begin_round(), Ok(()));
823        }
824        assert_eq!(meter.begin_round(), Err(BudgetDimension::ProviderRounds));
825        assert_eq!(meter.try_renew_rounds(), None);
826
827        assert_eq!(meter.remaining_tool_calls(), 3);
828        meter.charge_tool_calls(3);
829        assert_eq!(meter.remaining_tool_calls(), 0);
830
831        assert_eq!(meter.charge_tool_result_bytes(100), Ok(()));
832        assert_eq!(
833            meter.charge_tool_result_bytes(1),
834            Err(BudgetDimension::ToolResultBytes)
835        );
836    }
837
838    #[test]
839    fn zero_in_config_means_none_allowed_for_every_role() {
840        let cfg = agent_core::config::load_config_from_str(
841            "turn_budget.foreground.max_provider_rounds = 0\n\
842             turn_budget.foreground.max_tool_calls = 0\n\
843             turn_budget.foreground.max_accumulated_tool_result_bytes = 0\n\
844             turn_budget.worker.max_provider_rounds = 0\n\
845             turn_budget.worker.max_tool_calls = 0\n\
846             turn_budget.worker.max_accumulated_tool_result_bytes = 0\n\
847             turn_budget.event.max_provider_rounds = 0\n\
848             turn_budget.event.max_tool_calls = 0\n\
849             turn_budget.event.max_accumulated_tool_result_bytes = 0\n",
850        )
851        .turn_budgets;
852        for role in [
853            TurnRole::Foreground,
854            TurnRole::Event,
855            TurnRole::Worker,
856        ] {
857            let budget = TurnBudget::from_config(role, &cfg);
858            assert_eq!(budget.max_provider_rounds, Some(0), "{role:?}");
859            assert_eq!(budget.max_tool_calls, Some(0), "{role:?}");
860            assert_eq!(
861                budget.max_accumulated_tool_result_bytes,
862                Some(0),
863                "{role:?}"
864            );
865            let mut meter = TurnBudgetMeter::new(budget);
866            assert_eq!(meter.remaining_tool_calls(), 0);
867            assert_eq!(
868                meter.charge_tool_result_bytes(1),
869                Err(BudgetDimension::ToolResultBytes)
870            );
871            // Renewals cannot turn a zero round allowance into rounds.
872            loop {
873                match meter.begin_round() {
874                    Ok(()) => panic!("{role:?}: a zero round limit allows no rounds"),
875                    Err(BudgetDimension::ProviderRounds) => {
876                        if meter.try_renew_rounds().is_none() {
877                            break;
878                        }
879                    }
880                    Err(other) => panic!("unexpected {other:?}"),
881                }
882            }
883        }
884    }
885
886    #[test]
887    fn configured_unlimited_never_exhausts() {
888        let mut cfg = agent_core::config::TurnBudgetsConfig::default();
889        cfg.event.max_provider_rounds = Some(TurnLimit::Unlimited);
890        cfg.event.max_tool_calls = Some(TurnLimit::Unlimited);
891        cfg.event.max_accumulated_tool_result_bytes = Some(TurnLimit::Unlimited);
892        let mut meter = TurnBudgetMeter::new(TurnBudget::from_config(TurnRole::Event, &cfg));
893        for _ in 0..1_000 {
894            assert_eq!(meter.begin_round(), Ok(()));
895        }
896        assert_eq!(meter.remaining_tool_calls(), u32::MAX);
897        meter.charge_tool_calls(1_000);
898        assert_eq!(meter.remaining_tool_calls(), u32::MAX);
899        assert_eq!(meter.charge_tool_result_bytes(1 << 20), Ok(()));
900    }
901
902    #[test]
903    fn an_unlimited_meter_never_exhausts_but_still_counts() {
904        let mut unlimited = agent_core::config::TurnBudgetsConfig::default();
905        unlimited.event.max_provider_rounds = Some(TurnLimit::Unlimited);
906        unlimited.event.max_tool_calls = Some(TurnLimit::Unlimited);
907        unlimited.event.max_accumulated_tool_result_bytes = Some(TurnLimit::Unlimited);
908        unlimited.worker.max_provider_rounds = Some(TurnLimit::Unlimited);
909        for budget in [
910            TurnBudget::for_role(TurnRole::Foreground),
911            TurnBudget::from_config(TurnRole::Event, &unlimited),
912            TurnBudget::from_config(TurnRole::Worker, &unlimited),
913        ] {
914            let mut meter = TurnBudgetMeter::new(budget);
915            for _ in 0..10_000 {
916                assert_eq!(meter.begin_round(), Ok(()));
917                assert_eq!(meter.remaining_tool_calls(), u32::MAX);
918                meter.charge_tool_calls(1);
919                assert_eq!(meter.charge_tool_result_bytes(64 * 1024), Ok(()));
920            }
921            assert_eq!(meter.rounds_used(), 10_000);
922            assert_eq!(meter.tool_calls_used(), 10_000);
923            assert_eq!(meter.tool_result_bytes_used(), 10_000 * 64 * 1024);
924            assert_eq!(meter.round_renewals_used(), 0);
925            assert_eq!(meter.check_usage(&UsageCounters::default()), Ok(()));
926        }
927    }
928
929    #[test]
930    fn a_turn_without_a_time_limit_never_runs_out_of_time() {
931        let mut meter = TurnBudgetMeter::new(TurnBudget::for_role(TurnRole::Foreground));
932        meter.started = Instant::now() - Duration::from_secs(30 * 24 * 60 * 60);
933        assert!(!meter.wall_clock_exceeded());
934        assert_eq!(meter.begin_round(), Ok(()));
935    }
936
937    #[test]
938    fn committed_context_segment_resets_only_time() {
939        let mut meter = TurnBudgetMeter::new(TurnBudget {
940            max_elapsed: Some(Duration::from_secs(7200)),
941            ..TurnBudget::for_role(TurnRole::Foreground)
942        });
943        meter.started = Instant::now() - Duration::from_secs(7201);
944        meter.charge_tool_calls(3);
945        meter.charge_tool_result_bytes(1234).unwrap();
946        meter.try_renew_rounds();
947        let original = meter.budget().clone();
948        assert_eq!(meter.begin_round(), Err(BudgetDimension::WallClock));
949        meter.start_context_segment();
950        assert!(meter.begin_round().is_ok());
951        assert_eq!(meter.budget(), &original);
952        assert_eq!(meter.tool_calls_used(), 3);
953        assert_eq!(meter.tool_result_bytes_used(), 1234);
954        assert_eq!(meter.round_renewals_used(), 1);
955    }
956
957    #[test]
958    fn exhaustion_diagnostic_keeps_typed_outcome_and_reports_metadata() {
959        let meter = TurnBudgetMeter::new(TurnBudget {
960            max_elapsed: Some(Duration::from_secs(7200)),
961            ..TurnBudget::for_role(TurnRole::Foreground)
962        });
963        let error = meter.exhaustion_error(BudgetDimension::WallClock);
964        assert_eq!(
965            error.outcome,
966            agent_core::TurnOutcome::BudgetExceeded {
967                dimension: BudgetDimension::WallClock
968            }
969        );
970        assert!(error.message.contains("its 7200s time limit (ran "));
971        assert!(error.message.contains("History is kept"));
972        assert!(error.message.contains("/budget time off"));
973        // Other dimensions keep the existing diagnostic and exact typed label.
974        for dimension in [
975            BudgetDimension::ToolCalls,
976            BudgetDimension::ProviderRounds,
977            BudgetDimension::ToolResultBytes,
978            BudgetDimension::InputTokens,
979            BudgetDimension::OutputTokens,
980            BudgetDimension::CostUsd,
981        ] {
982            assert_eq!(
983                meter.exhaustion_error(dimension),
984                agent_core::TurnError::budget(dimension)
985            );
986        }
987    }
988
989    #[test]
990    fn meter_enforces_exact_rounds_and_wall_clock_order() {
991        let mut meter = TurnBudgetMeter::new(TurnBudget {
992            max_provider_rounds: Some(2),
993            ..TurnBudget::for_role(TurnRole::Foreground)
994        });
995        assert!(meter.begin_round().is_ok());
996        assert!(meter.begin_round().is_ok());
997        assert_eq!(meter.begin_round(), Err(BudgetDimension::ProviderRounds));
998
999        let mut stale = TurnBudgetMeter::new(TurnBudget {
1000            max_elapsed: Some(Duration::ZERO),
1001            ..TurnBudget::for_role(TurnRole::Foreground)
1002        });
1003        // Wall clock outranks the round check.
1004        assert_eq!(stale.begin_round(), Err(BudgetDimension::WallClock));
1005    }
1006
1007    #[test]
1008    fn round_renewals_extend_a_bounded_number_of_times() {
1009        let mut meter = TurnBudgetMeter::new(TurnBudget {
1010            max_provider_rounds: Some(1),
1011            max_round_renewals: 2,
1012            ..TurnBudget::for_role(TurnRole::Foreground)
1013        });
1014        // First round of the base allowance.
1015        assert!(meter.begin_round().is_ok());
1016        // Exhausted → renew #1, then a fresh round is available.
1017        assert_eq!(meter.begin_round(), Err(BudgetDimension::ProviderRounds));
1018        assert_eq!(meter.try_renew_rounds(), Some(1));
1019        assert!(meter.begin_round().is_ok());
1020        // Exhausted again → renew #2 (last one).
1021        assert_eq!(meter.begin_round(), Err(BudgetDimension::ProviderRounds));
1022        assert_eq!(meter.try_renew_rounds(), Some(0));
1023        assert!(meter.begin_round().is_ok());
1024        // Exhausted a third time → no renewals left: hard stop.
1025        assert_eq!(meter.begin_round(), Err(BudgetDimension::ProviderRounds));
1026        assert_eq!(meter.try_renew_rounds(), None);
1027        assert_eq!(meter.round_renewals_used(), 2);
1028    }
1029
1030    #[test]
1031    fn wall_clock_still_bounds_a_renewed_turn() {
1032        let mut meter = TurnBudgetMeter::new(TurnBudget {
1033            max_provider_rounds: Some(1),
1034            max_round_renewals: 5,
1035            max_elapsed: Some(Duration::ZERO),
1036            ..TurnBudget::for_role(TurnRole::Foreground)
1037        });
1038        // Renewal resets the round counter, but begin_round re-checks the
1039        // wall clock — a stale turn cannot extend past max_elapsed.
1040        assert_eq!(meter.try_renew_rounds(), Some(4));
1041        assert_eq!(meter.begin_round(), Err(BudgetDimension::WallClock));
1042    }
1043
1044    #[test]
1045    fn event_role_has_no_graceful_renewals() {
1046        let mut meter = TurnBudgetMeter::new(TurnBudget {
1047            max_provider_rounds: Some(1),
1048            ..TurnBudget::for_role(TurnRole::Event)
1049        });
1050        assert!(meter.begin_round().is_ok());
1051        assert_eq!(meter.begin_round(), Err(BudgetDimension::ProviderRounds));
1052        // Nobody watching: the round cap is a hard stop.
1053        assert_eq!(meter.try_renew_rounds(), None);
1054    }
1055
1056    #[test]
1057    fn meter_tool_calls_and_bytes_are_exact() {
1058        let mut meter = TurnBudgetMeter::new(TurnBudget {
1059            max_tool_calls: Some(3),
1060            max_accumulated_tool_result_bytes: Some(10),
1061            ..TurnBudget::for_role(TurnRole::Foreground)
1062        });
1063        assert_eq!(meter.remaining_tool_calls(), 3);
1064        meter.charge_tool_calls(2);
1065        assert_eq!(meter.remaining_tool_calls(), 1);
1066        meter.charge_tool_calls(1);
1067        assert_eq!(meter.remaining_tool_calls(), 0);
1068
1069        assert!(meter.charge_tool_result_bytes(10).is_ok());
1070        assert_eq!(
1071            meter.charge_tool_result_bytes(1),
1072            Err(BudgetDimension::ToolResultBytes)
1073        );
1074    }
1075
1076    #[test]
1077    fn usage_counters_drive_optional_dimensions() {
1078        let usage = UsageCounters::default();
1079        usage.record_priced("claude-sonnet-4-5", 100, 50, 10, 5, None, None);
1080        assert_eq!(usage.latest_context_tokens(), 115);
1081
1082        let meter = TurnBudgetMeter::new(TurnBudget {
1083            max_context_tokens: Some(100),
1084            ..TurnBudget::for_role(TurnRole::Foreground)
1085        });
1086        assert_eq!(meter.check_usage(&usage), Err(BudgetDimension::InputTokens));
1087
1088        let cost_meter = TurnBudgetMeter::new(TurnBudget {
1089            max_cost_usd: Some(0.0),
1090            ..TurnBudget::for_role(TurnRole::Foreground)
1091        });
1092        assert_eq!(
1093            cost_meter.check_usage(&usage),
1094            Err(BudgetDimension::CostUsd)
1095        );
1096        let unlimited = TurnBudgetMeter::new(TurnBudget::for_role(TurnRole::Foreground));
1097        assert!(unlimited.check_usage(&usage).is_ok());
1098    }
1099}