Skip to main content

aspect_std/
tokenbudget.rs

1//! Token-budget aspect for agentic LLM systems.
2//!
3//! Tracks cumulative LLM token spend per session and rejects calls
4//! once a declared budget is exhausted. The aspect is novel relative
5//! to classical rate limiters because it caps a *cost* dimension
6//! (tokens) rather than a request count, and because budgets are
7//! attributed per session rather than per process.
8
9use aspect_core::{Aspect, AspectError, ProceedingJoinPoint};
10use parking_lot::RwLock;
11use std::any::Any;
12use std::collections::HashMap;
13use std::sync::Arc;
14
15/// Identifier for the session whose token spend is being capped.
16pub type SessionId = String;
17
18/// Per-call context the host populates before the guarded join point.
19#[derive(Clone, Debug)]
20pub struct TokenCharge {
21    /// Session whose budget is debited.
22    pub session: SessionId,
23    /// Tokens the call is expected to consume (input + output estimate).
24    pub estimated: u64,
25}
26
27/// Token-budget aspect.
28///
29/// The host system calls `set_budget(session, max)` once and `bind(charge)`
30/// before each guarded invocation. The around advice debits the budget
31/// if the call would still fit; otherwise it short-circuits with an error.
32#[derive(Clone, Default)]
33pub struct TokenBudgetAspect {
34    inner: Arc<Inner>,
35}
36
37#[derive(Default)]
38struct Inner {
39    budgets: RwLock<HashMap<SessionId, Budget>>,
40    pending: RwLock<HashMap<u64, TokenCharge>>,
41    next_key: RwLock<u64>,
42}
43
44#[derive(Clone, Debug)]
45struct Budget {
46    cap: u64,
47    spent: u64,
48}
49
50impl TokenBudgetAspect {
51    /// Create an empty budget tracker.
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    /// Set or reset the budget cap for a session.
57    pub fn set_budget(&self, session: impl Into<SessionId>, cap: u64) {
58        self.inner
59            .budgets
60            .write()
61            .insert(session.into(), Budget { cap, spent: 0 });
62    }
63
64    /// Read remaining tokens for a session. Returns `None` if no budget
65    /// has been declared.
66    pub fn remaining(&self, session: &str) -> Option<u64> {
67        self.inner
68            .budgets
69            .read()
70            .get(session)
71            .map(|b| b.cap.saturating_sub(b.spent))
72    }
73
74    /// Bind a charge to the next guarded invocation.
75    pub fn bind(&self, charge: TokenCharge) -> u64 {
76        let mut next = self.inner.next_key.write();
77        *next += 1;
78        let key = *next;
79        self.inner.pending.write().insert(key, charge);
80        key
81    }
82
83    /// Clear a previously bound charge. Idempotent.
84    pub fn release(&self, key: u64) {
85        self.inner.pending.write().remove(&key);
86    }
87
88    fn most_recent_charge(&self) -> Option<TokenCharge> {
89        self.inner
90            .pending
91            .read()
92            .iter()
93            .max_by_key(|(k, _)| *k)
94            .map(|(_, v)| v.clone())
95    }
96
97    fn try_debit(&self, charge: &TokenCharge) -> Result<(), AspectError> {
98        let mut budgets = self.inner.budgets.write();
99        let entry = budgets.get_mut(&charge.session).ok_or_else(|| {
100            AspectError::execution(format!(
101                "no budget declared for session '{}'",
102                charge.session
103            ))
104        })?;
105        let projected = entry.spent.saturating_add(charge.estimated);
106        if projected > entry.cap {
107            return Err(AspectError::execution(format!(
108                "token budget exceeded for session '{}' ({} + {} > {})",
109                charge.session, entry.spent, charge.estimated, entry.cap
110            )));
111        }
112        entry.spent = projected;
113        Ok(())
114    }
115}
116
117impl Aspect for TokenBudgetAspect {
118    fn around(&self, pjp: ProceedingJoinPoint) -> Result<Box<dyn Any>, AspectError> {
119        if let Some(charge) = self.most_recent_charge() {
120            self.try_debit(&charge)?;
121        }
122        pjp.proceed()
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use aspect_core::{JoinPoint, Location};
130
131    fn jp() -> JoinPoint {
132        JoinPoint {
133            function_name: "execute_tool_call",
134            module_path: "agent::dispatch",
135            location: Location {
136                file: "agent.rs",
137                line: 1,
138            },
139        }
140    }
141
142    #[test]
143    fn budget_admits_calls_within_cap() {
144        let budget = TokenBudgetAspect::new();
145        budget.set_budget("s1", 1000);
146        let key = budget.bind(TokenCharge {
147            session: "s1".into(),
148            estimated: 300,
149        });
150
151        let pjp = ProceedingJoinPoint::new(|| Ok(Box::new(0_i32) as Box<dyn Any>), jp());
152        let result = budget.around(pjp);
153        budget.release(key);
154
155        assert!(result.is_ok());
156        assert_eq!(budget.remaining("s1"), Some(700));
157    }
158
159    #[test]
160    fn budget_denies_call_that_would_overspend() {
161        let budget = TokenBudgetAspect::new();
162        budget.set_budget("s1", 500);
163        let key = budget.bind(TokenCharge {
164            session: "s1".into(),
165            estimated: 600,
166        });
167
168        let pjp = ProceedingJoinPoint::new(|| Ok(Box::new(0_i32) as Box<dyn Any>), jp());
169        let result = budget.around(pjp);
170        budget.release(key);
171
172        assert!(result.is_err());
173        assert_eq!(budget.remaining("s1"), Some(500));
174    }
175
176    #[test]
177    fn unknown_session_is_rejected() {
178        let budget = TokenBudgetAspect::new();
179        let key = budget.bind(TokenCharge {
180            session: "anon".into(),
181            estimated: 1,
182        });
183        let pjp = ProceedingJoinPoint::new(|| Ok(Box::new(0_i32) as Box<dyn Any>), jp());
184        let result = budget.around(pjp);
185        budget.release(key);
186        assert!(result.is_err());
187    }
188
189    #[test]
190    fn cumulative_debits_eventually_exhaust_budget() {
191        let budget = TokenBudgetAspect::new();
192        budget.set_budget("s1", 100);
193        for _ in 0..4 {
194            let key = budget.bind(TokenCharge {
195                session: "s1".into(),
196                estimated: 25,
197            });
198            let pjp = ProceedingJoinPoint::new(|| Ok(Box::new(0_i32) as Box<dyn Any>), jp());
199            assert!(budget.around(pjp).is_ok());
200            budget.release(key);
201        }
202        let key = budget.bind(TokenCharge {
203            session: "s1".into(),
204            estimated: 1,
205        });
206        let pjp = ProceedingJoinPoint::new(|| Ok(Box::new(0_i32) as Box<dyn Any>), jp());
207        assert!(budget.around(pjp).is_err());
208        budget.release(key);
209    }
210}