Skip to main content

salvor_runtime/
budgets.rs

1//! Runtime budgets: the declared limits ([`Budgets`]), the pricing table a
2//! cost budget needs ([`Pricing`]), the extensions a human grants at resume
3//! time ([`BudgetExtensions`]), and the crossing check itself.
4//!
5//! Named `Budgets` (plural) deliberately: `salvor_core::Budget` is the *event
6//! payload* naming which single limit was crossed; this type is the
7//! *declaration* of every limit an agent runs under.
8//!
9//! # Determinism
10//!
11//! Budget checks run between events, before each model call, and every
12//! input to a check is replayed data:
13//!
14//! - **steps** counts completed model calls in this drive of the loop.
15//! - **tokens** and **cost** accumulate the recorded usage of completed
16//!   model calls (cost multiplies those integers by the agent's fixed
17//!   [`Pricing`]).
18//! - **wall time** is derived only from recorded `ctx.now()` observations
19//!   taken at each loop-iteration start, never from the ambient clock.
20//!
21//! So a crossing that fired live recomputes identically on replay, and the
22//! cursor matches it against the recorded `BudgetExceeded` event. A check
23//! fires when the observed value reaches or passes the effective limit
24//! (`observed >= limit`), and checks are evaluated in a fixed documented
25//! order: steps, tokens, cost, wall time.
26//!
27//! # The extension shape
28//!
29//! A budget crossing parks the run. Resuming it may carry an extension in
30//! the resume input, under the reserved `extend` key:
31//!
32//! ```json
33//! {
34//!     "extend": {
35//!         "steps": 5,
36//!         "tokens": 20000,
37//!         "cost_usd": 1.5,
38//!         "wall_time_seconds": 600.0
39//!     }
40//! }
41//! ```
42//!
43//! Every field is optional; `steps` and `tokens` are unsigned integers,
44//! `cost_usd` and `wall_time_seconds` are numbers. The effective limit for
45//! each dimension is the declared limit plus the sum of every recorded
46//! extension. Extensions live inside recorded `Resumed` events, so replay
47//! sees exactly the extensions the live run saw, in the same order, and the
48//! effective budget evolves identically. [`validate_extension_input`] is the
49//! shape check `Runtime::resume` applies before recording anything: the top
50//! level may contain only `extend`, and `extend` may contain only the four
51//! keys above with the right JSON types.
52
53use std::time::Duration;
54
55use salvor_core::{Budget, BudgetKind};
56use serde_json::Value;
57
58/// The limits an agent declares. Every dimension is optional; an absent
59/// dimension is never checked.
60#[derive(Clone, Debug, Default, PartialEq)]
61pub struct Budgets {
62    /// Maximum loop iterations, counted as completed model calls.
63    pub max_steps: Option<u64>,
64    /// Maximum total recorded tokens (input plus output) across the run.
65    pub max_tokens: Option<u64>,
66    /// Maximum cost in US dollars, computed from recorded usage and the
67    /// agent's [`Pricing`]. Declaring this without pricing is a build-time
68    /// error on the agent builder.
69    pub max_cost_usd: Option<f64>,
70    /// Maximum wall time, measured between recorded `ctx.now()`
71    /// observations, never against the ambient clock.
72    pub max_wall_time: Option<Duration>,
73}
74
75impl Budgets {
76    /// Whether any dimension is declared at all.
77    #[must_use]
78    pub fn any_declared(&self) -> bool {
79        self.max_steps.is_some()
80            || self.max_tokens.is_some()
81            || self.max_cost_usd.is_some()
82            || self.max_wall_time.is_some()
83    }
84
85    /// The first crossing, if any, in the fixed check order (steps, tokens,
86    /// cost, wall time). Returns the crossed [`Budget`] (whose `limit` is
87    /// the *effective* limit: declared plus extensions) and the observed
88    /// value, both exactly as they will be recorded.
89    #[must_use]
90    pub fn first_crossing(
91        &self,
92        extensions: &BudgetExtensions,
93        pricing: Option<&Pricing>,
94        observations: &BudgetObservations,
95    ) -> Option<(Budget, f64)> {
96        if let Some(max_steps) = self.max_steps {
97            let limit = to_f64(max_steps.saturating_add(extensions.steps));
98            let observed = to_f64(observations.steps);
99            if observed >= limit {
100                return Some((
101                    Budget {
102                        kind: BudgetKind::Steps,
103                        limit,
104                    },
105                    observed,
106                ));
107            }
108        }
109        if let Some(max_tokens) = self.max_tokens {
110            let limit = to_f64(max_tokens.saturating_add(extensions.tokens));
111            let observed = to_f64(
112                observations
113                    .input_tokens
114                    .saturating_add(observations.output_tokens),
115            );
116            if observed >= limit {
117                return Some((
118                    Budget {
119                        kind: BudgetKind::Tokens,
120                        limit,
121                    },
122                    observed,
123                ));
124            }
125        }
126        if let (Some(max_cost), Some(pricing)) = (self.max_cost_usd, pricing) {
127            let limit = max_cost + extensions.cost_usd;
128            let observed = pricing.cost_usd(observations.input_tokens, observations.output_tokens);
129            if observed >= limit {
130                return Some((
131                    Budget {
132                        kind: BudgetKind::CostUsd,
133                        limit,
134                    },
135                    observed,
136                ));
137            }
138        }
139        if let Some(max_wall) = self.max_wall_time {
140            let limit = max_wall.as_secs_f64() + extensions.wall_time_seconds;
141            let observed = observations.elapsed_seconds;
142            if observed >= limit {
143                return Some((
144                    Budget {
145                        kind: BudgetKind::WallTime,
146                        limit,
147                    },
148                    observed,
149                ));
150            }
151        }
152        None
153    }
154}
155
156/// Per-token pricing, in US dollars per million tokens. Required by the
157/// agent builder whenever a cost budget is declared.
158#[derive(Clone, Copy, Debug, PartialEq)]
159pub struct Pricing {
160    /// Dollars per million input tokens.
161    pub input_per_mtok: f64,
162    /// Dollars per million output tokens.
163    pub output_per_mtok: f64,
164}
165
166impl Pricing {
167    /// The cost of the given recorded token counts under this pricing. A
168    /// pure function of integers and the fixed rates, so it reproduces bit
169    /// for bit on replay.
170    #[must_use]
171    pub fn cost_usd(&self, input_tokens: u64, output_tokens: u64) -> f64 {
172        to_f64(input_tokens) / 1_000_000.0 * self.input_per_mtok
173            + to_f64(output_tokens) / 1_000_000.0 * self.output_per_mtok
174    }
175}
176
177/// The replay-derived quantities a budget check consumes. The loop builds
178/// one of these at each iteration start, exclusively from recorded data.
179#[derive(Clone, Copy, Debug, Default, PartialEq)]
180pub struct BudgetObservations {
181    /// Completed model calls so far.
182    pub steps: u64,
183    /// Recorded input tokens accumulated so far.
184    pub input_tokens: u64,
185    /// Recorded output tokens accumulated so far.
186    pub output_tokens: u64,
187    /// Seconds between the first recorded `ctx.now()` observation and the
188    /// latest one.
189    pub elapsed_seconds: f64,
190}
191
192/// The accumulated budget extensions granted by recorded resume inputs.
193/// See the module docs for the JSON shape they are parsed from.
194#[derive(Clone, Copy, Debug, Default, PartialEq)]
195pub struct BudgetExtensions {
196    /// Extra steps granted.
197    pub steps: u64,
198    /// Extra tokens granted.
199    pub tokens: u64,
200    /// Extra dollars granted.
201    pub cost_usd: f64,
202    /// Extra wall-time seconds granted.
203    pub wall_time_seconds: f64,
204}
205
206impl BudgetExtensions {
207    /// Folds one resume input's `extend` object (if present) into the
208    /// accumulated totals. Unknown or ill-typed fields are ignored here;
209    /// rejecting them is [`validate_extension_input`]'s job, applied before
210    /// the input was ever recorded.
211    pub fn absorb(&mut self, resume_input: &Value) {
212        let Some(extend) = resume_input.get("extend").and_then(Value::as_object) else {
213            return;
214        };
215        if let Some(steps) = extend.get("steps").and_then(Value::as_u64) {
216            self.steps = self.steps.saturating_add(steps);
217        }
218        if let Some(tokens) = extend.get("tokens").and_then(Value::as_u64) {
219            self.tokens = self.tokens.saturating_add(tokens);
220        }
221        if let Some(cost) = extend.get("cost_usd").and_then(Value::as_f64) {
222            self.cost_usd += cost;
223        }
224        if let Some(seconds) = extend.get("wall_time_seconds").and_then(Value::as_f64) {
225            self.wall_time_seconds += seconds;
226        }
227    }
228}
229
230/// Validates a resume input against the budget-extension shape documented
231/// at module level. Applied by `Runtime::resume` when the run parked on a
232/// budget crossing, *before* the input is recorded.
233///
234/// # Errors
235///
236/// Returns a human-readable description of the first violation: a non-object
237/// input, an unexpected top-level key, a non-object `extend`, an unknown
238/// key inside `extend`, or a field with the wrong JSON type.
239pub fn validate_extension_input(input: &Value) -> Result<(), String> {
240    let Some(top) = input.as_object() else {
241        return Err("a budget-crossing resume input must be a JSON object".to_owned());
242    };
243    for key in top.keys() {
244        if key != "extend" {
245            return Err(format!(
246                "unexpected top-level key `{key}`; a budget-crossing resume input may only carry `extend`"
247            ));
248        }
249    }
250    let Some(extend) = top.get("extend") else {
251        return Ok(());
252    };
253    let Some(extend) = extend.as_object() else {
254        return Err("`extend` must be a JSON object".to_owned());
255    };
256    for (key, value) in extend {
257        match key.as_str() {
258            "steps" | "tokens" => {
259                if value.as_u64().is_none() {
260                    return Err(format!("`extend.{key}` must be an unsigned integer"));
261                }
262            }
263            "cost_usd" | "wall_time_seconds" => {
264                if value.as_f64().is_none() {
265                    return Err(format!("`extend.{key}` must be a number"));
266                }
267            }
268            other => {
269                return Err(format!(
270                    "unknown key `extend.{other}`; expected steps, tokens, cost_usd, or wall_time_seconds"
271                ));
272            }
273        }
274    }
275    Ok(())
276}
277
278/// Widens an integer count to `f64` for the wire's numeric budget fields.
279/// Exact for every count below 2^53, far beyond any real run.
280#[allow(clippy::cast_precision_loss)]
281fn to_f64(count: u64) -> f64 {
282    count as f64
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use serde_json::json;
289
290    /// Checks fire on reaching the limit and honor absorbed extensions.
291    #[test]
292    fn crossing_fires_at_limit_and_extensions_raise_it() {
293        let budgets = Budgets {
294            max_steps: Some(2),
295            ..Budgets::default()
296        };
297        let mut extensions = BudgetExtensions::default();
298        let observations = BudgetObservations {
299            steps: 2,
300            ..BudgetObservations::default()
301        };
302
303        let (budget, observed) = budgets
304            .first_crossing(&extensions, None, &observations)
305            .expect("steps crossing fires at the limit");
306        assert_eq!(budget.kind, BudgetKind::Steps);
307        assert_eq!(budget.limit, 2.0);
308        assert_eq!(observed, 2.0);
309
310        extensions.absorb(&json!({"extend": {"steps": 3}}));
311        assert_eq!(
312            budgets.first_crossing(&extensions, None, &observations),
313            None,
314            "the extension raises the effective limit past the observation"
315        );
316    }
317
318    /// The documented check order: steps beats tokens when both cross.
319    #[test]
320    fn check_order_is_steps_first() {
321        let budgets = Budgets {
322            max_steps: Some(1),
323            max_tokens: Some(10),
324            ..Budgets::default()
325        };
326        let observations = BudgetObservations {
327            steps: 1,
328            input_tokens: 100,
329            output_tokens: 100,
330            ..BudgetObservations::default()
331        };
332        let (budget, _) = budgets
333            .first_crossing(&BudgetExtensions::default(), None, &observations)
334            .expect("a crossing fires");
335        assert_eq!(budget.kind, BudgetKind::Steps);
336    }
337
338    /// Cost uses pricing over recorded token counts.
339    #[test]
340    fn cost_crossing_uses_pricing() {
341        let budgets = Budgets {
342            max_cost_usd: Some(1.0),
343            ..Budgets::default()
344        };
345        let pricing = Pricing {
346            input_per_mtok: 3.0,
347            output_per_mtok: 15.0,
348        };
349        let observations = BudgetObservations {
350            input_tokens: 200_000,
351            output_tokens: 40_000,
352            ..BudgetObservations::default()
353        };
354        // 0.2 mtok * 3 + 0.04 mtok * 15 = 0.6 + 0.6 = 1.2 >= 1.0.
355        let (budget, observed) = budgets
356            .first_crossing(&BudgetExtensions::default(), Some(&pricing), &observations)
357            .expect("cost crossing fires");
358        assert_eq!(budget.kind, BudgetKind::CostUsd);
359        assert!((observed - 1.2).abs() < 1e-12);
360    }
361
362    /// The extension validator accepts the documented shape and rejects
363    /// obviously wrong ones.
364    #[test]
365    fn extension_validation_rejects_wrong_shapes() {
366        assert!(validate_extension_input(&json!({})).is_ok());
367        assert!(validate_extension_input(&json!({"extend": {"steps": 2}})).is_ok());
368        assert!(
369            validate_extension_input(&json!({
370                "extend": {"steps": 1, "tokens": 2, "cost_usd": 0.5, "wall_time_seconds": 60}
371            }))
372            .is_ok()
373        );
374        assert!(validate_extension_input(&json!("more please")).is_err());
375        assert!(validate_extension_input(&json!({"other": 1})).is_err());
376        assert!(validate_extension_input(&json!({"extend": 5})).is_err());
377        assert!(validate_extension_input(&json!({"extend": {"stepz": 1}})).is_err());
378        assert!(validate_extension_input(&json!({"extend": {"steps": -1}})).is_err());
379        assert!(validate_extension_input(&json!({"extend": {"cost_usd": "1"}})).is_err());
380    }
381}