1use std::time::{Duration, Instant};
15
16use agent_core::BudgetDimension;
17
18pub const TOOL_CALL_NOT_EXECUTED: &str = "Tool call not executed";
22
23pub const TOOL_CALL_BUDGET_REFUSAL: &str =
26 "Tool call not executed: turn tool-call budget exhausted";
27
28#[derive(Debug, Clone, PartialEq)]
30pub struct TurnBudget {
31 pub max_provider_rounds: Option<u32>,
36 pub max_tool_calls: Option<u32>,
40 pub max_elapsed: Option<Duration>,
45 pub max_accumulated_tool_result_bytes: Option<usize>,
49 pub max_context_tokens: Option<u64>,
50 pub max_cost_usd: Option<f64>,
51 pub max_total_tokens: Option<u64>,
56 pub max_round_renewals: u32,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum TurnRole {
68 Foreground,
70 Autonomous,
72 Event,
75 Worker,
77}
78
79impl TurnBudget {
80 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 max_round_renewals: 8,
100 },
101 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 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 max_round_renewals: 2,
125 },
126 }
127 }
128
129 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 pub fn for_event_turn(&self, config: &agent_core::config::TurnBudgetsConfig) -> TurnBudget {
160 self.tighter(&TurnBudget::from_config(TurnRole::Event, config))
161 }
162
163 pub fn has_time_limit(&self) -> bool {
166 self.max_elapsed.is_some_and(|d| !d.is_zero())
167 }
168
169 pub fn max_elapsed_secs(&self) -> Option<u64> {
171 self.max_elapsed.map(|d| d.as_secs())
172 }
173
174 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#[derive(Debug, Default)]
219pub struct OwnerSteerBudget {
220 pending: std::sync::Mutex<Option<TurnBudget>>,
221}
222
223impl OwnerSteerBudget {
224 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 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#[derive(Debug, Default)]
248pub struct UsageCounters {
249 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_context_tokens: std::sync::atomic::AtomicU64,
259 records: std::sync::atomic::AtomicU64,
262}
263
264impl UsageCounters {
265 #[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 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#[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 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 pub(crate) fn start_context_segment(&mut self) {
389 self.started = Instant::now();
390 }
391
392 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 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 pub fn round_renewals_used(&self) -> u32 {
427 self.round_renewals
428 }
429
430 pub fn elapsed(&self) -> Duration {
433 self.started.elapsed()
434 }
435
436 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 pub fn rounds_used(&self) -> u32 {
454 self.rounds
455 }
456
457 pub fn tool_calls_used(&self) -> u32 {
459 self.tool_calls
460 }
461
462 pub fn tool_result_bytes_used(&self) -> usize {
464 self.tool_result_bytes
465 }
466
467 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 pub fn charge_tool_calls(&mut self, n: u32) {
478 self.tool_calls = self.tool_calls.saturating_add(n);
479 }
480
481 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 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 assert_eq!(lift.drain(|| ()).1, None);
540 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 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 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 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 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 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 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 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 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 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 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 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 assert!(meter.begin_round().is_ok());
1069 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 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 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 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 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}