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}