Skip to main content

salvor_replay/
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: [`Budget`] is the *event payload*
6//! naming which single limit was crossed; this type is the *declaration* of
7//! every limit an agent runs under.
8//!
9//! # Why this lives in the pure crate
10//!
11//! Every input to a check is replayed data (see the determinism section
12//! below), so the check itself never touches a clock, a store, or a network:
13//! it is arithmetic over recorded numbers. It sits here rather than in
14//! `salvor-runtime` for the same reason [`derive_state`](crate::derive_state)
15//! does: the runtime enforces budgets at the IO edge and a browser wants to
16//! evaluate the identical rule client-side, and one implementation serving
17//! both is the only way the two cannot disagree. `salvor-runtime` re-exports
18//! every name here, so `salvor_runtime::Budgets` and the rest keep resolving.
19//!
20//! [`budget_observations`] closes the loop: it folds a recorded log into the
21//! [`BudgetObservations`] the loop would have built at that point, so a caller
22//! holding only the log can run the real check rather than approximate it.
23//!
24//! # Determinism
25//!
26//! Budget checks run between events, before each model call, and every
27//! input to a check is replayed data:
28//!
29//! - **steps** counts completed model calls in this drive of the loop.
30//! - **tokens** and **cost** accumulate the recorded usage of completed
31//!   model calls (cost multiplies those integers by the agent's fixed
32//!   [`Pricing`]).
33//! - **wall time** is derived only from recorded `ctx.now()` observations
34//!   taken at each loop-iteration start, never from the ambient clock.
35//!
36//! So a crossing that fired live recomputes identically on replay, and the
37//! cursor matches it against the recorded `BudgetExceeded` event. A check
38//! fires when the observed value reaches or passes the effective limit
39//! (`observed >= limit`), and checks are evaluated in a fixed documented
40//! order: steps, tokens, cost, wall time.
41//!
42//! # The extension shape
43//!
44//! A budget crossing parks the run. Resuming it may carry an extension in
45//! the resume input, under the reserved `extend` key:
46//!
47//! ```json
48//! {
49//!     "extend": {
50//!         "steps": 5,
51//!         "tokens": 20000,
52//!         "cost_usd": 1.5,
53//!         "wall_time_seconds": 600.0
54//!     }
55//! }
56//! ```
57//!
58//! Every field is optional; `steps` and `tokens` are unsigned integers,
59//! `cost_usd` and `wall_time_seconds` are numbers. The effective limit for
60//! each dimension is the declared limit plus the sum of every recorded
61//! extension. Extensions live inside recorded `Resumed` events, so replay
62//! sees exactly the extensions the live run saw, in the same order, and the
63//! effective budget evolves identically. [`validate_extension_input`] is the
64//! shape check `Runtime::resume` applies before recording anything: the top
65//! level may contain only `extend`, and `extend` may contain only the four
66//! keys above with the right JSON types.
67
68use std::time::Duration;
69
70use serde_json::Value;
71
72use crate::{Budget, BudgetKind, Event, EventEnvelope};
73
74/// The limits an agent declares. Every dimension is optional; an absent
75/// dimension is never checked.
76#[derive(Clone, Debug, Default, PartialEq)]
77pub struct Budgets {
78    /// Maximum loop iterations, counted as completed model calls.
79    pub max_steps: Option<u64>,
80    /// Maximum total recorded tokens (input plus output) across the run.
81    pub max_tokens: Option<u64>,
82    /// Maximum cost in US dollars, computed from recorded usage and the
83    /// agent's [`Pricing`]. Declaring this without pricing is a build-time
84    /// error on the agent builder.
85    pub max_cost_usd: Option<f64>,
86    /// Maximum wall time, measured between recorded `ctx.now()`
87    /// observations, never against the ambient clock.
88    pub max_wall_time: Option<Duration>,
89}
90
91impl Budgets {
92    /// Whether any dimension is declared at all.
93    #[must_use]
94    pub fn any_declared(&self) -> bool {
95        self.max_steps.is_some()
96            || self.max_tokens.is_some()
97            || self.max_cost_usd.is_some()
98            || self.max_wall_time.is_some()
99    }
100
101    /// The first crossing, if any, in the fixed check order (steps, tokens,
102    /// cost, wall time). Returns the crossed [`Budget`] (whose `limit` is
103    /// the *effective* limit: declared plus extensions) and the observed
104    /// value, both exactly as they will be recorded.
105    #[must_use]
106    pub fn first_crossing(
107        &self,
108        extensions: &BudgetExtensions,
109        pricing: Option<&Pricing>,
110        observations: &BudgetObservations,
111    ) -> Option<(Budget, f64)> {
112        if let Some(max_steps) = self.max_steps {
113            let limit = to_f64(max_steps.saturating_add(extensions.steps));
114            let observed = to_f64(observations.steps);
115            if observed >= limit {
116                return Some((
117                    Budget {
118                        kind: BudgetKind::Steps,
119                        limit,
120                    },
121                    observed,
122                ));
123            }
124        }
125        if let Some(max_tokens) = self.max_tokens {
126            let limit = to_f64(max_tokens.saturating_add(extensions.tokens));
127            let observed = to_f64(
128                observations
129                    .input_tokens
130                    .saturating_add(observations.output_tokens),
131            );
132            if observed >= limit {
133                return Some((
134                    Budget {
135                        kind: BudgetKind::Tokens,
136                        limit,
137                    },
138                    observed,
139                ));
140            }
141        }
142        if let (Some(max_cost), Some(pricing)) = (self.max_cost_usd, pricing) {
143            let limit = max_cost + extensions.cost_usd;
144            let observed = pricing.cost_usd(observations.input_tokens, observations.output_tokens);
145            if observed >= limit {
146                return Some((
147                    Budget {
148                        kind: BudgetKind::CostUsd,
149                        limit,
150                    },
151                    observed,
152                ));
153            }
154        }
155        if let Some(max_wall) = self.max_wall_time {
156            let limit = max_wall.as_secs_f64() + extensions.wall_time_seconds;
157            let observed = observations.elapsed_seconds;
158            if observed >= limit {
159                return Some((
160                    Budget {
161                        kind: BudgetKind::WallTime,
162                        limit,
163                    },
164                    observed,
165                ));
166            }
167        }
168        None
169    }
170}
171
172/// Per-token pricing, in US dollars per million tokens. Required by the
173/// agent builder whenever a cost budget is declared.
174#[derive(Clone, Copy, Debug, PartialEq)]
175pub struct Pricing {
176    /// Dollars per million input tokens.
177    pub input_per_mtok: f64,
178    /// Dollars per million output tokens.
179    pub output_per_mtok: f64,
180}
181
182impl Pricing {
183    /// The cost of the given recorded token counts under this pricing. A
184    /// pure function of integers and the fixed rates, so it reproduces bit
185    /// for bit on replay.
186    #[must_use]
187    pub fn cost_usd(&self, input_tokens: u64, output_tokens: u64) -> f64 {
188        to_f64(input_tokens) / 1_000_000.0 * self.input_per_mtok
189            + to_f64(output_tokens) / 1_000_000.0 * self.output_per_mtok
190    }
191}
192
193/// The replay-derived quantities a budget check consumes. The loop builds
194/// one of these at each iteration start, exclusively from recorded data.
195#[derive(Clone, Copy, Debug, Default, PartialEq)]
196pub struct BudgetObservations {
197    /// Completed model calls so far.
198    pub steps: u64,
199    /// Recorded input tokens accumulated so far.
200    pub input_tokens: u64,
201    /// Recorded output tokens accumulated so far.
202    pub output_tokens: u64,
203    /// Seconds between the first recorded `ctx.now()` observation and the
204    /// latest one.
205    pub elapsed_seconds: f64,
206}
207
208/// Folds a recorded log into the observations a budget check consumes.
209///
210/// The runtime's loop accumulates these as it drives, but every quantity it
211/// accumulates is itself recorded, so the same numbers can be read back out
212/// of the log afterwards. That is what makes a check reproducible off the
213/// event stream alone, which is what a browser has:
214///
215/// - **steps** is the count of `ModelCallCompleted` events, the loop's
216///   "completed model calls".
217/// - **tokens** are the recorded `usage` totals on those same events.
218/// - **elapsed** is the span between the first and the last `NowObserved`,
219///   the loop's baseline and its latest reading. Fewer than two observations
220///   means no span has elapsed, which is zero.
221///
222/// Pass the prefix the check would have seen. The loop checks *before* each
223/// model call, so the observations behind a recorded
224/// [`Event::BudgetExceeded`] at position `n` are the fold of `log[..n]`.
225///
226/// # Scope
227///
228/// This is the accounting of one agent run's log: what `salvor run` and
229/// `salvor resume` record, and what the loop counts. It is the whole log
230/// because the loop replays: on resume the driver re-enters at iteration
231/// zero and every recorded call is replayed through it, so its counters
232/// arrive at the live edge holding the run's full recorded history.
233///
234/// A graph run is deliberately not that shape. Its engine drives each node
235/// through its own loop with its own counters, so folding a graph log whole
236/// would sum quantities no single check ever saw. Fold one node's span, or
237/// do not use this on a graph log.
238#[must_use]
239pub fn budget_observations(log: &[EventEnvelope]) -> BudgetObservations {
240    let mut observations = BudgetObservations::default();
241    let mut first_now = None;
242    let mut last_now = None;
243
244    for envelope in log {
245        match &envelope.event {
246            Event::ModelCallCompleted { usage, .. } => {
247                observations.steps = observations.steps.saturating_add(1);
248                observations.input_tokens = observations
249                    .input_tokens
250                    .saturating_add(u64::from(usage.input_tokens));
251                observations.output_tokens = observations
252                    .output_tokens
253                    .saturating_add(u64::from(usage.output_tokens));
254            }
255            Event::NowObserved { now } => {
256                first_now.get_or_insert(*now);
257                last_now = Some(*now);
258            }
259            _ => {}
260        }
261    }
262
263    if let (Some(first), Some(last)) = (first_now, last_now) {
264        observations.elapsed_seconds = (last - first).as_seconds_f64();
265    }
266    observations
267}
268
269/// The accumulated budget extensions granted by recorded resume inputs.
270/// See the module docs for the JSON shape they are parsed from.
271#[derive(Clone, Copy, Debug, Default, PartialEq)]
272pub struct BudgetExtensions {
273    /// Extra steps granted.
274    pub steps: u64,
275    /// Extra tokens granted.
276    pub tokens: u64,
277    /// Extra dollars granted.
278    pub cost_usd: f64,
279    /// Extra wall-time seconds granted.
280    pub wall_time_seconds: f64,
281}
282
283impl BudgetExtensions {
284    /// Folds one resume input's `extend` object (if present) into the
285    /// accumulated totals. Unknown or ill-typed fields are ignored here;
286    /// rejecting them is [`validate_extension_input`]'s job, applied before
287    /// the input was ever recorded.
288    pub fn absorb(&mut self, resume_input: &Value) {
289        let Some(extend) = resume_input.get("extend").and_then(Value::as_object) else {
290            return;
291        };
292        if let Some(steps) = extend.get("steps").and_then(Value::as_u64) {
293            self.steps = self.steps.saturating_add(steps);
294        }
295        if let Some(tokens) = extend.get("tokens").and_then(Value::as_u64) {
296            self.tokens = self.tokens.saturating_add(tokens);
297        }
298        if let Some(cost) = extend.get("cost_usd").and_then(Value::as_f64) {
299            self.cost_usd += cost;
300        }
301        if let Some(seconds) = extend.get("wall_time_seconds").and_then(Value::as_f64) {
302            self.wall_time_seconds += seconds;
303        }
304    }
305}
306
307/// Folds a recorded log into the extensions a budget check has been granted.
308///
309/// The loop absorbs an extension exactly when a resume answers a budget
310/// crossing, so this absorbs the input of a [`Event::Resumed`] whose
311/// immediately preceding event is a [`Event::BudgetExceeded`], and no other.
312/// That adjacency is not a heuristic: `ctx.budget_exceeded` records the
313/// crossing and the `await_resume` that follows it records the answer, with
314/// nothing in between. A resume answering a *suspension* is a different
315/// conversation and is deliberately not absorbed here, even if its input
316/// happens to carry a key spelled `extend`.
317///
318/// Like [`budget_observations`], this is the whole log because the loop
319/// replays: on resume the driver re-absorbs every recorded extension in
320/// order before it reaches the live edge.
321#[must_use]
322pub fn budget_extensions(log: &[EventEnvelope]) -> BudgetExtensions {
323    let mut extensions = BudgetExtensions::default();
324    for pair in log.windows(2) {
325        if let (Event::BudgetExceeded { .. }, Event::Resumed { input }) =
326            (&pair[0].event, &pair[1].event)
327        {
328            extensions.absorb(input);
329        }
330    }
331    extensions
332}
333
334/// Validates a resume input against the budget-extension shape documented
335/// at module level. Applied by `Runtime::resume` when the run parked on a
336/// budget crossing, *before* the input is recorded.
337///
338/// # Errors
339///
340/// Returns a human-readable description of the first violation: a non-object
341/// input, an unexpected top-level key, a non-object `extend`, an unknown
342/// key inside `extend`, or a field with the wrong JSON type.
343pub fn validate_extension_input(input: &Value) -> Result<(), String> {
344    let Some(top) = input.as_object() else {
345        return Err("a budget-crossing resume input must be a JSON object".to_owned());
346    };
347    for key in top.keys() {
348        if key != "extend" {
349            return Err(format!(
350                "unexpected top-level key `{key}`; a budget-crossing resume input may only carry `extend`"
351            ));
352        }
353    }
354    let Some(extend) = top.get("extend") else {
355        return Ok(());
356    };
357    let Some(extend) = extend.as_object() else {
358        return Err("`extend` must be a JSON object".to_owned());
359    };
360    for (key, value) in extend {
361        match key.as_str() {
362            "steps" | "tokens" => {
363                if value.as_u64().is_none() {
364                    return Err(format!("`extend.{key}` must be an unsigned integer"));
365                }
366            }
367            "cost_usd" | "wall_time_seconds" => {
368                if value.as_f64().is_none() {
369                    return Err(format!("`extend.{key}` must be a number"));
370                }
371            }
372            other => {
373                return Err(format!(
374                    "unknown key `extend.{other}`; expected steps, tokens, cost_usd, or wall_time_seconds"
375                ));
376            }
377        }
378    }
379    Ok(())
380}
381
382/// Widens an integer count to `f64` for the wire's numeric budget fields.
383/// Exact for every count below 2^53, far beyond any real run.
384#[allow(clippy::cast_precision_loss)]
385fn to_f64(count: u64) -> f64 {
386    count as f64
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use serde_json::json;
393
394    /// Checks fire on reaching the limit and honor absorbed extensions.
395    #[test]
396    fn crossing_fires_at_limit_and_extensions_raise_it() {
397        let budgets = Budgets {
398            max_steps: Some(2),
399            ..Budgets::default()
400        };
401        let mut extensions = BudgetExtensions::default();
402        let observations = BudgetObservations {
403            steps: 2,
404            ..BudgetObservations::default()
405        };
406
407        let (budget, observed) = budgets
408            .first_crossing(&extensions, None, &observations)
409            .expect("steps crossing fires at the limit");
410        assert_eq!(budget.kind, BudgetKind::Steps);
411        assert_eq!(budget.limit, 2.0);
412        assert_eq!(observed, 2.0);
413
414        extensions.absorb(&json!({"extend": {"steps": 3}}));
415        assert_eq!(
416            budgets.first_crossing(&extensions, None, &observations),
417            None,
418            "the extension raises the effective limit past the observation"
419        );
420    }
421
422    /// The documented check order: steps beats tokens when both cross.
423    #[test]
424    fn check_order_is_steps_first() {
425        let budgets = Budgets {
426            max_steps: Some(1),
427            max_tokens: Some(10),
428            ..Budgets::default()
429        };
430        let observations = BudgetObservations {
431            steps: 1,
432            input_tokens: 100,
433            output_tokens: 100,
434            ..BudgetObservations::default()
435        };
436        let (budget, _) = budgets
437            .first_crossing(&BudgetExtensions::default(), None, &observations)
438            .expect("a crossing fires");
439        assert_eq!(budget.kind, BudgetKind::Steps);
440    }
441
442    /// Cost uses pricing over recorded token counts.
443    #[test]
444    fn cost_crossing_uses_pricing() {
445        let budgets = Budgets {
446            max_cost_usd: Some(1.0),
447            ..Budgets::default()
448        };
449        let pricing = Pricing {
450            input_per_mtok: 3.0,
451            output_per_mtok: 15.0,
452        };
453        let observations = BudgetObservations {
454            input_tokens: 200_000,
455            output_tokens: 40_000,
456            ..BudgetObservations::default()
457        };
458        // 0.2 mtok * 3 + 0.04 mtok * 15 = 0.6 + 0.6 = 1.2 >= 1.0.
459        let (budget, observed) = budgets
460            .first_crossing(&BudgetExtensions::default(), Some(&pricing), &observations)
461            .expect("cost crossing fires");
462        assert_eq!(budget.kind, BudgetKind::CostUsd);
463        assert!((observed - 1.2).abs() < 1e-12);
464    }
465
466    /// The extension validator accepts the documented shape and rejects
467    /// obviously wrong ones.
468    #[test]
469    fn extension_validation_rejects_wrong_shapes() {
470        assert!(validate_extension_input(&json!({})).is_ok());
471        assert!(validate_extension_input(&json!({"extend": {"steps": 2}})).is_ok());
472        assert!(
473            validate_extension_input(&json!({
474                "extend": {"steps": 1, "tokens": 2, "cost_usd": 0.5, "wall_time_seconds": 60}
475            }))
476            .is_ok()
477        );
478        assert!(validate_extension_input(&json!("more please")).is_err());
479        assert!(validate_extension_input(&json!({"other": 1})).is_err());
480        assert!(validate_extension_input(&json!({"extend": 5})).is_err());
481        assert!(validate_extension_input(&json!({"extend": {"stepz": 1}})).is_err());
482        assert!(validate_extension_input(&json!({"extend": {"steps": -1}})).is_err());
483        assert!(validate_extension_input(&json!({"extend": {"cost_usd": "1"}})).is_err());
484    }
485}