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}