Skip to main content

lc_agents/hooks/
rate_limit.rs

1// lc-agents/src/hooks/rate_limit.rs
2//! TokenBudgetHook — LLM token budget / quota rate limiting (P2-9).
3//!
4//! Checks cumulative usage in `on_before_completion` (before every LLM call): if
5//! the token budget or the call-count quota is exceeded it returns `Reject`,
6//! which [`crate::base::AgentExecutor`] turns into an error that aborts the run;
7//! `on_after_completion` accumulates the real token usage.
8
9use async_trait::async_trait;
10use lc_core::token_counter::{CharRatioCounter, TokenCounter};
11use lc_schema::Message;
12use std::sync::atomic::{AtomicUsize, Ordering};
13use std::sync::Arc;
14
15use super::{AgentHook, CompletionAction, CompletionContext, CompletionResult, HookError};
16
17/// Pre-call rate-limiting hook based on a token budget + call quota.
18///
19/// - `budget`: cumulative token cap for the whole run. `on_before_completion`
20///   pre-checks "confirmed usage + estimated input", returning `Reject` when
21///   exceeded; `on_after_completion` accumulates the real usage.
22/// - `max_calls`: optional maximum number of LLM calls.
23/// - Without a custom counter, input is estimated with
24///   [`CharRatioCounter::new(4)`](CharRatioCounter) (characters / 4).
25///
26/// # Example
27///
28/// ```rust,ignore
29/// use lc_agents::hooks::TokenBudgetHook;
30///
31/// let executor = AgentExecutor::new(agent, tools)
32///     .hook(TokenBudgetHook::new(10_000).with_max_calls(20));
33/// ```
34pub struct TokenBudgetHook {
35    /// Token budget cap.
36    budget: usize,
37    /// Optional maximum number of LLM calls.
38    max_calls: Option<usize>,
39    /// Cumulative token usage (real usage from completed LLM calls).
40    tokens_used: AtomicUsize,
41    /// Number of LLM calls made so far.
42    calls: AtomicUsize,
43    /// Precise counter (optional; defaults to characters/4 estimation).
44    counter: Option<Arc<dyn TokenCounter>>,
45}
46
47impl TokenBudgetHook {
48    /// Creates a token-budget hook. `budget` is the cumulative token cap for the whole run.
49    pub fn new(budget: usize) -> Self {
50        Self {
51            budget,
52            max_calls: None,
53            tokens_used: AtomicUsize::new(0),
54            calls: AtomicUsize::new(0),
55            counter: None,
56        }
57    }
58
59    /// Sets the maximum number of LLM calls.
60    pub fn with_max_calls(mut self, max_calls: usize) -> Self {
61        self.max_calls = Some(max_calls);
62        self
63    }
64
65    /// Uses a precise token counter instead of character-ratio estimation.
66    pub fn with_counter(mut self, counter: Arc<dyn TokenCounter>) -> Self {
67        self.counter = Some(counter);
68        self
69    }
70
71    /// Token budget cap.
72    pub fn budget(&self) -> usize {
73        self.budget
74    }
75
76    /// Cumulative token usage (real usage from completed LLM calls).
77    pub fn tokens_used(&self) -> usize {
78        self.tokens_used.load(Ordering::SeqCst)
79    }
80
81    /// Number of LLM calls made so far.
82    pub fn calls(&self) -> usize {
83        self.calls.load(Ordering::SeqCst)
84    }
85
86    /// Remaining token budget (floored at 0).
87    pub fn remaining(&self) -> usize {
88        self.budget.saturating_sub(self.tokens_used())
89    }
90
91    /// Estimates the token count of a message list: custom counter first, otherwise characters/4.
92    fn estimate_messages(&self, messages: &[Message]) -> usize {
93        match &self.counter {
94            Some(c) => c.count_messages(messages) as usize,
95            None => CharRatioCounter::new(4).count_messages(messages) as usize,
96        }
97    }
98}
99
100#[async_trait]
101impl AgentHook for TokenBudgetHook {
102    fn on_before_completion(&self, ctx: &mut CompletionContext) -> CompletionAction {
103        // Call quota.
104        if let Some(max) = self.max_calls {
105            if self.calls.load(Ordering::SeqCst) >= max {
106                return CompletionAction::Reject {
107                    reason: format!("LLM call quota exceeded: max_calls={max}"),
108                };
109            }
110        }
111
112        // Token-budget pre-check: confirmed usage + estimated input.
113        let used = self.tokens_used.load(Ordering::SeqCst);
114        let estimate = self.estimate_messages(&ctx.messages);
115        if used.saturating_add(estimate) > self.budget {
116            return CompletionAction::Reject {
117                reason: format!(
118                    "token budget exceeded: budget={}, used={used}, estimate={estimate}",
119                    self.budget
120                ),
121            };
122        }
123
124        self.calls.fetch_add(1, Ordering::SeqCst);
125        CompletionAction::Continue
126    }
127
128    fn on_after_completion(&self, ctx: &mut CompletionResult) -> Result<(), HookError> {
129        if let Some(usage) = &ctx.tokens_used {
130            self.tokens_used
131                .fetch_add(usage.total_tokens, Ordering::SeqCst);
132        }
133        Ok(())
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use lc_core::language_models::TokenUsage;
141
142    fn completion_ctx(text: &str) -> CompletionContext {
143        CompletionContext {
144            messages: vec![Message::human(text.to_string())],
145            model: "mock".to_string(),
146            metadata: std::collections::HashMap::new(),
147        }
148    }
149
150    #[test]
151    fn test_allows_within_budget() {
152        let hook = TokenBudgetHook::new(1_000);
153        assert!(matches!(
154            hook.on_before_completion(&mut completion_ctx("short")),
155            CompletionAction::Continue
156        ));
157        assert_eq!(hook.calls(), 1);
158    }
159
160    #[test]
161    fn test_rejects_when_estimated_over_budget() {
162        // Zero budget: any input (including message overhead) exceeds it.
163        let hook = TokenBudgetHook::new(0);
164        assert!(matches!(
165            hook.on_before_completion(&mut completion_ctx("x")),
166            CompletionAction::Reject { .. }
167        ));
168    }
169
170    #[test]
171    fn test_accumulates_real_usage_after_completion() {
172        let hook = TokenBudgetHook::new(1_000);
173        let mut result = CompletionResult {
174            message: Message::ai("hi"),
175            tokens_used: Some(TokenUsage {
176                prompt_tokens: 10,
177                completion_tokens: 20,
178                total_tokens: 30,
179            }),
180        };
181        hook.on_after_completion(&mut result).unwrap();
182        assert_eq!(hook.tokens_used(), 30);
183        assert_eq!(hook.remaining(), 970);
184    }
185
186    #[test]
187    fn test_max_calls_quota() {
188        let hook = TokenBudgetHook::new(1_000).with_max_calls(2);
189        assert!(matches!(
190            hook.on_before_completion(&mut completion_ctx("a")),
191            CompletionAction::Continue
192        ));
193        assert!(matches!(
194            hook.on_before_completion(&mut completion_ctx("b")),
195            CompletionAction::Continue
196        ));
197        // The third call exceeds the quota.
198        let action = hook.on_before_completion(&mut completion_ctx("c"));
199        match action {
200            CompletionAction::Reject { reason } => assert!(reason.contains("quota"), "{reason}"),
201            other => panic!("expected Reject, got {:?}", other),
202        }
203        assert_eq!(hook.calls(), 2);
204    }
205
206    #[test]
207    fn test_rejects_after_real_usage_exceeds_budget() {
208        let hook = TokenBudgetHook::new(100);
209        // Real usage is already 90; adding the input estimate (>10) exceeds it.
210        let mut result = CompletionResult {
211            message: Message::ai("hi"),
212            tokens_used: Some(TokenUsage {
213                prompt_tokens: 0,
214                completion_tokens: 90,
215                total_tokens: 90,
216            }),
217        };
218        hook.on_after_completion(&mut result).unwrap();
219        let action = hook.on_before_completion(&mut completion_ctx("a long enough message"));
220        match action {
221            CompletionAction::Reject { reason } => {
222                assert!(reason.contains("budget"), "{reason}")
223            }
224            other => panic!("expected Reject, got {:?}", other),
225        }
226    }
227}