aspect_std/
tokenbudget.rs1use aspect_core::{Aspect, AspectError, ProceedingJoinPoint};
10use parking_lot::RwLock;
11use std::any::Any;
12use std::collections::HashMap;
13use std::sync::Arc;
14
15pub type SessionId = String;
17
18#[derive(Clone, Debug)]
20pub struct TokenCharge {
21 pub session: SessionId,
23 pub estimated: u64,
25}
26
27#[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 pub fn new() -> Self {
53 Self::default()
54 }
55
56 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 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 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 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}