Skip to main content

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}