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: Duration,
24 pub max_accumulated_tool_result_bytes: usize,
25 pub max_context_tokens: Option<u64>,
26 pub max_cost_usd: Option<f64>,
27 pub max_round_renewals: u32,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum TurnRole {
38 Foreground,
40 Autonomous,
42 Worker,
44}
45
46impl TurnBudget {
47 pub fn for_role(role: TurnRole) -> Self {
51 match role {
52 TurnRole::Foreground => Self {
53 max_provider_rounds: 128,
54 max_tool_calls: 512,
55 max_elapsed: Duration::from_secs(2 * 60 * 60),
56 max_accumulated_tool_result_bytes: 32 * 1024 * 1024,
57 max_context_tokens: None,
58 max_cost_usd: None,
59 max_round_renewals: 8,
63 },
64 TurnRole::Autonomous => Self {
65 max_provider_rounds: 24,
66 max_tool_calls: 96,
67 max_elapsed: Duration::from_secs(15 * 60),
68 max_accumulated_tool_result_bytes: 8 * 1024 * 1024,
69 max_context_tokens: None,
70 max_cost_usd: None,
71 max_round_renewals: 0,
74 },
75 TurnRole::Worker => Self {
76 max_provider_rounds: 64,
77 max_tool_calls: 256,
78 max_elapsed: Duration::from_secs(60 * 60),
79 max_accumulated_tool_result_bytes: 16 * 1024 * 1024,
80 max_context_tokens: None,
81 max_cost_usd: None,
82 max_round_renewals: 2,
84 },
85 }
86 }
87
88 pub fn from_config(role: TurnRole, config: &agent_core::config::TurnBudgetsConfig) -> Self {
91 let overrides = match role {
92 TurnRole::Foreground => &config.foreground,
93 TurnRole::Autonomous => &config.autonomous,
94 TurnRole::Worker => &config.worker,
95 };
96 let mut budget = Self::for_role(role);
97 if let Some(v) = overrides.max_provider_rounds {
98 budget.max_provider_rounds = v;
99 }
100 if let Some(v) = overrides.max_tool_calls {
101 budget.max_tool_calls = v;
102 }
103 if let Some(v) = overrides.max_elapsed_secs {
104 budget.max_elapsed = Duration::from_secs(v);
105 }
106 if let Some(v) = overrides.max_accumulated_tool_result_bytes {
107 budget.max_accumulated_tool_result_bytes = v;
108 }
109 if overrides.max_context_tokens.is_some() {
110 budget.max_context_tokens = overrides.max_context_tokens;
111 }
112 if overrides.max_cost_usd.is_some() {
113 budget.max_cost_usd = overrides.max_cost_usd;
114 }
115 if let Some(v) = overrides.max_round_renewals {
116 budget.max_round_renewals = v;
117 }
118 budget
119 }
120}
121
122#[derive(Debug, Default)]
126pub struct UsageCounters {
127 input_tokens: std::sync::atomic::AtomicU64,
128 output_tokens: std::sync::atomic::AtomicU64,
129 cache_read_tokens: std::sync::atomic::AtomicU64,
130 cache_write_tokens: std::sync::atomic::AtomicU64,
131 latest_context_tokens: std::sync::atomic::AtomicU64,
134}
135
136impl UsageCounters {
137 pub fn record(&self, input: u64, output: u64, cache_read: u64, cache_write: u64) {
138 use std::sync::atomic::Ordering;
139 self.input_tokens.fetch_add(input, Ordering::Relaxed);
140 self.output_tokens.fetch_add(output, Ordering::Relaxed);
141 self.cache_read_tokens
142 .fetch_add(cache_read, Ordering::Relaxed);
143 self.cache_write_tokens
144 .fetch_add(cache_write, Ordering::Relaxed);
145 self.latest_context_tokens.store(
146 input.saturating_add(cache_read).saturating_add(cache_write),
147 Ordering::Relaxed,
148 );
149 }
150
151 pub fn latest_context_tokens(&self) -> u64 {
152 self.latest_context_tokens
153 .load(std::sync::atomic::Ordering::Relaxed)
154 }
155
156 pub fn totals(&self) -> (u64, u64, u64, u64) {
158 use std::sync::atomic::Ordering;
159 (
160 self.input_tokens.load(Ordering::Relaxed),
161 self.output_tokens.load(Ordering::Relaxed),
162 self.cache_read_tokens.load(Ordering::Relaxed),
163 self.cache_write_tokens.load(Ordering::Relaxed),
164 )
165 }
166}
167
168#[derive(Debug)]
171pub struct TurnBudgetMeter {
172 budget: TurnBudget,
173 started: Instant,
174 rounds: u32,
175 tool_calls: u32,
176 tool_result_bytes: usize,
177 round_renewals: u32,
178}
179
180impl TurnBudgetMeter {
181 pub fn new(budget: TurnBudget) -> Self {
182 Self {
183 budget,
184 started: Instant::now(),
185 rounds: 0,
186 tool_calls: 0,
187 tool_result_bytes: 0,
188 round_renewals: 0,
189 }
190 }
191
192 pub fn budget(&self) -> &TurnBudget {
193 &self.budget
194 }
195
196 pub(crate) fn wall_clock_exceeded(&self) -> bool {
197 self.started.elapsed() >= self.budget.max_elapsed
198 }
199
200 pub(crate) fn start_context_segment(&mut self) {
204 self.started = Instant::now();
205 }
206
207 pub fn begin_round(&mut self) -> Result<(), BudgetDimension> {
211 if self.wall_clock_exceeded() {
212 return Err(BudgetDimension::WallClock);
213 }
214 if self.rounds >= self.budget.max_provider_rounds {
215 return Err(BudgetDimension::ProviderRounds);
216 }
217 self.rounds += 1;
218 Ok(())
219 }
220
221 pub fn try_renew_rounds(&mut self) -> Option<u32> {
228 if self.round_renewals >= self.budget.max_round_renewals {
229 return None;
230 }
231 self.round_renewals += 1;
232 self.rounds = 0;
233 Some(self.budget.max_round_renewals - self.round_renewals)
234 }
235
236 pub fn round_renewals_used(&self) -> u32 {
238 self.round_renewals
239 }
240
241 pub fn elapsed(&self) -> Duration {
244 self.started.elapsed()
245 }
246
247 pub fn exhaustion_error(&self, dimension: BudgetDimension) -> agent_core::TurnError {
250 let mut error = agent_core::TurnError::budget(dimension);
251 if dimension == BudgetDimension::WallClock {
252 error.message = format!(
253 "turn budget exhausted (wall_clock): elapsed {}s / limit {}s. \
254 This is a local per-turn time limit, not a provider error. \
255 History retained. An authorized auto driver can continue; otherwise send a new prompt for a fresh turn budget. \
256 For longer turns, use /budget status and /budget time <duration> (e.g. 4h) in the foreground, \
257 or configure turn_budget.<role>.max_elapsed_secs before restarting the host. \
258 In context auto, a committed rollover starts a fresh time allowance.",
259 self.elapsed().as_secs(),
260 self.budget.max_elapsed.as_secs(),
261 );
262 }
263 error
264 }
265
266 pub fn rounds_used(&self) -> u32 {
269 self.rounds
270 }
271
272 pub fn tool_calls_used(&self) -> u32 {
274 self.tool_calls
275 }
276
277 pub fn tool_result_bytes_used(&self) -> usize {
279 self.tool_result_bytes
280 }
281
282 pub fn remaining_tool_calls(&self) -> u32 {
284 self.budget.max_tool_calls.saturating_sub(self.tool_calls)
285 }
286
287 pub fn charge_tool_calls(&mut self, n: u32) {
290 self.tool_calls = self.tool_calls.saturating_add(n);
291 }
292
293 pub fn charge_tool_result_bytes(&mut self, bytes: usize) -> Result<(), BudgetDimension> {
296 self.tool_result_bytes = self.tool_result_bytes.saturating_add(bytes);
297 if self.tool_result_bytes > self.budget.max_accumulated_tool_result_bytes {
298 return Err(BudgetDimension::ToolResultBytes);
299 }
300 Ok(())
301 }
302
303 pub fn check_usage(&self, usage: &UsageCounters, model: &str) -> Result<(), BudgetDimension> {
307 if let Some(max_context) = self.budget.max_context_tokens {
308 if usage.latest_context_tokens() > max_context {
309 return Err(BudgetDimension::InputTokens);
310 }
311 }
312 if let Some(max_cost) = self.budget.max_cost_usd {
313 let (input, output, cache_read, cache_write) = usage.totals();
314 let cost =
315 agent_core::pricing::calculate_cost(model, input, output, cache_read, cache_write);
316 if cost > max_cost {
317 return Err(BudgetDimension::CostUsd);
318 }
319 }
320 Ok(())
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 #[test]
329 fn role_defaults_are_ordered_and_typed() {
330 let fg = TurnBudget::for_role(TurnRole::Foreground);
331 let auto = TurnBudget::for_role(TurnRole::Autonomous);
332 let worker = TurnBudget::for_role(TurnRole::Worker);
333 assert!(auto.max_provider_rounds < worker.max_provider_rounds);
334 assert!(worker.max_provider_rounds <= fg.max_provider_rounds);
335 assert!(auto.max_elapsed < worker.max_elapsed);
336 assert!(fg.max_context_tokens.is_none());
337 assert!(fg.max_cost_usd.is_none());
338 assert_eq!(auto.max_round_renewals, 0);
341 assert!(worker.max_round_renewals > 0);
342 assert!(fg.max_round_renewals >= worker.max_round_renewals);
343 }
344
345 #[test]
346 fn config_overlays_only_set_fields() {
347 let mut cfg = agent_core::config::TurnBudgetsConfig::default();
348 cfg.autonomous.max_provider_rounds = Some(2);
349 cfg.autonomous.max_elapsed_secs = Some(9);
350 cfg.autonomous.max_round_renewals = Some(3);
351 let budget = TurnBudget::from_config(TurnRole::Autonomous, &cfg);
352 assert_eq!(budget.max_provider_rounds, 2);
353 assert_eq!(budget.max_elapsed, Duration::from_secs(9));
354 assert_eq!(budget.max_round_renewals, 3);
355 assert_eq!(
356 budget.max_tool_calls,
357 TurnBudget::for_role(TurnRole::Autonomous).max_tool_calls
358 );
359 }
360
361 #[test]
362 fn committed_context_segment_resets_only_time() {
363 let mut meter = TurnBudgetMeter::new(TurnBudget::for_role(TurnRole::Foreground));
364 meter.started = Instant::now() - Duration::from_secs(7201);
365 meter.charge_tool_calls(3);
366 meter.charge_tool_result_bytes(1234).unwrap();
367 meter.try_renew_rounds();
368 let original = meter.budget().clone();
369 assert_eq!(meter.begin_round(), Err(BudgetDimension::WallClock));
370 meter.start_context_segment();
371 assert!(meter.begin_round().is_ok());
372 assert_eq!(meter.budget(), &original);
373 assert_eq!(meter.tool_calls_used(), 3);
374 assert_eq!(meter.tool_result_bytes_used(), 1234);
375 assert_eq!(meter.round_renewals_used(), 1);
376 }
377
378 #[test]
379 fn exhaustion_diagnostic_keeps_typed_outcome_and_reports_metadata() {
380 let meter = TurnBudgetMeter::new(TurnBudget::for_role(TurnRole::Foreground));
381 let error = meter.exhaustion_error(BudgetDimension::WallClock);
382 assert_eq!(
383 error.outcome,
384 agent_core::TurnOutcome::BudgetExceeded {
385 dimension: BudgetDimension::WallClock
386 }
387 );
388 assert!(error.message.contains("elapsed "));
389 assert!(error.message.contains("/ limit 7200s"));
390 assert!(error.message.contains("History retained"));
391 assert!(error.message.contains("/budget time <duration>"));
392 assert!(error
393 .message
394 .contains("committed rollover starts a fresh time allowance"));
395 for dimension in [
397 BudgetDimension::ToolCalls,
398 BudgetDimension::ProviderRounds,
399 BudgetDimension::ToolResultBytes,
400 BudgetDimension::InputTokens,
401 BudgetDimension::OutputTokens,
402 BudgetDimension::CostUsd,
403 ] {
404 assert_eq!(
405 meter.exhaustion_error(dimension),
406 agent_core::TurnError::budget(dimension)
407 );
408 }
409 }
410
411 #[test]
412 fn meter_enforces_exact_rounds_and_wall_clock_order() {
413 let mut meter = TurnBudgetMeter::new(TurnBudget {
414 max_provider_rounds: 2,
415 ..TurnBudget::for_role(TurnRole::Foreground)
416 });
417 assert!(meter.begin_round().is_ok());
418 assert!(meter.begin_round().is_ok());
419 assert_eq!(meter.begin_round(), Err(BudgetDimension::ProviderRounds));
420
421 let mut stale = TurnBudgetMeter::new(TurnBudget {
422 max_elapsed: Duration::ZERO,
423 ..TurnBudget::for_role(TurnRole::Foreground)
424 });
425 assert_eq!(stale.begin_round(), Err(BudgetDimension::WallClock));
427 }
428
429 #[test]
430 fn round_renewals_extend_a_bounded_number_of_times() {
431 let mut meter = TurnBudgetMeter::new(TurnBudget {
432 max_provider_rounds: 1,
433 max_round_renewals: 2,
434 ..TurnBudget::for_role(TurnRole::Foreground)
435 });
436 assert!(meter.begin_round().is_ok());
438 assert_eq!(meter.begin_round(), Err(BudgetDimension::ProviderRounds));
440 assert_eq!(meter.try_renew_rounds(), Some(1));
441 assert!(meter.begin_round().is_ok());
442 assert_eq!(meter.begin_round(), Err(BudgetDimension::ProviderRounds));
444 assert_eq!(meter.try_renew_rounds(), Some(0));
445 assert!(meter.begin_round().is_ok());
446 assert_eq!(meter.begin_round(), Err(BudgetDimension::ProviderRounds));
448 assert_eq!(meter.try_renew_rounds(), None);
449 assert_eq!(meter.round_renewals_used(), 2);
450 }
451
452 #[test]
453 fn wall_clock_still_bounds_a_renewed_turn() {
454 let mut meter = TurnBudgetMeter::new(TurnBudget {
455 max_provider_rounds: 1,
456 max_round_renewals: 5,
457 max_elapsed: Duration::ZERO,
458 ..TurnBudget::for_role(TurnRole::Foreground)
459 });
460 assert_eq!(meter.try_renew_rounds(), Some(4));
463 assert_eq!(meter.begin_round(), Err(BudgetDimension::WallClock));
464 }
465
466 #[test]
467 fn autonomous_role_has_no_graceful_renewals() {
468 let mut meter = TurnBudgetMeter::new(TurnBudget {
469 max_provider_rounds: 1,
470 ..TurnBudget::for_role(TurnRole::Autonomous)
471 });
472 assert!(meter.begin_round().is_ok());
473 assert_eq!(meter.begin_round(), Err(BudgetDimension::ProviderRounds));
474 assert_eq!(meter.try_renew_rounds(), None);
476 }
477
478 #[test]
479 fn meter_tool_calls_and_bytes_are_exact() {
480 let mut meter = TurnBudgetMeter::new(TurnBudget {
481 max_tool_calls: 3,
482 max_accumulated_tool_result_bytes: 10,
483 ..TurnBudget::for_role(TurnRole::Foreground)
484 });
485 assert_eq!(meter.remaining_tool_calls(), 3);
486 meter.charge_tool_calls(2);
487 assert_eq!(meter.remaining_tool_calls(), 1);
488 meter.charge_tool_calls(1);
489 assert_eq!(meter.remaining_tool_calls(), 0);
490
491 assert!(meter.charge_tool_result_bytes(10).is_ok());
492 assert_eq!(
493 meter.charge_tool_result_bytes(1),
494 Err(BudgetDimension::ToolResultBytes)
495 );
496 }
497
498 #[test]
499 fn usage_counters_drive_optional_dimensions() {
500 let usage = UsageCounters::default();
501 usage.record(100, 50, 10, 5);
502 assert_eq!(usage.latest_context_tokens(), 115);
503
504 let meter = TurnBudgetMeter::new(TurnBudget {
505 max_context_tokens: Some(100),
506 ..TurnBudget::for_role(TurnRole::Foreground)
507 });
508 assert_eq!(
509 meter.check_usage(&usage, "claude-sonnet-4-5"),
510 Err(BudgetDimension::InputTokens)
511 );
512
513 let cost_meter = TurnBudgetMeter::new(TurnBudget {
514 max_cost_usd: Some(0.0),
515 ..TurnBudget::for_role(TurnRole::Foreground)
516 });
517 assert_eq!(
518 cost_meter.check_usage(&usage, "claude-sonnet-4-5"),
519 Err(BudgetDimension::CostUsd)
520 );
521 let unlimited = TurnBudgetMeter::new(TurnBudget::for_role(TurnRole::Foreground));
522 assert!(unlimited.check_usage(&usage, "claude-sonnet-4-5").is_ok());
523 }
524}