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 fn wall_clock_exceeded(&self) -> bool {
197 self.started.elapsed() >= self.budget.max_elapsed
198 }
199
200 pub fn begin_round(&mut self) -> Result<(), BudgetDimension> {
204 if self.wall_clock_exceeded() {
205 return Err(BudgetDimension::WallClock);
206 }
207 if self.rounds >= self.budget.max_provider_rounds {
208 return Err(BudgetDimension::ProviderRounds);
209 }
210 self.rounds += 1;
211 Ok(())
212 }
213
214 pub fn try_renew_rounds(&mut self) -> Option<u32> {
221 if self.round_renewals >= self.budget.max_round_renewals {
222 return None;
223 }
224 self.round_renewals += 1;
225 self.rounds = 0;
226 Some(self.budget.max_round_renewals - self.round_renewals)
227 }
228
229 pub fn round_renewals_used(&self) -> u32 {
231 self.round_renewals
232 }
233
234 pub fn elapsed(&self) -> Duration {
237 self.started.elapsed()
238 }
239
240 pub fn rounds_used(&self) -> u32 {
243 self.rounds
244 }
245
246 pub fn tool_calls_used(&self) -> u32 {
248 self.tool_calls
249 }
250
251 pub fn tool_result_bytes_used(&self) -> usize {
253 self.tool_result_bytes
254 }
255
256 pub fn remaining_tool_calls(&self) -> u32 {
258 self.budget.max_tool_calls.saturating_sub(self.tool_calls)
259 }
260
261 pub fn charge_tool_calls(&mut self, n: u32) {
264 self.tool_calls = self.tool_calls.saturating_add(n);
265 }
266
267 pub fn charge_tool_result_bytes(&mut self, bytes: usize) -> Result<(), BudgetDimension> {
270 self.tool_result_bytes = self.tool_result_bytes.saturating_add(bytes);
271 if self.tool_result_bytes > self.budget.max_accumulated_tool_result_bytes {
272 return Err(BudgetDimension::ToolResultBytes);
273 }
274 Ok(())
275 }
276
277 pub fn check_usage(&self, usage: &UsageCounters, model: &str) -> Result<(), BudgetDimension> {
281 if let Some(max_context) = self.budget.max_context_tokens {
282 if usage.latest_context_tokens() > max_context {
283 return Err(BudgetDimension::InputTokens);
284 }
285 }
286 if let Some(max_cost) = self.budget.max_cost_usd {
287 let (input, output, cache_read, cache_write) = usage.totals();
288 let cost =
289 agent_core::pricing::calculate_cost(model, input, output, cache_read, cache_write);
290 if cost > max_cost {
291 return Err(BudgetDimension::CostUsd);
292 }
293 }
294 Ok(())
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn role_defaults_are_ordered_and_typed() {
304 let fg = TurnBudget::for_role(TurnRole::Foreground);
305 let auto = TurnBudget::for_role(TurnRole::Autonomous);
306 let worker = TurnBudget::for_role(TurnRole::Worker);
307 assert!(auto.max_provider_rounds < worker.max_provider_rounds);
308 assert!(worker.max_provider_rounds <= fg.max_provider_rounds);
309 assert!(auto.max_elapsed < worker.max_elapsed);
310 assert!(fg.max_context_tokens.is_none());
311 assert!(fg.max_cost_usd.is_none());
312 assert_eq!(auto.max_round_renewals, 0);
315 assert!(worker.max_round_renewals > 0);
316 assert!(fg.max_round_renewals >= worker.max_round_renewals);
317 }
318
319 #[test]
320 fn config_overlays_only_set_fields() {
321 let mut cfg = agent_core::config::TurnBudgetsConfig::default();
322 cfg.autonomous.max_provider_rounds = Some(2);
323 cfg.autonomous.max_elapsed_secs = Some(9);
324 cfg.autonomous.max_round_renewals = Some(3);
325 let budget = TurnBudget::from_config(TurnRole::Autonomous, &cfg);
326 assert_eq!(budget.max_provider_rounds, 2);
327 assert_eq!(budget.max_elapsed, Duration::from_secs(9));
328 assert_eq!(budget.max_round_renewals, 3);
329 assert_eq!(
330 budget.max_tool_calls,
331 TurnBudget::for_role(TurnRole::Autonomous).max_tool_calls
332 );
333 }
334
335 #[test]
336 fn meter_enforces_exact_rounds_and_wall_clock_order() {
337 let mut meter = TurnBudgetMeter::new(TurnBudget {
338 max_provider_rounds: 2,
339 ..TurnBudget::for_role(TurnRole::Foreground)
340 });
341 assert!(meter.begin_round().is_ok());
342 assert!(meter.begin_round().is_ok());
343 assert_eq!(meter.begin_round(), Err(BudgetDimension::ProviderRounds));
344
345 let mut stale = TurnBudgetMeter::new(TurnBudget {
346 max_elapsed: Duration::ZERO,
347 ..TurnBudget::for_role(TurnRole::Foreground)
348 });
349 assert_eq!(stale.begin_round(), Err(BudgetDimension::WallClock));
351 }
352
353 #[test]
354 fn round_renewals_extend_a_bounded_number_of_times() {
355 let mut meter = TurnBudgetMeter::new(TurnBudget {
356 max_provider_rounds: 1,
357 max_round_renewals: 2,
358 ..TurnBudget::for_role(TurnRole::Foreground)
359 });
360 assert!(meter.begin_round().is_ok());
362 assert_eq!(meter.begin_round(), Err(BudgetDimension::ProviderRounds));
364 assert_eq!(meter.try_renew_rounds(), Some(1));
365 assert!(meter.begin_round().is_ok());
366 assert_eq!(meter.begin_round(), Err(BudgetDimension::ProviderRounds));
368 assert_eq!(meter.try_renew_rounds(), Some(0));
369 assert!(meter.begin_round().is_ok());
370 assert_eq!(meter.begin_round(), Err(BudgetDimension::ProviderRounds));
372 assert_eq!(meter.try_renew_rounds(), None);
373 assert_eq!(meter.round_renewals_used(), 2);
374 }
375
376 #[test]
377 fn wall_clock_still_bounds_a_renewed_turn() {
378 let mut meter = TurnBudgetMeter::new(TurnBudget {
379 max_provider_rounds: 1,
380 max_round_renewals: 5,
381 max_elapsed: Duration::ZERO,
382 ..TurnBudget::for_role(TurnRole::Foreground)
383 });
384 assert_eq!(meter.try_renew_rounds(), Some(4));
387 assert_eq!(meter.begin_round(), Err(BudgetDimension::WallClock));
388 }
389
390 #[test]
391 fn autonomous_role_has_no_graceful_renewals() {
392 let mut meter = TurnBudgetMeter::new(TurnBudget {
393 max_provider_rounds: 1,
394 ..TurnBudget::for_role(TurnRole::Autonomous)
395 });
396 assert!(meter.begin_round().is_ok());
397 assert_eq!(meter.begin_round(), Err(BudgetDimension::ProviderRounds));
398 assert_eq!(meter.try_renew_rounds(), None);
400 }
401
402 #[test]
403 fn meter_tool_calls_and_bytes_are_exact() {
404 let mut meter = TurnBudgetMeter::new(TurnBudget {
405 max_tool_calls: 3,
406 max_accumulated_tool_result_bytes: 10,
407 ..TurnBudget::for_role(TurnRole::Foreground)
408 });
409 assert_eq!(meter.remaining_tool_calls(), 3);
410 meter.charge_tool_calls(2);
411 assert_eq!(meter.remaining_tool_calls(), 1);
412 meter.charge_tool_calls(1);
413 assert_eq!(meter.remaining_tool_calls(), 0);
414
415 assert!(meter.charge_tool_result_bytes(10).is_ok());
416 assert_eq!(
417 meter.charge_tool_result_bytes(1),
418 Err(BudgetDimension::ToolResultBytes)
419 );
420 }
421
422 #[test]
423 fn usage_counters_drive_optional_dimensions() {
424 let usage = UsageCounters::default();
425 usage.record(100, 50, 10, 5);
426 assert_eq!(usage.latest_context_tokens(), 115);
427
428 let meter = TurnBudgetMeter::new(TurnBudget {
429 max_context_tokens: Some(100),
430 ..TurnBudget::for_role(TurnRole::Foreground)
431 });
432 assert_eq!(
433 meter.check_usage(&usage, "claude-sonnet-4-5"),
434 Err(BudgetDimension::InputTokens)
435 );
436
437 let cost_meter = TurnBudgetMeter::new(TurnBudget {
438 max_cost_usd: Some(0.0),
439 ..TurnBudget::for_role(TurnRole::Foreground)
440 });
441 assert_eq!(
442 cost_meter.check_usage(&usage, "claude-sonnet-4-5"),
443 Err(BudgetDimension::CostUsd)
444 );
445 let unlimited = TurnBudgetMeter::new(TurnBudget::for_role(TurnRole::Foreground));
446 assert!(unlimited.check_usage(&usage, "claude-sonnet-4-5").is_ok());
447 }
448}