1use std::time::{Duration, Instant};
15
16use agent_core::BudgetDimension;
17
18pub const TOOL_CALL_NOT_EXECUTED: &str = "Tool call not executed";
21
22pub const TOOL_CALL_BUDGET_REFUSAL: &str =
25 "Tool call not executed: turn tool-call budget exhausted";
26
27#[derive(Debug, Clone, PartialEq)]
29pub struct TurnBudget {
30 pub max_provider_rounds: Option<u32>,
35 pub max_tool_calls: Option<u32>,
39 pub max_elapsed: Option<Duration>,
44 pub max_accumulated_tool_result_bytes: Option<usize>,
48 pub max_context_tokens: Option<u64>,
49 pub max_cost_usd: Option<f64>,
50 pub max_round_renewals: u32,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum TurnRole {
62 Foreground,
64 Event,
67 Worker,
69}
70
71impl TurnBudget {
72 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 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 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 max_round_renewals: 2,
113 },
114 }
115 }
116
117 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 pub fn for_event_turn(&self, config: &agent_core::config::TurnBudgetsConfig) -> TurnBudget {
146 self.tighter(&TurnBudget::from_config(TurnRole::Event, config))
147 }
148
149 pub fn has_time_limit(&self) -> bool {
152 self.max_elapsed.is_some_and(|d| !d.is_zero())
153 }
154
155 pub fn max_elapsed_secs(&self) -> Option<u64> {
157 self.max_elapsed.map(|d| d.as_secs())
158 }
159
160 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#[derive(Debug, Default)]
204pub struct OwnerSteerBudget {
205 pending: std::sync::Mutex<Option<TurnBudget>>,
206}
207
208impl OwnerSteerBudget {
209 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 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#[derive(Debug, Default)]
233pub struct UsageCounters {
234 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_context_tokens: std::sync::atomic::AtomicU64,
244 records: std::sync::atomic::AtomicU64,
247}
248
249impl UsageCounters {
250 #[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 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#[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 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 pub(crate) fn start_context_segment(&mut self) {
374 self.started = Instant::now();
375 }
376
377 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 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 pub fn round_renewals_used(&self) -> u32 {
412 self.round_renewals
413 }
414
415 pub fn elapsed(&self) -> Duration {
418 self.started.elapsed()
419 }
420
421 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 pub fn rounds_used(&self) -> u32 {
439 self.rounds
440 }
441
442 pub fn tool_calls_used(&self) -> u32 {
444 self.tool_calls
445 }
446
447 pub fn tool_result_bytes_used(&self) -> usize {
449 self.tool_result_bytes
450 }
451
452 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 pub fn charge_tool_calls(&mut self, n: u32) {
463 self.tool_calls = self.tool_calls.saturating_add(n);
464 }
465
466 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 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 assert_eq!(lift.drain(|| ()).1, None);
519 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 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 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 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 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 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 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 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 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 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 assert!(meter.begin_round().is_ok());
1016 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 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 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 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 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}