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/// Per-turn limits (spec §8.1).
19#[derive(Debug, Clone, PartialEq)]
20pub struct TurnBudget {
21    pub max_provider_rounds: u32,
22    pub max_tool_calls: u32,
23    /// Wall-clock limit for one turn. `None` (the default for every role)
24    /// means no limit: a turn that is still making progress is never cut off
25    /// for taking long. Opt in with `turn_budget.<role>.max_elapsed_secs` or
26    /// `/budget time`. Rounds, tool calls and tool-result bytes stay bounded.
27    pub max_elapsed: Option<Duration>,
28    pub max_accumulated_tool_result_bytes: usize,
29    pub max_context_tokens: Option<u64>,
30    pub max_cost_usd: Option<f64>,
31    /// Bounded auto-renewals of the provider-round allowance (spec §8.1).
32    /// On exhaustion the stream loop may reset the round counter this many
33    /// times so a long, legitimate task continues instead of hard-failing.
34    /// Round renewal never resets wall-clock (durable context successors do); a
35    /// value of `0` means no graceful continuation (immediate hard stop).
36    pub max_round_renewals: u32,
37}
38
39/// Execution role a turn runs under.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum TurnRole {
42    /// Interactive foreground session.
43    Foreground,
44    /// Autonomous/watcher sessions (no human in the loop per turn).
45    Autonomous,
46    /// Delegated worker/subagent sessions.
47    Worker,
48}
49
50impl TurnBudget {
51    /// Compiled per-role defaults. Foreground is the most generous (a
52    /// human is watching); autonomous is the tightest (nobody is);
53    /// workers sit between (bounded delegation with a supervising turn).
54    pub fn for_role(role: TurnRole) -> Self {
55        match role {
56            TurnRole::Foreground => Self {
57                max_provider_rounds: 128,
58                max_tool_calls: 512,
59                max_elapsed: None,
60                max_accumulated_tool_result_bytes: 32 * 1024 * 1024,
61                max_context_tokens: None,
62                max_cost_usd: None,
63                // A human is watching and can interrupt; let long agentic
64                // tasks continue through several checkpoints.
65                max_round_renewals: 8,
66            },
67            TurnRole::Autonomous => Self {
68                max_provider_rounds: 24,
69                max_tool_calls: 96,
70                max_elapsed: None,
71                max_accumulated_tool_result_bytes: 8 * 1024 * 1024,
72                max_context_tokens: None,
73                max_cost_usd: None,
74                // Nobody is watching: no graceful extension — the round cap is
75                // a hard stop.
76                max_round_renewals: 0,
77            },
78            TurnRole::Worker => Self {
79                max_provider_rounds: 64,
80                max_tool_calls: 256,
81                max_elapsed: None,
82                max_accumulated_tool_result_bytes: 16 * 1024 * 1024,
83                max_context_tokens: None,
84                max_cost_usd: None,
85                // Bounded delegation under a supervising turn.
86                max_round_renewals: 2,
87            },
88        }
89    }
90
91    /// Whether a wall-clock limit can end (or checkpoint) a turn. A zero
92    /// limit counts as exhausted at once and never as a time checkpoint.
93    pub fn has_time_limit(&self) -> bool {
94        self.max_elapsed.is_some_and(|d| !d.is_zero())
95    }
96
97    /// The wall-clock limit in seconds, for diagnostics (`None` = no limit).
98    pub fn max_elapsed_secs(&self) -> Option<u64> {
99        self.max_elapsed.map(|d| d.as_secs())
100    }
101
102    /// Role defaults overlaid with the typed config (unset fields keep
103    /// the compiled defaults).
104    pub fn from_config(role: TurnRole, config: &agent_core::config::TurnBudgetsConfig) -> Self {
105        let overrides = match role {
106            TurnRole::Foreground => &config.foreground,
107            TurnRole::Autonomous => &config.autonomous,
108            TurnRole::Worker => &config.worker,
109        };
110        let mut budget = Self::for_role(role);
111        if let Some(v) = overrides.max_provider_rounds {
112            budget.max_provider_rounds = v;
113        }
114        if let Some(v) = overrides.max_tool_calls {
115            budget.max_tool_calls = v;
116        }
117        if let Some(v) = overrides.max_elapsed_secs {
118            // 0 = no wall-clock limit.
119            budget.max_elapsed = (v > 0).then(|| Duration::from_secs(v));
120        }
121        if let Some(v) = overrides.max_accumulated_tool_result_bytes {
122            budget.max_accumulated_tool_result_bytes = v;
123        }
124        if overrides.max_context_tokens.is_some() {
125            budget.max_context_tokens = overrides.max_context_tokens;
126        }
127        if overrides.max_cost_usd.is_some() {
128            budget.max_cost_usd = overrides.max_cost_usd;
129        }
130        if let Some(v) = overrides.max_round_renewals {
131            budget.max_round_renewals = v;
132        }
133        budget
134    }
135}
136
137/// Shared provider-usage counters, updated at the single authoritative
138/// Usage emission sites in the transport and read by the stream loop for
139/// the optional token/cost dimensions. Metadata only.
140#[derive(Debug, Default)]
141pub struct UsageCounters {
142    /// Sum of individually priced requests, rounded UP to nanodollars.
143    /// Saturation is fail-closed for any finite configured budget.
144    cost_nanodollars: std::sync::atomic::AtomicU64,
145    input_tokens: std::sync::atomic::AtomicU64,
146    output_tokens: std::sync::atomic::AtomicU64,
147    cache_read_tokens: std::sync::atomic::AtomicU64,
148    cache_write_tokens: std::sync::atomic::AtomicU64,
149    /// Latest request's context footprint (input + cache read + cache
150    /// write) — the context-token dimension compares against this.
151    latest_context_tokens: std::sync::atomic::AtomicU64,
152}
153
154impl UsageCounters {
155    /// Book exactly one request, using its model and optional cache TTL split.
156    /// Unknown models retain the pricing table's nonzero Sonnet fallback.
157    #[allow(clippy::too_many_arguments)]
158    pub fn record_priced(
159        &self,
160        model: &str,
161        input: u64,
162        output: u64,
163        cache_read: u64,
164        cache_write: u64,
165        cache_write_5m: Option<u64>,
166        cache_write_1h: Option<u64>,
167    ) {
168        use std::sync::atomic::Ordering;
169        let cost = agent_core::pricing::calculate_cost_optional_split(
170            model,
171            input,
172            output,
173            cache_read,
174            cache_write,
175            cache_write_5m,
176            cache_write_1h,
177        );
178        let units = cost * 1_000_000_000.0;
179        let units = if !units.is_finite() || units < 0.0 {
180            u64::MAX
181        } else {
182            units.ceil() as u64
183        };
184        Self::saturating_add(&self.cost_nanodollars, units);
185        Self::saturating_add(&self.input_tokens, input);
186        Self::saturating_add(&self.output_tokens, output);
187        Self::saturating_add(&self.cache_read_tokens, cache_read);
188        Self::saturating_add(&self.cache_write_tokens, cache_write);
189        self.latest_context_tokens.store(
190            input.saturating_add(cache_read).saturating_add(cache_write),
191            Ordering::Relaxed,
192        );
193    }
194
195    fn saturating_add(counter: &std::sync::atomic::AtomicU64, value: u64) {
196        use std::sync::atomic::Ordering;
197        let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |old| {
198            Some(old.saturating_add(value))
199        });
200    }
201
202    pub fn cost_usd(&self) -> f64 {
203        let units = self
204            .cost_nanodollars
205            .load(std::sync::atomic::Ordering::Relaxed);
206        if units == u64::MAX {
207            f64::MAX
208        } else {
209            units as f64 / 1_000_000_000.0
210        }
211    }
212
213    pub fn latest_context_tokens(&self) -> u64 {
214        self.latest_context_tokens
215            .load(std::sync::atomic::Ordering::Relaxed)
216    }
217
218    /// Accumulated (input, output, cache_read, cache_write) totals.
219    pub fn totals(&self) -> (u64, u64, u64, u64) {
220        use std::sync::atomic::Ordering;
221        (
222            self.input_tokens.load(Ordering::Relaxed),
223            self.output_tokens.load(Ordering::Relaxed),
224            self.cache_read_tokens.load(Ordering::Relaxed),
225            self.cache_write_tokens.load(Ordering::Relaxed),
226        )
227    }
228}
229
230/// One turn's running budget meter. NOT thread-shared: owned by the
231/// stream loop; the only cross-task input is the [`UsageCounters`] cell.
232#[derive(Debug)]
233pub struct TurnBudgetMeter {
234    budget: TurnBudget,
235    started: Instant,
236    rounds: u32,
237    tool_calls: u32,
238    tool_result_bytes: usize,
239    round_renewals: u32,
240}
241
242impl TurnBudgetMeter {
243    pub fn new(budget: TurnBudget) -> Self {
244        Self {
245            budget,
246            started: Instant::now(),
247            rounds: 0,
248            tool_calls: 0,
249            tool_result_bytes: 0,
250            round_renewals: 0,
251        }
252    }
253
254    pub fn budget(&self) -> &TurnBudget {
255        &self.budget
256    }
257
258    pub(crate) fn wall_clock_exceeded(&self) -> bool {
259        self.budget
260            .max_elapsed
261            .is_some_and(|max| self.started.elapsed() >= max)
262    }
263
264    /// A successfully committed durable context successor starts a new time
265    /// segment. Never call on an attempted/failed save or a model checkpoint.
266    /// Cumulative resource/cost limits and round renewals remain unchanged.
267    pub(crate) fn start_context_segment(&mut self) {
268        self.started = Instant::now();
269    }
270
271    /// Charge one provider round. Checked BEFORE the provider call:
272    /// wall-clock first (a stale turn must not spend another request),
273    /// then the exact round cap.
274    pub fn begin_round(&mut self) -> Result<(), BudgetDimension> {
275        if self.wall_clock_exceeded() {
276            return Err(BudgetDimension::WallClock);
277        }
278        if self.rounds >= self.budget.max_provider_rounds {
279            return Err(BudgetDimension::ProviderRounds);
280        }
281        self.rounds += 1;
282        Ok(())
283    }
284
285    /// Attempt a bounded graceful extension after the provider-round cap is
286    /// hit. Resets the round counter and returns the number of renewals still
287    /// remaining, or `None` when the renewal budget is exhausted. Wall-clock
288    /// is deliberately untouched — [`Self::begin_round`] re-checks it, so a
289    /// stale turn cannot extend past `max_elapsed`. Only meaningful right
290    /// after `begin_round` returned [`BudgetDimension::ProviderRounds`].
291    pub fn try_renew_rounds(&mut self) -> Option<u32> {
292        if self.round_renewals >= self.budget.max_round_renewals {
293            return None;
294        }
295        self.round_renewals += 1;
296        self.rounds = 0;
297        Some(self.budget.max_round_renewals - self.round_renewals)
298    }
299
300    /// Number of graceful round renewals already consumed this turn.
301    pub fn round_renewals_used(&self) -> u32 {
302        self.round_renewals
303    }
304
305    /// Wall-clock consumed by this turn so far. Diagnostics only — the
306    /// enforcement path uses [`Self::wall_clock_exceeded`].
307    pub fn elapsed(&self) -> Duration {
308        self.started.elapsed()
309    }
310
311    /// Metadata-only diagnostic; preserve the typed outcome for every frontend
312    /// and autonomous driver. Exhaustion is a local stop, not provider fallback.
313    pub fn exhaustion_error(&self, dimension: BudgetDimension) -> agent_core::TurnError {
314        let mut error = agent_core::TurnError::budget(dimension);
315        if dimension == BudgetDimension::WallClock {
316            error.message = format!(
317                "turn stopped at its {}s time limit (ran {}s). History is kept; send a prompt to continue. \
318                 The limit comes from turn_budget.<role>.max_elapsed_secs or /budget time; /budget time off removes it.",
319                self.budget.max_elapsed_secs().unwrap_or(0),
320                self.elapsed().as_secs(),
321            );
322        }
323        error
324    }
325
326    /// Provider rounds charged since the last renewal (reset by
327    /// [`Self::try_renew_rounds`]). Diagnostics only.
328    pub fn rounds_used(&self) -> u32 {
329        self.rounds
330    }
331
332    /// Tool calls charged this turn. Diagnostics only.
333    pub fn tool_calls_used(&self) -> u32 {
334        self.tool_calls
335    }
336
337    /// Accumulated tool-result bytes charged this turn. Diagnostics only.
338    pub fn tool_result_bytes_used(&self) -> usize {
339        self.tool_result_bytes
340    }
341
342    /// Remaining tool-call allowance (for exact-cap batch splitting).
343    pub fn remaining_tool_calls(&self) -> u32 {
344        self.budget.max_tool_calls.saturating_sub(self.tool_calls)
345    }
346
347    /// Charge `n` executed tool calls (the caller split the batch against
348    /// [`Self::remaining_tool_calls`], so this never over-charges).
349    pub fn charge_tool_calls(&mut self, n: u32) {
350        self.tool_calls = self.tool_calls.saturating_add(n);
351    }
352
353    /// Charge the bytes of one round's tool results; exceeding the
354    /// accumulated cap exhausts the ToolResultBytes dimension.
355    pub fn charge_tool_result_bytes(&mut self, bytes: usize) -> Result<(), BudgetDimension> {
356        self.tool_result_bytes = self.tool_result_bytes.saturating_add(bytes);
357        if self.tool_result_bytes > self.budget.max_accumulated_tool_result_bytes {
358            return Err(BudgetDimension::ToolResultBytes);
359        }
360        Ok(())
361    }
362
363    /// Optional dimensions, consulted after each provider round from the
364    /// shared usage counters. Costs are summed per request, never repriced
365    /// from token totals; both checks are no-ops when unconfigured.
366    pub fn check_usage(&self, usage: &UsageCounters) -> Result<(), BudgetDimension> {
367        if let Some(max_context) = self.budget.max_context_tokens {
368            if usage.latest_context_tokens() > max_context {
369                return Err(BudgetDimension::InputTokens);
370            }
371        }
372        if let Some(max_cost) = self.budget.max_cost_usd {
373            if usage.cost_usd() > max_cost {
374                return Err(BudgetDimension::CostUsd);
375            }
376        }
377        Ok(())
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn partial_ttl_metadata_cannot_drop_cost_or_lower_tier() {
387        for (input, aggregate, c5, c1, expected) in [
388            (0, 100_001, Some(0), None, 0.100001),
389            (0, 100_001, None, Some(0), 0.100001),
390            (60_000, 40_000, Some(10_000), None, 0.01325),
391            (60_000, 40_001, Some(10_000), None, 0.066251),
392            (60_000, 40_001, None, Some(10_000), 0.070001),
393            (60_000, 40_001, Some(10_000), Some(10_000), 0.066251),
394            (60_000, 1, Some(20_000), Some(20_001), 0.062501),
395            (60_000, 1, Some(40_001), None, 0.055000625),
396            (60_000, 40_001, Some(40_001), None, 0.055000625),
397            (60_000, 40_001, None, Some(40_001), 0.070001),
398            (60_000, 40_001, Some(10_000), Some(30_001), 0.066251),
399            (60_000, 40_001, None, None, 0.055000625),
400        ] {
401            let usage = UsageCounters::default();
402            usage.record_priced("claude-haiku-5.5", input, 0, 0, aggregate, c5, c1);
403            assert!(
404                (usage.cost_usd() - expected).abs() < 2e-9,
405                "input={input}, aggregate={aggregate}, split={c5:?}/{c1:?}"
406            );
407            let mut budget = TurnBudget::for_role(TurnRole::Foreground);
408            budget.max_cost_usd = Some(expected / 2.0);
409            assert_eq!(
410                TurnBudgetMeter::new(budget).check_usage(&usage),
411                Err(BudgetDimension::CostUsd)
412            );
413        }
414        for (aggregate, c5, c1) in [
415            (u64::MAX, Some(1), None),
416            (1, Some(u64::MAX), Some(u64::MAX)),
417        ] {
418            let usage = UsageCounters::default();
419            usage.record_priced("claude-haiku-5.5", 0, 0, 0, aggregate, c5, c1);
420            assert_eq!(usage.cost_usd(), f64::MAX);
421        }
422    }
423
424    #[test]
425    fn costs_are_per_request_not_cumulative_tiers_or_current_model() {
426        let usage = UsageCounters::default();
427        for _ in 0..2 {
428            usage.record_priced("claude-haiku-5.5", 60_000, 1_000, 0, 0, None, None);
429        }
430        assert!((usage.cost_usd() - 0.013).abs() < 3e-9);
431        let mut budget = TurnBudget::for_role(TurnRole::Foreground);
432        budget.max_cost_usd = Some(0.03);
433        let meter = TurnBudgetMeter::new(budget);
434        assert_eq!(meter.check_usage(&usage), Ok(()));
435        usage.record_priced("claude-haiku-5-5", 100_001, 1_000, 0, 0, None, None);
436        assert!((usage.cost_usd() - 0.0655005).abs() < 4e-9);
437        assert_eq!(meter.check_usage(&usage), Err(BudgetDimension::CostUsd));
438        usage.record_priced("claude-opus-4.1", 1_000_000, 1_000_000, 0, 0, None, None);
439        usage.record_priced("claude-opus-4.8", 1_000_000, 1_000_000, 0, 0, None, None);
440        assert!((usage.cost_usd() - 120.0655005).abs() < 6e-9);
441        assert_eq!(usage.latest_context_tokens(), 1_000_000);
442    }
443
444    #[test]
445    fn request_tier_includes_reads_and_both_write_ttls() {
446        for extra in [0, 1] {
447            let usage = UsageCounters::default();
448            usage.record_priced(
449                "claude-haiku-5-5",
450                60_000,
451                1_000,
452                20_000 + extra,
453                20_000,
454                Some(5_000),
455                Some(15_000),
456            );
457            let expected = (0.006 + 0.0002 + 0.000625 + 0.003 + 0.0005 + extra as f64 * 0.00000001)
458                * if extra == 0 { 1.0 } else { 5.0 };
459            assert!((usage.cost_usd() - expected).abs() < 2e-9);
460            assert_eq!(usage.latest_context_tokens(), 100_000 + extra);
461        }
462    }
463
464    #[test]
465    fn unknown_models_and_saturation_cannot_silently_zero_cost() {
466        let usage = UsageCounters::default();
467        usage.record_priced("unknown-model", 1_000_000, 0, 0, 0, None, None);
468        assert!((usage.cost_usd() - 3.0).abs() < 2e-9);
469        for _ in 0..2 {
470            usage.record_priced(
471                "claude-opus-4.1",
472                u64::MAX,
473                u64::MAX,
474                u64::MAX,
475                u64::MAX,
476                None,
477                None,
478            );
479        }
480        assert_eq!(usage.totals(), (u64::MAX, u64::MAX, u64::MAX, u64::MAX));
481        assert_eq!(usage.cost_usd(), f64::MAX);
482        assert_eq!(usage.latest_context_tokens(), u64::MAX);
483    }
484
485    #[test]
486    fn role_defaults_are_ordered_and_typed() {
487        let fg = TurnBudget::for_role(TurnRole::Foreground);
488        let auto = TurnBudget::for_role(TurnRole::Autonomous);
489        let worker = TurnBudget::for_role(TurnRole::Worker);
490        assert!(auto.max_provider_rounds < worker.max_provider_rounds);
491        assert!(worker.max_provider_rounds <= fg.max_provider_rounds);
492        // No role has a wall-clock limit unless one is configured.
493        for budget in [&fg, &auto, &worker] {
494            assert_eq!(budget.max_elapsed, None);
495            assert!(!budget.has_time_limit());
496        }
497        assert!(fg.max_context_tokens.is_none());
498        assert!(fg.max_cost_usd.is_none());
499        // Graceful continuation is most generous for foreground (human
500        // watching) and disabled for autonomous (nobody watching).
501        assert_eq!(auto.max_round_renewals, 0);
502        assert!(worker.max_round_renewals > 0);
503        assert!(fg.max_round_renewals >= worker.max_round_renewals);
504    }
505
506    #[test]
507    fn config_overlays_only_set_fields() {
508        let mut cfg = agent_core::config::TurnBudgetsConfig::default();
509        cfg.autonomous.max_provider_rounds = Some(2);
510        cfg.autonomous.max_elapsed_secs = Some(9);
511        cfg.autonomous.max_round_renewals = Some(3);
512        let budget = TurnBudget::from_config(TurnRole::Autonomous, &cfg);
513        assert_eq!(budget.max_provider_rounds, 2);
514        assert_eq!(budget.max_elapsed, Some(Duration::from_secs(9)));
515        assert_eq!(budget.max_round_renewals, 3);
516        assert_eq!(
517            budget.max_tool_calls,
518            TurnBudget::for_role(TurnRole::Autonomous).max_tool_calls
519        );
520    }
521
522    #[test]
523    fn zero_seconds_in_config_means_no_time_limit() {
524        let mut cfg = agent_core::config::TurnBudgetsConfig::default();
525        cfg.foreground.max_elapsed_secs = Some(0);
526        let budget = TurnBudget::from_config(TurnRole::Foreground, &cfg);
527        assert_eq!(budget.max_elapsed, None);
528    }
529
530    #[test]
531    fn a_turn_without_a_time_limit_never_runs_out_of_time() {
532        let mut meter = TurnBudgetMeter::new(TurnBudget::for_role(TurnRole::Foreground));
533        meter.started = Instant::now() - Duration::from_secs(30 * 24 * 60 * 60);
534        assert!(!meter.wall_clock_exceeded());
535        assert_eq!(meter.begin_round(), Ok(()));
536    }
537
538    #[test]
539    fn committed_context_segment_resets_only_time() {
540        let mut meter = TurnBudgetMeter::new(TurnBudget {
541            max_elapsed: Some(Duration::from_secs(7200)),
542            ..TurnBudget::for_role(TurnRole::Foreground)
543        });
544        meter.started = Instant::now() - Duration::from_secs(7201);
545        meter.charge_tool_calls(3);
546        meter.charge_tool_result_bytes(1234).unwrap();
547        meter.try_renew_rounds();
548        let original = meter.budget().clone();
549        assert_eq!(meter.begin_round(), Err(BudgetDimension::WallClock));
550        meter.start_context_segment();
551        assert!(meter.begin_round().is_ok());
552        assert_eq!(meter.budget(), &original);
553        assert_eq!(meter.tool_calls_used(), 3);
554        assert_eq!(meter.tool_result_bytes_used(), 1234);
555        assert_eq!(meter.round_renewals_used(), 1);
556    }
557
558    #[test]
559    fn exhaustion_diagnostic_keeps_typed_outcome_and_reports_metadata() {
560        let meter = TurnBudgetMeter::new(TurnBudget {
561            max_elapsed: Some(Duration::from_secs(7200)),
562            ..TurnBudget::for_role(TurnRole::Foreground)
563        });
564        let error = meter.exhaustion_error(BudgetDimension::WallClock);
565        assert_eq!(
566            error.outcome,
567            agent_core::TurnOutcome::BudgetExceeded {
568                dimension: BudgetDimension::WallClock
569            }
570        );
571        assert!(error.message.contains("its 7200s time limit (ran "));
572        assert!(error.message.contains("History is kept"));
573        assert!(error.message.contains("/budget time off"));
574        // Other dimensions keep the existing diagnostic and exact typed label.
575        for dimension in [
576            BudgetDimension::ToolCalls,
577            BudgetDimension::ProviderRounds,
578            BudgetDimension::ToolResultBytes,
579            BudgetDimension::InputTokens,
580            BudgetDimension::OutputTokens,
581            BudgetDimension::CostUsd,
582        ] {
583            assert_eq!(
584                meter.exhaustion_error(dimension),
585                agent_core::TurnError::budget(dimension)
586            );
587        }
588    }
589
590    #[test]
591    fn meter_enforces_exact_rounds_and_wall_clock_order() {
592        let mut meter = TurnBudgetMeter::new(TurnBudget {
593            max_provider_rounds: 2,
594            ..TurnBudget::for_role(TurnRole::Foreground)
595        });
596        assert!(meter.begin_round().is_ok());
597        assert!(meter.begin_round().is_ok());
598        assert_eq!(meter.begin_round(), Err(BudgetDimension::ProviderRounds));
599
600        let mut stale = TurnBudgetMeter::new(TurnBudget {
601            max_elapsed: Some(Duration::ZERO),
602            ..TurnBudget::for_role(TurnRole::Foreground)
603        });
604        // Wall clock outranks the round check.
605        assert_eq!(stale.begin_round(), Err(BudgetDimension::WallClock));
606    }
607
608    #[test]
609    fn round_renewals_extend_a_bounded_number_of_times() {
610        let mut meter = TurnBudgetMeter::new(TurnBudget {
611            max_provider_rounds: 1,
612            max_round_renewals: 2,
613            ..TurnBudget::for_role(TurnRole::Foreground)
614        });
615        // First round of the base allowance.
616        assert!(meter.begin_round().is_ok());
617        // Exhausted → renew #1, then a fresh round is available.
618        assert_eq!(meter.begin_round(), Err(BudgetDimension::ProviderRounds));
619        assert_eq!(meter.try_renew_rounds(), Some(1));
620        assert!(meter.begin_round().is_ok());
621        // Exhausted again → renew #2 (last one).
622        assert_eq!(meter.begin_round(), Err(BudgetDimension::ProviderRounds));
623        assert_eq!(meter.try_renew_rounds(), Some(0));
624        assert!(meter.begin_round().is_ok());
625        // Exhausted a third time → no renewals left: hard stop.
626        assert_eq!(meter.begin_round(), Err(BudgetDimension::ProviderRounds));
627        assert_eq!(meter.try_renew_rounds(), None);
628        assert_eq!(meter.round_renewals_used(), 2);
629    }
630
631    #[test]
632    fn wall_clock_still_bounds_a_renewed_turn() {
633        let mut meter = TurnBudgetMeter::new(TurnBudget {
634            max_provider_rounds: 1,
635            max_round_renewals: 5,
636            max_elapsed: Some(Duration::ZERO),
637            ..TurnBudget::for_role(TurnRole::Foreground)
638        });
639        // Renewal resets the round counter, but begin_round re-checks the
640        // wall clock — a stale turn cannot extend past max_elapsed.
641        assert_eq!(meter.try_renew_rounds(), Some(4));
642        assert_eq!(meter.begin_round(), Err(BudgetDimension::WallClock));
643    }
644
645    #[test]
646    fn autonomous_role_has_no_graceful_renewals() {
647        let mut meter = TurnBudgetMeter::new(TurnBudget {
648            max_provider_rounds: 1,
649            ..TurnBudget::for_role(TurnRole::Autonomous)
650        });
651        assert!(meter.begin_round().is_ok());
652        assert_eq!(meter.begin_round(), Err(BudgetDimension::ProviderRounds));
653        // Nobody watching: the round cap is a hard stop.
654        assert_eq!(meter.try_renew_rounds(), None);
655    }
656
657    #[test]
658    fn meter_tool_calls_and_bytes_are_exact() {
659        let mut meter = TurnBudgetMeter::new(TurnBudget {
660            max_tool_calls: 3,
661            max_accumulated_tool_result_bytes: 10,
662            ..TurnBudget::for_role(TurnRole::Foreground)
663        });
664        assert_eq!(meter.remaining_tool_calls(), 3);
665        meter.charge_tool_calls(2);
666        assert_eq!(meter.remaining_tool_calls(), 1);
667        meter.charge_tool_calls(1);
668        assert_eq!(meter.remaining_tool_calls(), 0);
669
670        assert!(meter.charge_tool_result_bytes(10).is_ok());
671        assert_eq!(
672            meter.charge_tool_result_bytes(1),
673            Err(BudgetDimension::ToolResultBytes)
674        );
675    }
676
677    #[test]
678    fn usage_counters_drive_optional_dimensions() {
679        let usage = UsageCounters::default();
680        usage.record_priced("claude-sonnet-4-5", 100, 50, 10, 5, None, None);
681        assert_eq!(usage.latest_context_tokens(), 115);
682
683        let meter = TurnBudgetMeter::new(TurnBudget {
684            max_context_tokens: Some(100),
685            ..TurnBudget::for_role(TurnRole::Foreground)
686        });
687        assert_eq!(meter.check_usage(&usage), Err(BudgetDimension::InputTokens));
688
689        let cost_meter = TurnBudgetMeter::new(TurnBudget {
690            max_cost_usd: Some(0.0),
691            ..TurnBudget::for_role(TurnRole::Foreground)
692        });
693        assert_eq!(
694            cost_meter.check_usage(&usage),
695            Err(BudgetDimension::CostUsd)
696        );
697        let unlimited = TurnBudgetMeter::new(TurnBudget::for_role(TurnRole::Foreground));
698        assert!(unlimited.check_usage(&usage).is_ok());
699    }
700}