use aspect_core::{Aspect, AspectError, ProceedingJoinPoint};
use parking_lot::RwLock;
use std::any::Any;
use std::collections::HashMap;
use std::sync::Arc;
pub type SessionId = String;
#[derive(Clone, Debug)]
pub struct TokenCharge {
pub session: SessionId,
pub estimated: u64,
}
#[derive(Clone, Default)]
pub struct TokenBudgetAspect {
inner: Arc<Inner>,
}
#[derive(Default)]
struct Inner {
budgets: RwLock<HashMap<SessionId, Budget>>,
pending: RwLock<HashMap<u64, TokenCharge>>,
next_key: RwLock<u64>,
}
#[derive(Clone, Debug)]
struct Budget {
cap: u64,
spent: u64,
}
impl TokenBudgetAspect {
pub fn new() -> Self {
Self::default()
}
pub fn set_budget(&self, session: impl Into<SessionId>, cap: u64) {
self.inner
.budgets
.write()
.insert(session.into(), Budget { cap, spent: 0 });
}
pub fn remaining(&self, session: &str) -> Option<u64> {
self.inner
.budgets
.read()
.get(session)
.map(|b| b.cap.saturating_sub(b.spent))
}
pub fn bind(&self, charge: TokenCharge) -> u64 {
let mut next = self.inner.next_key.write();
*next += 1;
let key = *next;
self.inner.pending.write().insert(key, charge);
key
}
pub fn release(&self, key: u64) {
self.inner.pending.write().remove(&key);
}
fn most_recent_charge(&self) -> Option<TokenCharge> {
self.inner
.pending
.read()
.iter()
.max_by_key(|(k, _)| *k)
.map(|(_, v)| v.clone())
}
fn try_debit(&self, charge: &TokenCharge) -> Result<(), AspectError> {
let mut budgets = self.inner.budgets.write();
let entry = budgets.get_mut(&charge.session).ok_or_else(|| {
AspectError::execution(format!(
"no budget declared for session '{}'",
charge.session
))
})?;
let projected = entry.spent.saturating_add(charge.estimated);
if projected > entry.cap {
return Err(AspectError::execution(format!(
"token budget exceeded for session '{}' ({} + {} > {})",
charge.session, entry.spent, charge.estimated, entry.cap
)));
}
entry.spent = projected;
Ok(())
}
}
impl Aspect for TokenBudgetAspect {
fn around(&self, pjp: ProceedingJoinPoint) -> Result<Box<dyn Any>, AspectError> {
if let Some(charge) = self.most_recent_charge() {
self.try_debit(&charge)?;
}
pjp.proceed()
}
}
#[cfg(test)]
mod tests {
use super::*;
use aspect_core::{JoinPoint, Location};
fn jp() -> JoinPoint {
JoinPoint {
function_name: "execute_tool_call",
module_path: "agent::dispatch",
location: Location {
file: "agent.rs",
line: 1,
},
}
}
#[test]
fn budget_admits_calls_within_cap() {
let budget = TokenBudgetAspect::new();
budget.set_budget("s1", 1000);
let key = budget.bind(TokenCharge {
session: "s1".into(),
estimated: 300,
});
let pjp = ProceedingJoinPoint::new(|| Ok(Box::new(0_i32) as Box<dyn Any>), jp());
let result = budget.around(pjp);
budget.release(key);
assert!(result.is_ok());
assert_eq!(budget.remaining("s1"), Some(700));
}
#[test]
fn budget_denies_call_that_would_overspend() {
let budget = TokenBudgetAspect::new();
budget.set_budget("s1", 500);
let key = budget.bind(TokenCharge {
session: "s1".into(),
estimated: 600,
});
let pjp = ProceedingJoinPoint::new(|| Ok(Box::new(0_i32) as Box<dyn Any>), jp());
let result = budget.around(pjp);
budget.release(key);
assert!(result.is_err());
assert_eq!(budget.remaining("s1"), Some(500));
}
#[test]
fn unknown_session_is_rejected() {
let budget = TokenBudgetAspect::new();
let key = budget.bind(TokenCharge {
session: "anon".into(),
estimated: 1,
});
let pjp = ProceedingJoinPoint::new(|| Ok(Box::new(0_i32) as Box<dyn Any>), jp());
let result = budget.around(pjp);
budget.release(key);
assert!(result.is_err());
}
#[test]
fn cumulative_debits_eventually_exhaust_budget() {
let budget = TokenBudgetAspect::new();
budget.set_budget("s1", 100);
for _ in 0..4 {
let key = budget.bind(TokenCharge {
session: "s1".into(),
estimated: 25,
});
let pjp = ProceedingJoinPoint::new(|| Ok(Box::new(0_i32) as Box<dyn Any>), jp());
assert!(budget.around(pjp).is_ok());
budget.release(key);
}
let key = budget.bind(TokenCharge {
session: "s1".into(),
estimated: 1,
});
let pjp = ProceedingJoinPoint::new(|| Ok(Box::new(0_i32) as Box<dyn Any>), jp());
assert!(budget.around(pjp).is_err());
budget.release(key);
}
}