Skip to main content

av_state/
budget.rs

1//! Action budgets (Module B): stateful tool limits — per-tool caps via
2//! `max_tool_calls` (e.g. `db_write: 3`), payout ceilings via
3//! `max_payout_usd_micros` — plus per-session token ceilings.
4//!
5//! Money is tracked in integer micro-USD; a payout of $12.34 spends
6//! 12_340_000. Fractional-cent dust can therefore never accumulate invisibly.
7
8use crate::store::{Spend, StateError, StateStore};
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11
12/// Declarative budget for one session/agent (config-file surface).
13///
14/// Unknown keys are rejected so `[budget]` typos fail loudly at startup.
15#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(deny_unknown_fields)]
17pub struct BudgetSpec {
18    /// Max total tokens (prompt+completion) per session. `None` = unlimited.
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub max_tokens: Option<u64>,
21    /// Max cumulative payout in micro-USD.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub max_payout_usd_micros: Option<u64>,
24    /// Per-tool invocation caps, e.g. `db_write: 3`.
25    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
26    pub max_tool_calls: BTreeMap<String, u64>,
27    /// Cap on *all* tool calls combined.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub max_total_tool_calls: Option<u64>,
30}
31
32/// Outcome of a budget check.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub enum BudgetDecision {
35    /// Spend recorded; remaining headroom for the tightest matching limit.
36    Allowed {
37        /// Remaining amount under the binding limit (min across dimensions).
38        remaining: u64,
39    },
40    /// Refused: which limit would be exceeded.
41    Refused {
42        /// Human-readable limit name (`max_tool_calls.db_write`, …).
43        limit: String,
44        /// The configured cap.
45        cap: u64,
46    },
47}
48
49impl BudgetDecision {
50    /// True when the action was allowed.
51    pub fn is_allowed(&self) -> bool {
52        matches!(self, Self::Allowed { .. })
53    }
54}
55
56/// Budget enforcement bound to a session id and a state store.
57pub struct ActionBudget<'a> {
58    store: &'a dyn StateStore,
59    session: &'a str,
60    spec: &'a BudgetSpec,
61}
62
63impl<'a> ActionBudget<'a> {
64    /// Bind a spec to a session.
65    pub fn new(store: &'a dyn StateStore, session: &'a str, spec: &'a BudgetSpec) -> Self {
66        Self { store, session, spec }
67    }
68
69    fn key(&self, dim: &str) -> String {
70        format!("{}{dim}", Self::session_prefix(self.session))
71    }
72
73    /// Common key prefix for every budget counter of `session`. Callers use
74    /// this with [`StateStore::remove_prefix`] to drop a finalized session's
75    /// counters (per-tool keys are dynamic, so single-key removal cannot
76    /// enumerate them).
77    pub fn session_prefix(session: &str) -> String {
78        let digest = av_core::digest::sha256_hex(session.as_bytes());
79        // 32 hex chars = 128 bits: collision-safe well beyond realistic session counts.
80        format!("budget:{{{}}}:", digest.get(..32).unwrap_or(&digest))
81    }
82
83    /// Check-and-spend one invocation of `tool`, with an optional payout
84    /// amount in micro-USD carried by this call.
85    ///
86    /// Dimensions are checked in a fixed order (total calls → per-tool →
87    /// payout) and committed atomically via `try_spend_many`: every
88    /// dimension is validated first and either all spends commit or none
89    /// do, so a refused call consumes nothing.
90    pub fn try_tool_call(&self, tool: &str, payout_usd_micros: u64) -> Result<BudgetDecision, StateError> {
91        // Parallel arrays keep spends and limit-names together without paying
92        // for a joined-tuple clone before hitting the state store. There are
93        // at most three dimensions (total, per-tool, payout).
94        let mut spends: Vec<Spend> = Vec::with_capacity(3);
95        let mut limit_names: Vec<String> = Vec::with_capacity(3);
96
97        if let Some(cap) = self.spec.max_total_tool_calls {
98            spends.push(Spend {
99                key: self.key("total_calls"),
100                amount: 1,
101                limit: cap,
102            });
103            limit_names.push("max_total_tool_calls".into());
104        }
105        if let Some(cap) = self.spec.max_tool_calls.get(tool).copied() {
106            spends.push(Spend {
107                key: self.key(&format!("tool:{tool}")),
108                amount: 1,
109                limit: cap,
110            });
111            limit_names.push(format!("max_tool_calls.{tool}"));
112        }
113        if payout_usd_micros > 0 {
114            match self.spec.max_payout_usd_micros {
115                Some(cap) => {
116                    spends.push(Spend {
117                        key: self.key("payout"),
118                        amount: payout_usd_micros,
119                        limit: cap,
120                    });
121                    limit_names.push("max_payout_usd_micros".into());
122                }
123                None => {
124                    return Ok(BudgetDecision::Refused {
125                        limit: "max_payout_usd_micros(unset)".into(),
126                        cap: 0,
127                    });
128                }
129            }
130        }
131
132        if let Some(index) = self.store.try_spend_many(&spends)? {
133            let spend = spends
134                .get(index)
135                .ok_or_else(|| StateError::Backend(format!("invalid failed spend index {index}")))?;
136            let limit = limit_names
137                .get(index)
138                .ok_or_else(|| StateError::Backend(format!("invalid failed spend index {index}")))?;
139            return Ok(BudgetDecision::Refused {
140                limit: limit.clone(),
141                cap: spend.limit,
142            });
143        }
144
145        let remaining = self.remaining_min(tool)?;
146        Ok(BudgetDecision::Allowed { remaining })
147    }
148
149    /// Round-33 F1: compensating refund for a previously-successful
150    /// [`Self::try_tool_call`]. Reverses the spend on exactly the same
151    /// dimensions that were debited (total_calls, per-tool, payout) so
152    /// a lost-race path in the caller (concurrent identical MCP
153    /// request loses `execution.claim()` after the sandbox gate has
154    /// already spent) does not double-charge the session budget.
155    /// Best-effort: any backend error is silently absorbed by the
156    /// underlying [`StateStore::refund`] contract — a Redis blip on
157    /// the compensation path must never turn a lost-race response
158    /// into a 5xx.
159    pub fn refund_tool_call(&self, tool: &str, payout_usd_micros: u64) {
160        if self.spec.max_total_tool_calls.is_some() {
161            self.store.refund(&self.key("total_calls"), 1);
162        }
163        if self.spec.max_tool_calls.contains_key(tool) {
164            self.store.refund(&self.key(&format!("tool:{tool}")), 1);
165        }
166        if payout_usd_micros > 0 && self.spec.max_payout_usd_micros.is_some() {
167            self.store.refund(&self.key("payout"), payout_usd_micros);
168        }
169    }
170
171    /// Check-and-spend `tokens` against `max_tokens`.
172    pub fn try_tokens(&self, tokens: u64) -> Result<BudgetDecision, StateError> {
173        match self.spec.max_tokens {
174            Some(cap) => {
175                let key = self.key("tokens");
176                if self.store.try_spend(&key, tokens, cap)? {
177                    let used = self.store.get(&key)?;
178                    Ok(BudgetDecision::Allowed {
179                        remaining: cap.saturating_sub(used),
180                    })
181                } else {
182                    Ok(BudgetDecision::Refused {
183                        limit: "max_tokens".into(),
184                        cap,
185                    })
186                }
187            }
188            None => Ok(BudgetDecision::Allowed { remaining: u64::MAX }),
189        }
190    }
191
192    fn remaining_min(&self, tool: &str) -> Result<u64, StateError> {
193        let mut min = u64::MAX;
194        if let Some(cap) = self.spec.max_total_tool_calls {
195            let used = self.store.get(&self.key("total_calls"))?;
196            min = min.min(cap.saturating_sub(used));
197        }
198        if let Some(cap) = self.spec.max_tool_calls.get(tool) {
199            let used = self.store.get(&self.key(&format!("tool:{tool}")))?;
200            min = min.min(cap.saturating_sub(used));
201        }
202        if let Some(cap) = self.spec.max_payout_usd_micros {
203            let used = self.store.get(&self.key("payout"))?;
204            min = min.min(cap.saturating_sub(used));
205        }
206        Ok(min)
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
213
214    use super::*;
215    use crate::store::InMemoryStore;
216
217    fn spec() -> BudgetSpec {
218        BudgetSpec {
219            max_tokens: Some(1000),
220            max_payout_usd_micros: Some(50_000_000), // $50 (brief example)
221            max_tool_calls: BTreeMap::from([("db_write".to_owned(), 3)]), // brief example
222            max_total_tool_calls: Some(10),
223        }
224    }
225
226    #[test]
227    fn db_write_capped_at_three() {
228        let store = InMemoryStore::new();
229        let s = spec();
230        let b = ActionBudget::new(&store, "sess", &s);
231        for _ in 0..3 {
232            assert!(b.try_tool_call("db_write", 0).unwrap().is_allowed());
233        }
234        let refused = b.try_tool_call("db_write", 0).unwrap();
235        assert_eq!(
236            refused,
237            BudgetDecision::Refused {
238                limit: "max_tool_calls.db_write".into(),
239                cap: 3
240            }
241        );
242        // Other tools still work (total budget has room).
243        assert!(b.try_tool_call("search", 0).unwrap().is_allowed());
244    }
245
246    #[test]
247    fn payout_capped_at_fifty_dollars() {
248        let store = InMemoryStore::new();
249        let s = spec();
250        let b = ActionBudget::new(&store, "sess", &s);
251        assert!(b.try_tool_call("payout", 49_000_000).unwrap().is_allowed());
252        let refused = b.try_tool_call("payout", 2_000_000).unwrap(); // would total $51
253        assert!(!refused.is_allowed());
254        // The refused call must not have consumed its per-call slot either:
255        // spend $1 exactly — succeeds if rollback was complete.
256        assert!(b.try_tool_call("payout", 1_000_000).unwrap().is_allowed());
257    }
258
259    #[test]
260    fn payout_without_cap_fails_closed() {
261        let store = InMemoryStore::new();
262        let s = BudgetSpec::default();
263        let b = ActionBudget::new(&store, "sess", &s);
264        let d = b.try_tool_call("payout", 1).unwrap();
265        assert!(
266            !d.is_allowed(),
267            "uncapped payout must be refused, not silently allowed"
268        );
269    }
270
271    #[test]
272    fn refused_multi_dimension_spend_rolls_back() {
273        let store = InMemoryStore::new();
274        let s = spec();
275        let b = ActionBudget::new(&store, "sess", &s);
276        // Exhaust db_write (3 calls, 3 total slots used).
277        for _ in 0..3 {
278            assert!(b.try_tool_call("db_write", 0).unwrap().is_allowed());
279        }
280        // This refusal must roll back its total_calls spend:
281        assert!(!b.try_tool_call("db_write", 0).unwrap().is_allowed());
282        // 7 remaining total slots — all must be grantable.
283        for _ in 0..7 {
284            assert!(b.try_tool_call("other", 0).unwrap().is_allowed());
285        }
286        assert!(
287            !b.try_tool_call("other", 0).unwrap().is_allowed(),
288            "total cap must bind at 10"
289        );
290    }
291
292    #[test]
293    fn token_budget() {
294        let store = InMemoryStore::new();
295        let s = spec();
296        let b = ActionBudget::new(&store, "sess", &s);
297        assert!(b.try_tokens(900).unwrap().is_allowed());
298        assert!(b.try_tokens(100).unwrap().is_allowed()); // exactly at cap
299        assert!(!b.try_tokens(1).unwrap().is_allowed());
300    }
301
302    #[test]
303    fn sessions_are_isolated() {
304        let store = InMemoryStore::new();
305        let s = spec();
306        let a = ActionBudget::new(&store, "sess-a", &s);
307        let b = ActionBudget::new(&store, "sess-b", &s);
308        for _ in 0..3 {
309            assert!(a.try_tool_call("db_write", 0).unwrap().is_allowed());
310        }
311        assert!(!a.try_tool_call("db_write", 0).unwrap().is_allowed());
312        assert!(
313            b.try_tool_call("db_write", 0).unwrap().is_allowed(),
314            "session b unaffected"
315        );
316    }
317
318    #[test]
319    fn concurrent_multi_dimension_spends_commit_all_or_none() {
320        let store = std::sync::Arc::new(InMemoryStore::new());
321        let spec = std::sync::Arc::new(BudgetSpec {
322            max_tool_calls: BTreeMap::from([("db_write".to_owned(), 100)]),
323            max_total_tool_calls: Some(100),
324            ..BudgetSpec::default()
325        });
326        let mut handles = Vec::new();
327        for _ in 0..32 {
328            let store = std::sync::Arc::clone(&store);
329            let spec = std::sync::Arc::clone(&spec);
330            handles.push(std::thread::spawn(move || {
331                let budget = ActionBudget::new(store.as_ref(), "atomic", spec.as_ref());
332                (0..20)
333                    .filter(|_| budget.try_tool_call("db_write", 0).unwrap().is_allowed())
334                    .count()
335            }));
336        }
337        let allowed: usize = handles.into_iter().map(|handle| handle.join().unwrap()).sum();
338        assert_eq!(allowed, 100);
339        let budget = ActionBudget::new(store.as_ref(), "atomic", spec.as_ref());
340        assert_eq!(store.get(&budget.key("total_calls")).unwrap(), 100);
341        assert_eq!(store.get(&budget.key("tool:db_write")).unwrap(), 100);
342    }
343
344    #[test]
345    fn unlimited_spec_allows_everything() {
346        let store = InMemoryStore::new();
347        let s = BudgetSpec::default();
348        let b = ActionBudget::new(&store, "sess", &s);
349        for _ in 0..100 {
350            assert!(b.try_tool_call("anything", 0).unwrap().is_allowed());
351        }
352        assert!(b.try_tokens(u64::MAX / 4).unwrap().is_allowed());
353    }
354
355    #[test]
356    fn allowed_decision_reports_true_remaining_headroom() {
357        // Catches any stub of `remaining_min` (e.g. → Ok(0) / Ok(1)): with a
358        // cap of 10, 3 prior spends, and the measured 4th call spending one
359        // itself, `remaining` must be exactly 6.
360        let store = InMemoryStore::new();
361        let s = BudgetSpec {
362            max_total_tool_calls: Some(10),
363            ..BudgetSpec::default()
364        };
365        let b = ActionBudget::new(&store, "sess-rem", &s);
366        for _ in 0..3 {
367            assert!(b.try_tool_call("t", 0).unwrap().is_allowed());
368        }
369        match b.try_tool_call("t", 0).unwrap() {
370            BudgetDecision::Allowed { remaining } => assert_eq!(remaining, 6),
371            other => panic!("expected Allowed with real remaining, got {other:?}"),
372        }
373    }
374
375    /// Round-33 F1: refund_tool_call compensates the exact dimensions
376    /// try_tool_call debited. Locks in the primary invariant needed
377    /// by the harness's lost-claim path — after refund, the same
378    /// call succeeds again against the same caps.
379    #[test]
380    fn refund_tool_call_reverses_the_spend_exactly() {
381        let store = InMemoryStore::new();
382        let s = BudgetSpec {
383            max_total_tool_calls: Some(2),
384            max_tool_calls: BTreeMap::from([("db_write".to_owned(), 1u64)]),
385            max_payout_usd_micros: Some(1_000_000),
386            ..BudgetSpec::default()
387        };
388        let b = ActionBudget::new(&store, "sess-refund", &s);
389        // Debit 1 total + 1 per-tool + 500k payout.
390        assert!(b.try_tool_call("db_write", 500_000).unwrap().is_allowed());
391        // Without refund, per-tool cap trips the next call.
392        assert!(matches!(
393            b.try_tool_call("db_write", 100).unwrap(),
394            BudgetDecision::Refused { .. }
395        ));
396        // Refund reverses exactly the spend.
397        b.refund_tool_call("db_write", 500_000);
398        // The same call now succeeds — proving all three dimensions
399        // were compensated.
400        assert!(b.try_tool_call("db_write", 500_000).unwrap().is_allowed());
401    }
402
403    /// Round-33 F1: refund saturates at 0 under a concurrent clear.
404    /// The compensating refund must never leave a negative "budget
405    /// spent" counter — that would give the next legit call a free
406    /// ride relative to its cap.
407    #[test]
408    fn refund_is_saturating() {
409        let store = InMemoryStore::new();
410        let s = BudgetSpec {
411            max_total_tool_calls: Some(10),
412            ..BudgetSpec::default()
413        };
414        let b = ActionBudget::new(&store, "sess-sat", &s);
415        assert!(b.try_tool_call("t", 0).unwrap().is_allowed());
416        // Two refunds in a row: the second must clamp at 0, not
417        // underflow the counter.
418        b.refund_tool_call("t", 0);
419        b.refund_tool_call("t", 0);
420        // 10 successful calls remain — the counter is 0 (clamped).
421        for _ in 0..10 {
422            assert!(b.try_tool_call("t", 0).unwrap().is_allowed());
423        }
424        assert!(matches!(
425            b.try_tool_call("t", 0).unwrap(),
426            BudgetDecision::Refused { .. }
427        ));
428    }
429}