Skip to main content

harn_vm/
call_budget.rs

1//! Per-dispatch ceilings on outbound *call counts* — MCP tool calls and
2//! Postgres queries — mirroring the LLM cost/token budgets in
3//! [`crate::llm::cost`]. A `.harn` handler exported through `harn-serve`
4//! declares `@budget(mcp_calls: 20, pg_queries: 50)`; the dispatcher
5//! installs the matching guards for the lifetime of the call. Each
6//! charge increments a per-thread counter and, once the ceiling is
7//! crossed, raises a structured `BudgetExceeded`-categorised error that
8//! adapter codecs render as HTTP 429.
9//!
10//! Counters only advance while a budget is installed, so dispatches
11//! without a `@budget` declaration pay nothing and never accumulate
12//! cross-call state. Guards restore the prior ceiling and count on drop,
13//! keeping nested dispatches (a handler that re-enters the dispatcher)
14//! from leaking a tighter budget outward or a wider one back into a
15//! finished inner scope.
16//!
17//! A ceiling and its running count travel together in one [`CallBudget`],
18//! and the count lives behind an `Arc`. That is what makes the ceiling hold
19//! when a dispatch fans out: `parallel each { mcp.call(..) }` charges the
20//! SAME counter the dispatcher installed, even though each branch runs on a
21//! different thread. A per-branch copy of the count would let every branch
22//! spend the whole ceiling.
23
24use crate::value::VmDictExt;
25use std::cell::RefCell;
26use std::collections::BTreeMap;
27use std::sync::atomic::{AtomicU64, Ordering};
28use std::sync::Arc;
29use std::thread::LocalKey;
30
31use crate::value::{VmError, VmValue};
32
33/// One dispatch's ceiling plus the count spent against it.
34///
35/// Cloning shares the count. `AmbientExecutionScope` clones this into every
36/// subtask, so a fan-out spends one budget rather than one budget per branch.
37#[derive(Clone, Debug)]
38pub(crate) struct CallBudget {
39    max: u64,
40    spent: Arc<AtomicU64>,
41}
42
43impl CallBudget {
44    fn new(max: u64) -> Self {
45        Self {
46            max,
47            spent: Arc::new(AtomicU64::new(0)),
48        }
49    }
50
51    fn spent(&self) -> u64 {
52        self.spent.load(Ordering::Relaxed)
53    }
54}
55
56thread_local! {
57    static MCP_CALL_BUDGET: RefCell<Option<CallBudget>> = const { RefCell::new(None) };
58    static PG_QUERY_BUDGET: RefCell<Option<CallBudget>> = const { RefCell::new(None) };
59}
60
61/// Swap the MCP call budget. Paired with `AmbientExecutionScope`'s per-poll
62/// swap.
63pub(crate) fn swap_mcp_call_budget(next: Option<CallBudget>) -> Option<CallBudget> {
64    MCP_CALL_BUDGET.with(|slot| std::mem::replace(&mut *slot.borrow_mut(), next))
65}
66
67/// Swap the Postgres query budget. Paired with `AmbientExecutionScope`'s
68/// per-poll swap.
69pub(crate) fn swap_pg_query_budget(next: Option<CallBudget>) -> Option<CallBudget> {
70    PG_QUERY_BUDGET.with(|slot| std::mem::replace(&mut *slot.borrow_mut(), next))
71}
72
73/// Reset thread-local call-budget state. Call between test runs so a
74/// guard that outlived an unwinding test cannot leak a ceiling.
75pub(crate) fn reset_call_budget_state() {
76    MCP_CALL_BUDGET.with(|b| *b.borrow_mut() = None);
77    PG_QUERY_BUDGET.with(|b| *b.borrow_mut() = None);
78}
79
80/// The two call-count dimensions. Each names the `@budget(...)` field it
81/// backs so the structured error carries the dimension that fired and
82/// `harn-serve`'s `budget_category_from_error` can recover it from the
83/// `limit` field without inspecting the message.
84#[derive(Clone, Copy)]
85enum CallBudgetKind {
86    McpCalls,
87    PgQueries,
88}
89
90impl CallBudgetKind {
91    /// The `@budget(...)` field name, surfaced as the error's `limit`.
92    fn limit_label(self) -> &'static str {
93        match self {
94            CallBudgetKind::McpCalls => "mcp_calls",
95            CallBudgetKind::PgQueries => "pg_queries",
96        }
97    }
98
99    /// Human-readable noun for the error message, pluralised to agree
100    /// with the ceiling count.
101    fn noun(self, plural: bool) -> &'static str {
102        match (self, plural) {
103            (CallBudgetKind::McpCalls, false) => "MCP call",
104            (CallBudgetKind::McpCalls, true) => "MCP calls",
105            (CallBudgetKind::PgQueries, false) => "Postgres query",
106            (CallBudgetKind::PgQueries, true) => "Postgres queries",
107        }
108    }
109}
110
111/// Increment the counter behind `budget`/`count` and raise once the
112/// ceiling is crossed. A `None` budget short-circuits — no install, no
113/// charge. The counter only advances while a ceiling is present so
114/// budget-free dispatches stay zero-cost.
115fn charge(
116    budget: &'static LocalKey<RefCell<Option<CallBudget>>>,
117    kind: CallBudgetKind,
118) -> Result<(), VmError> {
119    let Some(budget) = budget.with(|b| b.borrow().clone()) else {
120        return Ok(());
121    };
122    let spent = budget
123        .spent
124        .fetch_add(1, Ordering::Relaxed)
125        .saturating_add(1);
126    if spent > budget.max {
127        return Err(budget_exceeded_error(kind, spent, budget.max));
128    }
129    Ok(())
130}
131
132/// Build the structured error rendered as HTTP 429. The `category` field
133/// routes it through `ErrorCategory::BudgetExceeded`; the `limit` field
134/// names the dimension so adapters report `code: "budget_exceeded"` with
135/// the precise `@budget(...)` field that fired.
136fn budget_exceeded_error(kind: CallBudgetKind, spent: u64, max: u64) -> VmError {
137    let mut dict = BTreeMap::new();
138    dict.put_str("category", "budget_exceeded");
139    dict.put_str("kind", "terminal");
140    dict.put_str("reason", "budget_exceeded");
141    dict.put_str("limit", kind.limit_label());
142    dict.insert("limit_value".to_string(), VmValue::Int(max as i64));
143    dict.insert("spent".to_string(), VmValue::Int(spent as i64));
144    dict.put_str(
145        "message",
146        format!(
147            "{} budget exceeded: this dispatch attempted {} of {} permitted {}",
148            kind.limit_label(),
149            spent,
150            max,
151            kind.noun(max != 1),
152        ),
153    );
154    VmError::Thrown(VmValue::dict(dict))
155}
156
157/// RAII guard for [`install_mcp_call_budget`]. Restores the prior MCP
158/// call ceiling and count on drop.
159#[must_use = "dropping the guard immediately restores the prior MCP call budget"]
160pub struct McpCallBudgetGuard {
161    previous: Option<CallBudget>,
162}
163
164impl Drop for McpCallBudgetGuard {
165    fn drop(&mut self) {
166        MCP_CALL_BUDGET.with(|b| *b.borrow_mut() = self.previous.take());
167    }
168}
169
170/// Pin the per-dispatch MCP tool-call ceiling at `max` for the lifetime
171/// of the returned guard. Sourced from `@budget(mcp_calls: …)` on
172/// `.harn` handlers in `harn-serve`; the `(max + 1)`-th call raises a
173/// `BudgetExceeded`-categorised error adapters render as HTTP 429.
174pub fn install_mcp_call_budget(max: u64) -> McpCallBudgetGuard {
175    McpCallBudgetGuard {
176        previous: swap_mcp_call_budget(Some(CallBudget::new(max))),
177    }
178}
179
180/// Charge one MCP tool call against the active `@budget(mcp_calls: …)`
181/// ceiling, if any. Called once per logical `mcp.call` dispatch.
182pub fn charge_mcp_call() -> Result<(), VmError> {
183    charge(&MCP_CALL_BUDGET, CallBudgetKind::McpCalls)
184}
185
186/// The MCP calls charged against the active ceiling, or `None` when no
187/// `@budget(mcp_calls: …)` is installed. Exists so a test can prove a
188/// subtask's charges reach its parent's counter.
189pub fn mcp_calls_spent() -> Option<u64> {
190    MCP_CALL_BUDGET.with(|b| b.borrow().as_ref().map(CallBudget::spent))
191}
192
193/// RAII guard for [`install_pg_query_budget`]. Restores the prior
194/// Postgres query ceiling and count on drop.
195#[must_use = "dropping the guard immediately restores the prior Postgres query budget"]
196pub struct PgQueryBudgetGuard {
197    previous: Option<CallBudget>,
198}
199
200impl Drop for PgQueryBudgetGuard {
201    fn drop(&mut self) {
202        PG_QUERY_BUDGET.with(|b| *b.borrow_mut() = self.previous.take());
203    }
204}
205
206/// Pin the per-dispatch Postgres query ceiling at `max` for the lifetime
207/// of the returned guard. Sourced from `@budget(pg_queries: …)` on
208/// `.harn` handlers in `harn-serve`; the `(max + 1)`-th query raises a
209/// `BudgetExceeded`-categorised error adapters render as HTTP 429.
210pub fn install_pg_query_budget(max: u64) -> PgQueryBudgetGuard {
211    PgQueryBudgetGuard {
212        previous: swap_pg_query_budget(Some(CallBudget::new(max))),
213    }
214}
215
216/// Charge one Postgres query against the active `@budget(pg_queries: …)`
217/// ceiling, if any. Called once per `pg_query` / `pg_query_one` /
218/// `pg_execute` statement (including mock-pool statements).
219pub fn charge_pg_query() -> Result<(), VmError> {
220    charge(&PG_QUERY_BUDGET, CallBudgetKind::PgQueries)
221}
222
223/// The Postgres queries charged against the active ceiling, or `None` when no
224/// `@budget(pg_queries: …)` is installed.
225pub fn pg_queries_spent() -> Option<u64> {
226    PG_QUERY_BUDGET.with(|b| b.borrow().as_ref().map(CallBudget::spent))
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use crate::value::{error_to_category, ErrorCategory};
233
234    #[test]
235    fn charge_is_noop_without_installed_budget() {
236        reset_call_budget_state();
237        for _ in 0..1000 {
238            assert!(charge_mcp_call().is_ok());
239            assert!(charge_pg_query().is_ok());
240        }
241        // No guard installed → nothing to advance.
242        assert_eq!(mcp_calls_spent(), None);
243        assert_eq!(pg_queries_spent(), None);
244    }
245
246    #[test]
247    fn mcp_budget_admits_up_to_ceiling_then_rejects() {
248        reset_call_budget_state();
249        let _guard = install_mcp_call_budget(2);
250        assert!(charge_mcp_call().is_ok());
251        assert!(charge_mcp_call().is_ok());
252        let third = charge_mcp_call();
253        let err = third.expect_err("third call must exceed mcp_calls: 2");
254        assert_eq!(error_to_category(&err), ErrorCategory::BudgetExceeded);
255        match &err {
256            VmError::Thrown(VmValue::Dict(d)) => {
257                assert_eq!(
258                    d.get("limit").map(|v| v.display()).as_deref(),
259                    Some("mcp_calls")
260                );
261                assert_eq!(d.get("limit_value").and_then(VmValue::as_int), Some(2));
262                assert_eq!(d.get("spent").and_then(VmValue::as_int), Some(3));
263            }
264            other => panic!("expected structured Thrown dict, got {other:?}"),
265        }
266        reset_call_budget_state();
267    }
268
269    #[test]
270    fn pg_budget_message_pluralises_and_names_dimension() {
271        reset_call_budget_state();
272        let _guard = install_pg_query_budget(1);
273        assert!(charge_pg_query().is_ok());
274        let err = charge_pg_query().expect_err("second query must exceed pg_queries: 1");
275        match &err {
276            VmError::Thrown(VmValue::Dict(d)) => {
277                let message = d.get("message").map(|v| v.display()).unwrap_or_default();
278                assert!(
279                    message.contains("pg_queries budget exceeded"),
280                    "got: {message}"
281                );
282                assert!(message.contains("Postgres query"), "got: {message}");
283            }
284            other => panic!("expected structured Thrown dict, got {other:?}"),
285        }
286        reset_call_budget_state();
287    }
288
289    #[test]
290    fn nested_guard_restores_outer_budget_and_count_on_drop() {
291        reset_call_budget_state();
292        let outer = install_mcp_call_budget(5);
293        assert!(charge_mcp_call().is_ok());
294        assert_eq!(mcp_calls_spent(), Some(1));
295
296        {
297            // Nested dispatch installs a tighter ceiling and starts fresh.
298            let _inner = install_mcp_call_budget(1);
299            assert_eq!(mcp_calls_spent(), Some(0));
300            assert!(charge_mcp_call().is_ok());
301            assert!(charge_mcp_call().is_err());
302        }
303
304        // Inner drop restores the outer ceiling and its accumulated count.
305        assert_eq!(
306            MCP_CALL_BUDGET.with(|b| b.borrow().as_ref().map(|b| b.max)),
307            Some(5)
308        );
309        assert_eq!(mcp_calls_spent(), Some(1));
310        drop(outer);
311        assert_eq!(mcp_calls_spent(), None);
312        reset_call_budget_state();
313    }
314}