lc_agents/executor/budget.rs
1// lc-agents/src/executor/budget.rs
2//! Budget gates (§4.2): `BudgetConfig` hard-limit configuration + `BudgetExceeded`
3//! over-limit details + four gate-check functions (shared by the invoke / stream paths).
4//!
5//! The `AgentExecutor` control loop is all-off by default (`None` field = unlimited) and
6//! existing behavior is unchanged; once enabled via `.with_budget(BudgetConfig { .. })`,
7//! hitting any limit returns [`super::AgentError::BudgetExceeded`], letting the caller
8//! distinguish a budget stop from the model not converging. The gate functions are called
9//! with the same semantics in `run_agent_loop_from` (invoke) and `stream`, so the two
10//! paths cannot diverge.
11
12use super::AgentError;
13use crate::metrics::AgentMetrics;
14use std::time::{Duration, Instant};
15
16/// Hard budget for the agent control loop. A `None` field means that item is unlimited.
17#[derive(Debug, Clone, Default)]
18pub struct BudgetConfig {
19 /// Cumulative tool-call cap (parallel calls included; stops when exceeded).
20 pub max_tool_calls: Option<usize>,
21 /// Cumulative LLM output-token cap (reads `AgentMetrics.total_tokens`; has no effect
22 /// when the agent does not report tokens).
23 pub max_tokens: Option<usize>,
24 /// Loop wall-clock cap (timed from `run_agent_loop`).
25 pub max_duration: Option<Duration>,
26 /// Iteration cap (tightens `AgentExecutor::max_iterations`; hitting it returns an error
27 /// instead of the placeholder return path used at the iteration limit).
28 pub max_iterations: Option<usize>,
29 /// Cumulative USD spend cap (read from the shared
30 /// `lc_core::cost::CostTracker`; has no effect when the executor carries no
31 /// cost tracker). Measured after each LLM call.
32 pub max_cost_usd: Option<f64>,
33}
34
35/// Details of a budget over-limit.
36#[derive(Debug, Clone)]
37pub enum BudgetExceeded {
38 /// Cumulative tool-call count exceeded.
39 ToolCalls {
40 /// Configured limit.
41 limit: usize,
42 /// Actual cumulative count at trigger time.
43 actual: usize,
44 },
45 /// Cumulative LLM output-token count exceeded.
46 Tokens {
47 /// Configured limit.
48 limit: usize,
49 /// Actual cumulative tokens at trigger time.
50 actual: usize,
51 },
52 /// Loop wall-clock duration exceeded.
53 Duration {
54 /// Configured limit.
55 limit: Duration,
56 /// Actual elapsed time at trigger time.
57 elapsed: Duration,
58 },
59 /// Iteration count exceeded.
60 Iterations {
61 /// Effective limit (already `min`'d with `AgentExecutor::max_iterations`).
62 limit: usize,
63 },
64 /// Cumulative USD spend exceeded.
65 Cost {
66 /// Configured USD limit.
67 limit: f64,
68 /// Actual cumulative USD spend at trigger time.
69 actual: f64,
70 },
71}
72
73/// Budget gate (§4.2): iteration-level check (iteration count + wall-clock). Returns an
74/// error when a limit is exceeded.
75///
76/// `max_iterations` uses `min(self.max_iterations, budget.max_iterations)` as the
77/// effective limit — when the budget is tighter than the default it hard-stops on
78/// exceeding; when looser, `max_iterations` backs it up without changing the original
79/// placeholder return path. Shared by invoke / stream.
80pub(crate) fn budget_iteration_gate(
81 budget: Option<&BudgetConfig>,
82 max_iterations: usize,
83 iteration: usize,
84 loop_start: Instant,
85) -> Option<AgentError> {
86 let budget = budget?;
87 if let Some(limit) = budget.max_iterations {
88 let effective = limit.min(max_iterations);
89 if iteration >= effective {
90 return Some(AgentError::BudgetExceeded(BudgetExceeded::Iterations {
91 limit: effective,
92 }));
93 }
94 }
95 if let Some(limit) = budget.max_duration {
96 let elapsed = loop_start.elapsed();
97 if elapsed >= limit {
98 return Some(AgentError::BudgetExceeded(BudgetExceeded::Duration {
99 limit,
100 elapsed,
101 }));
102 }
103 }
104 None
105}
106
107/// Budget gate (§4.2): cumulative-token check after an LLM call. No effect when the
108/// agent does not report tokens.
109pub(crate) fn budget_token_gate(
110 budget: Option<&BudgetConfig>,
111 metrics: &AgentMetrics,
112) -> Option<AgentError> {
113 let budget = budget?;
114 let limit = budget.max_tokens?;
115 let actual = metrics.total_tokens.unwrap_or(0);
116 if actual >= limit {
117 return Some(AgentError::BudgetExceeded(BudgetExceeded::Tokens {
118 limit,
119 actual,
120 }));
121 }
122 None
123}
124
125/// Budget gate (§4.2): cumulative USD-spend check after an LLM call. The caller
126/// reads `CostTracker::total_cost_usd()` and passes the value in; when no
127/// tracker is attached the measured spend stays `0.0`, so a cost limit without
128/// a tracker simply never trips (measurement and enforcement stay explicit).
129pub(crate) fn budget_cost_gate(
130 budget: Option<&BudgetConfig>,
131 current_cost_usd: f64,
132) -> Option<AgentError> {
133 let budget = budget?;
134 let limit = budget.max_cost_usd?;
135 if current_cost_usd >= limit {
136 return Some(AgentError::BudgetExceeded(BudgetExceeded::Cost {
137 limit,
138 actual: current_cost_usd,
139 }));
140 }
141 None
142}
143
144/// Budget gate (§4.2): checks cumulative call count and wall-clock before a tool runs.
145///
146/// `metrics.tool_calls` is already incremented, so the check uses `> limit` — allowing
147/// exactly `limit` tool executions, with the `limit + 1`-th triggering the hard stop.
148pub(crate) fn budget_tool_gate(
149 budget: Option<&BudgetConfig>,
150 metrics: &AgentMetrics,
151 loop_start: Instant,
152) -> Option<AgentError> {
153 let budget = budget?;
154 if let Some(limit) = budget.max_tool_calls {
155 if metrics.tool_calls > limit {
156 return Some(AgentError::BudgetExceeded(BudgetExceeded::ToolCalls {
157 limit,
158 actual: metrics.tool_calls,
159 }));
160 }
161 }
162 if let Some(limit) = budget.max_duration {
163 let elapsed = loop_start.elapsed();
164 if elapsed >= limit {
165 return Some(AgentError::BudgetExceeded(BudgetExceeded::Duration {
166 limit,
167 elapsed,
168 }));
169 }
170 }
171 None
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 fn cost_cfg(limit: f64) -> BudgetConfig {
179 BudgetConfig {
180 max_cost_usd: Some(limit),
181 ..Default::default()
182 }
183 }
184
185 #[test]
186 fn cost_gate_without_budget_is_inert() {
187 assert!(budget_cost_gate(None, 9_999.0).is_none());
188 }
189
190 #[test]
191 fn cost_gate_without_limit_is_inert_even_with_budget() {
192 assert!(budget_cost_gate(Some(&BudgetConfig::default()), 9_999.0).is_none());
193 }
194
195 #[test]
196 fn cost_gate_below_limit_passes() {
197 assert!(budget_cost_gate(Some(&cost_cfg(1.0)), 0.99).is_none());
198 }
199
200 #[test]
201 fn cost_gate_at_and_above_limit_stops() {
202 match budget_cost_gate(Some(&cost_cfg(1.0)), 1.0) {
203 Some(AgentError::BudgetExceeded(BudgetExceeded::Cost { limit, actual })) => {
204 assert_eq!(limit, 1.0);
205 assert_eq!(actual, 1.0);
206 }
207 other => panic!("expected Cost stop at the limit, got {other:?}"),
208 }
209 assert!(budget_cost_gate(Some(&cost_cfg(1.0)), 1.5).is_some());
210 }
211}