1use std::time::{Duration, Instant};
15
16use agent_core::BudgetDimension;
17
18#[derive(Debug, Clone, PartialEq)]
20pub struct TurnBudget {
21 pub max_provider_rounds: u32,
22 pub max_tool_calls: u32,
23 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 pub max_round_renewals: u32,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum TurnRole {
42 Foreground,
44 Autonomous,
46 Worker,
48}
49
50impl TurnBudget {
51 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 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 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 max_round_renewals: 2,
87 },
88 }
89 }
90
91 pub fn has_time_limit(&self) -> bool {
94 self.max_elapsed.is_some_and(|d| !d.is_zero())
95 }
96
97 pub fn max_elapsed_secs(&self) -> Option<u64> {
99 self.max_elapsed.map(|d| d.as_secs())
100 }
101
102 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 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#[derive(Debug, Default)]
141pub struct UsageCounters {
142 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_context_tokens: std::sync::atomic::AtomicU64,
152}
153
154impl UsageCounters {
155 #[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 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#[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 pub(crate) fn start_context_segment(&mut self) {
268 self.started = Instant::now();
269 }
270
271 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 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 pub fn round_renewals_used(&self) -> u32 {
302 self.round_renewals
303 }
304
305 pub fn elapsed(&self) -> Duration {
308 self.started.elapsed()
309 }
310
311 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 pub fn rounds_used(&self) -> u32 {
329 self.rounds
330 }
331
332 pub fn tool_calls_used(&self) -> u32 {
334 self.tool_calls
335 }
336
337 pub fn tool_result_bytes_used(&self) -> usize {
339 self.tool_result_bytes
340 }
341
342 pub fn remaining_tool_calls(&self) -> u32 {
344 self.budget.max_tool_calls.saturating_sub(self.tool_calls)
345 }
346
347 pub fn charge_tool_calls(&mut self, n: u32) {
350 self.tool_calls = self.tool_calls.saturating_add(n);
351 }
352
353 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 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 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 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 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 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 assert!(meter.begin_round().is_ok());
617 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 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 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 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 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}