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