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 + three 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}
30
31/// Details of a budget over-limit.
32#[derive(Debug, Clone)]
33pub enum BudgetExceeded {
34 /// Cumulative tool-call count exceeded.
35 ToolCalls {
36 /// Configured limit.
37 limit: usize,
38 /// Actual cumulative count at trigger time.
39 actual: usize,
40 },
41 /// Cumulative LLM output-token count exceeded.
42 Tokens {
43 /// Configured limit.
44 limit: usize,
45 /// Actual cumulative tokens at trigger time.
46 actual: usize,
47 },
48 /// Loop wall-clock duration exceeded.
49 Duration {
50 /// Configured limit.
51 limit: Duration,
52 /// Actual elapsed time at trigger time.
53 elapsed: Duration,
54 },
55 /// Iteration count exceeded.
56 Iterations {
57 /// Effective limit (already `min`'d with `AgentExecutor::max_iterations`).
58 limit: usize,
59 },
60}
61
62/// Budget gate (§4.2): iteration-level check (iteration count + wall-clock). Returns an
63/// error when a limit is exceeded.
64///
65/// `max_iterations` uses `min(self.max_iterations, budget.max_iterations)` as the
66/// effective limit — when the budget is tighter than the default it hard-stops on
67/// exceeding; when looser, `max_iterations` backs it up without changing the original
68/// placeholder return path. Shared by invoke / stream.
69pub(crate) fn budget_iteration_gate(
70 budget: Option<&BudgetConfig>,
71 max_iterations: usize,
72 iteration: usize,
73 loop_start: Instant,
74) -> Option<AgentError> {
75 let budget = budget?;
76 if let Some(limit) = budget.max_iterations {
77 let effective = limit.min(max_iterations);
78 if iteration >= effective {
79 return Some(AgentError::BudgetExceeded(BudgetExceeded::Iterations {
80 limit: effective,
81 }));
82 }
83 }
84 if let Some(limit) = budget.max_duration {
85 let elapsed = loop_start.elapsed();
86 if elapsed >= limit {
87 return Some(AgentError::BudgetExceeded(BudgetExceeded::Duration {
88 limit,
89 elapsed,
90 }));
91 }
92 }
93 None
94}
95
96/// Budget gate (§4.2): cumulative-token check after an LLM call. No effect when the
97/// agent does not report tokens.
98pub(crate) fn budget_token_gate(
99 budget: Option<&BudgetConfig>,
100 metrics: &AgentMetrics,
101) -> Option<AgentError> {
102 let budget = budget?;
103 let limit = budget.max_tokens?;
104 let actual = metrics.total_tokens.unwrap_or(0);
105 if actual >= limit {
106 return Some(AgentError::BudgetExceeded(BudgetExceeded::Tokens {
107 limit,
108 actual,
109 }));
110 }
111 None
112}
113
114/// Budget gate (§4.2): checks cumulative call count and wall-clock before a tool runs.
115///
116/// `metrics.tool_calls` is already incremented, so the check uses `> limit` — allowing
117/// exactly `limit` tool executions, with the `limit + 1`-th triggering the hard stop.
118pub(crate) fn budget_tool_gate(
119 budget: Option<&BudgetConfig>,
120 metrics: &AgentMetrics,
121 loop_start: Instant,
122) -> Option<AgentError> {
123 let budget = budget?;
124 if let Some(limit) = budget.max_tool_calls {
125 if metrics.tool_calls > limit {
126 return Some(AgentError::BudgetExceeded(BudgetExceeded::ToolCalls {
127 limit,
128 actual: metrics.tool_calls,
129 }));
130 }
131 }
132 if let Some(limit) = budget.max_duration {
133 let elapsed = loop_start.elapsed();
134 if elapsed >= limit {
135 return Some(AgentError::BudgetExceeded(BudgetExceeded::Duration {
136 limit,
137 elapsed,
138 }));
139 }
140 }
141 None
142}