salvor_runtime/ctx.rs
1//! [`RunCtx`]: the public durability substrate. One recorded run, one
2//! context; every operation is answered from history or executed live and
3//! persisted immediately.
4//!
5//! This is the library-first tier: a Rust team
6//! that wants to own its control flow writes an ordinary async function
7//! against this type and gets the same durability, replay, and budget
8//! guarantees as the built-in loop, which is itself written against exactly
9//! this surface.
10//!
11//! # What it owns, and what it wraps
12//!
13//! The pure replay cursor in `salvor-core` refuses to own three things: a
14//! store, executors, and the ambient clock/RNG. `RunCtx` owns all three and
15//! wraps each cursor request one to one:
16//!
17//! | `RunCtx` method | cursor request | live side effect |
18//! |---|---|---|
19//! | [`begin`](RunCtx::begin) | `begin` | persist `RunStarted` |
20//! | [`now`](RunCtx::now) | `now` | read the injected clock, persist |
21//! | [`random`](RunCtx::random) | `random` | draw from the injected RNG, persist |
22//! | [`model_call`](RunCtx::model_call) | `model_call` | persist intent, call provider, persist completion |
23//! | [`tool_call`](RunCtx::tool_call) | `tool_call` | persist intent **before executing**, execute, persist completion |
24//! | [`suspend`](RunCtx::suspend) | `suspend` | persist `Suspended` |
25//! | [`await_resume`](RunCtx::await_resume) | `await_resume` | persist `Resumed` when input was provided |
26//! | [`budget_exceeded`](RunCtx::budget_exceeded) | `budget_exceeded` | persist `BudgetExceeded` |
27//! | [`complete_run`](RunCtx::complete_run) / [`fail_run`](RunCtx::fail_run) | same | persist the terminal event |
28//!
29//! Every live permit redemption persists its event *immediately*, with a
30//! timestamp read from the injected clock at this IO edge. Nothing is
31//! buffered: when a method returns `Ok`, the event is durable.
32//!
33//! # Injected clock and randomness
34//!
35//! The constructor takes the clock and RNG as functions. The defaults read
36//! the real clock and the operating system's randomness; tests inject
37//! deterministic ones, which makes whole event logs (envelopes included)
38//! comparable across runs. Note the injection covers the *envelope
39//! timestamps and observations*, not replay: replayed values always come
40//! from the log, whatever functions are installed.
41//!
42//! # Write-ahead ordering
43//!
44//! [`tool_call`](RunCtx::tool_call) persists the intent event and only then
45//! executes the tool. For a `Write`-effect tool this ordering is the whole
46//! reconciliation story: a crash between intent and completion leaves
47//! evidence, and resume refuses to guess. Model intents persist before the
48//! provider call for the same reason (though a dangling model intent is
49//! safely re-issued rather than reconciled).
50//!
51//! # Retries inside one tool call
52//!
53//! One `tool_call` is one intent/completion pair, so retries of a failed
54//! live execution happen *inside* the call, between the two events, honoring
55//! `RetryPolicy`: `Read` and `Idempotent` handler failures re-execute up to
56//! [`MAX_TOOL_ATTEMPTS`] total attempts (idempotent retries reuse the same
57//! key, carried on `ToolCtx`), `Write` failures never re-execute, and input
58//! validation or output serialization failures never retry because they
59//! would fail identically again. Whatever the final result, the completion
60//! is recorded: an output, a suspension sentinel, or a failure object (see
61//! [`crate::wire`]).
62
63use std::collections::BTreeMap;
64use std::sync::Arc;
65
66use salvor_core::{
67 Budget, Emitted, EventEnvelope, ModelReply, Outcome, ReplayCursor, RunId, SequenceNumber,
68 TokenUsage,
69};
70use salvor_llm::{Client, MessageAccumulator, MessageRequest, MessageResponse, StreamEvent};
71use salvor_store::EventStore;
72use salvor_tools::{DynTool, RetryPolicy, Suspension, ToolCtx, ToolError, ToolOutcome};
73use serde_json::Value;
74use time::OffsetDateTime;
75use uuid::Uuid;
76
77use crate::error::RuntimeError;
78use crate::hash::hash_value;
79use crate::labels::validate_labels;
80use crate::model::{response_value, usage_of};
81use crate::wire::{
82 ToolFailure, decode_failure, decode_suspension, encode_failure, encode_suspension,
83};
84
85/// The injected clock: called once per persisted event (for the envelope
86/// timestamp) and once per live [`RunCtx::now`] observation.
87pub type ClockFn = Arc<dyn Fn() -> OffsetDateTime + Send + Sync>;
88
89/// The injected random source: called once per live [`RunCtx::random`]
90/// observation, returning 64 raw bits.
91pub type RandomFn = Arc<dyn Fn() -> u64 + Send + Sync>;
92
93/// The cap on total executions of one live tool call, counting the first
94/// attempt. Applies only where `RetryPolicy` allows retrying at all.
95pub const MAX_TOOL_ATTEMPTS: u32 = 3;
96
97/// A model call's result: the typed response plus the token usage recorded
98/// for it. Identical whether the call was executed live or replayed.
99#[derive(Debug, Clone)]
100pub struct ModelTurn {
101 /// The model's response.
102 pub response: MessageResponse,
103 /// The recorded token usage for this call.
104 pub usage: TokenUsage,
105}
106
107/// A tool call's result, decoded from the recorded completion output (the
108/// decoding is identical live and on replay, which is what keeps a resumed
109/// orchestration on the recorded path).
110#[derive(Debug, Clone)]
111pub enum ToolCallResult {
112 /// The tool produced this output.
113 Output(Value),
114 /// The tool failed after exhausting its retry policy; the full error is
115 /// recorded in the completion. See [`crate::wire`] for the shape.
116 Failed(ToolFailure),
117 /// The tool asked to park the run. Follow with [`RunCtx::suspend`] and
118 /// [`RunCtx::await_resume`].
119 Suspended(Suspension),
120}
121
122/// What [`RunCtx::await_resume`] produced.
123#[derive(Debug, Clone)]
124pub enum Resumption {
125 /// The resume input, recorded or just persisted. Continue the run.
126 Resumed(Value),
127 /// No resume input exists yet. The run is parked durably; the log
128 /// already holds everything, so the process may simply stop driving it.
129 Parked,
130}
131
132/// The public durability substrate for one run. See the module docs.
133pub struct RunCtx {
134 cursor: ReplayCursor,
135 store: Arc<dyn EventStore>,
136 run_id: RunId,
137 clock: ClockFn,
138 random: RandomFn,
139 resume_input: Option<Value>,
140 /// Whether to record the full model request body on each
141 /// `ModelCallRequested`. Off unless [`with_record_prompts`](Self::with_record_prompts)
142 /// turns it on. See that method for the PII rationale.
143 record_prompts: bool,
144 /// Correlation tags to stamp on a genuinely fresh `RunStarted`. Unset
145 /// unless [`with_labels`](Self::with_labels) sets them. See that method.
146 labels: Option<BTreeMap<String, String>>,
147}
148
149impl RunCtx {
150 /// Builds a context over a run's recorded log (empty for a fresh run),
151 /// with the default clock (the real UTC clock) and the default random
152 /// source (operating-system randomness).
153 ///
154 /// # Errors
155 ///
156 /// Returns [`RuntimeError::Replay`] when the log is not a well-formed
157 /// run history.
158 pub fn new(
159 store: Arc<dyn EventStore>,
160 run_id: RunId,
161 log: Vec<EventEnvelope>,
162 ) -> Result<Self, RuntimeError> {
163 Self::with_hooks(
164 store,
165 run_id,
166 log,
167 Arc::new(OffsetDateTime::now_utc),
168 Arc::new(os_random),
169 )
170 }
171
172 /// Builds a context with an injected clock and random source.
173 ///
174 /// The clock stamps every persisted envelope and answers live
175 /// [`now`](Self::now) observations; the random source answers live
176 /// [`random`](Self::random) observations. Injecting deterministic
177 /// functions makes complete event logs comparable across runs, which is
178 /// how the kill/resume tests prove byte-identical recovery.
179 ///
180 /// # Errors
181 ///
182 /// Returns [`RuntimeError::Replay`] when the log is not a well-formed
183 /// run history.
184 pub fn with_hooks(
185 store: Arc<dyn EventStore>,
186 run_id: RunId,
187 log: Vec<EventEnvelope>,
188 clock: ClockFn,
189 random: RandomFn,
190 ) -> Result<Self, RuntimeError> {
191 let cursor = ReplayCursor::new(log)?;
192 Ok(Self {
193 cursor,
194 store,
195 run_id,
196 clock,
197 random,
198 resume_input: None,
199 record_prompts: false,
200 labels: None,
201 })
202 }
203
204 /// Turns on recording of the full model request body into the durable log.
205 ///
206 /// Additive and off by default: the existing [`new`](Self::new) and
207 /// [`with_hooks`](Self::with_hooks) constructors leave it off, so no
208 /// caller that predates this method changes behavior. Chained builder
209 /// style keeps those signatures intact, which is why the flag arrives this
210 /// way rather than as a new constructor argument.
211 ///
212 /// When on, each live [`model_call`](Self::model_call) records the exact
213 /// request it sent on the `ModelCallRequested` event, so the v0.3 dashboard
214 /// inspector can show the prompt. This is PII-sensitive: the body can hold
215 /// user data and secrets, which is why the default is off and turning it on
216 /// is a deliberate per-agent or operator choice. The recorded body lands
217 /// only in the event log; it never reaches the progress stream or any
218 /// console output. It does not affect replay: the request hash is computed
219 /// the same either way, and replay ignores the body.
220 #[must_use]
221 pub fn with_record_prompts(mut self, record_prompts: bool) -> Self {
222 self.record_prompts = record_prompts;
223 self
224 }
225
226 /// Sets the correlation tags to stamp on a genuinely fresh `RunStarted`.
227 ///
228 /// Additive and unset by default: the existing [`new`](Self::new) and
229 /// [`with_hooks`](Self::with_hooks) constructors leave it unset, so no
230 /// caller that predates this method changes behavior. Chained builder
231 /// style, mirroring [`with_record_prompts`](Self::with_record_prompts).
232 ///
233 /// Labels are checked against the sanity bounds (see
234 /// [`crate::validate_labels`]) only on [`begin`](Self::begin)'s live path,
235 /// the moment a `RunStarted` is actually about to be created;
236 /// [`RuntimeError::InvalidLabels`] surfaces there, not here, so this
237 /// setter itself is infallible. A replayed `begin` never re-checks them:
238 /// whatever the log already holds is trusted and returned as recorded.
239 /// Labels never enter `agent_def_hash` or any request hash; they are a
240 /// tag on the run, not part of its identity.
241 #[must_use]
242 pub fn with_labels(mut self, labels: BTreeMap<String, String>) -> Self {
243 self.labels = Some(labels);
244 self
245 }
246
247 /// Provides the input a parked run is being resumed with. The next
248 /// [`await_resume`](Self::await_resume) that reaches live mode records
249 /// it as the `Resumed` event and returns it; without one, a live
250 /// `await_resume` reports [`Resumption::Parked`].
251 pub fn set_resume_input(&mut self, input: Value) {
252 self.resume_input = Some(input);
253 }
254
255 /// The run this context drives.
256 #[must_use]
257 pub fn run_id(&self) -> RunId {
258 self.run_id
259 }
260
261 /// Whether recorded history remains to be consumed.
262 #[must_use]
263 pub fn is_replaying(&self) -> bool {
264 self.cursor.is_replaying()
265 }
266
267 /// The log position the next consumed or emitted event occupies.
268 #[must_use]
269 pub fn next_seq(&self) -> SequenceNumber {
270 self.cursor.next_seq()
271 }
272
273 /// Starts (or replays the start of) the run.
274 ///
275 /// Live: records `RunStarted` with `input` and the labels set through
276 /// [`with_labels`](Self::with_labels) (if any), and returns `input`.
277 /// Replayed: verifies `agent_def_hash` against the recorded event and
278 /// returns the *recorded* input, which always wins; the `input` argument
279 /// is only used when the log is empty, exactly like `labels`.
280 ///
281 /// # Errors
282 ///
283 /// [`RuntimeError::Replay`] on a definition-hash mismatch or any other
284 /// divergence; [`RuntimeError::InvalidLabels`] when the labels set
285 /// through [`with_labels`](Self::with_labels) violate the sanity bounds
286 /// (only checked on the live path; see that method); [`RuntimeError::Store`]
287 /// when persistence fails.
288 pub async fn begin(
289 &mut self,
290 agent_def_hash: &str,
291 input: &Value,
292 ) -> Result<Value, RuntimeError> {
293 match self.cursor.begin(agent_def_hash, self.labels.clone())? {
294 Outcome::Replayed(recorded) => Ok(recorded),
295 Outcome::Live(permit) => {
296 if let Some(labels) = &self.labels {
297 validate_labels(labels).map_err(RuntimeError::InvalidLabels)?;
298 }
299 let emitted = permit.record(input.clone());
300 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await?;
301 Ok(input.clone())
302 }
303 }
304 }
305
306 /// Starts (or replays the start of) a graph run: the graph-document
307 /// counterpart of [`begin`](Self::begin).
308 ///
309 /// Live: records [`salvor_core::Event::GraphRunStarted`] with `input`, the
310 /// labels set through [`with_labels`](Self::with_labels) (if any), and no
311 /// fork origin, then returns `input`. Replayed: verifies `graph_hash`
312 /// against the recorded head (a changed graph document must not silently
313 /// resume an old run) and returns the *recorded* input, which always wins.
314 ///
315 /// A graph run's log opens with this event rather than `RunStarted` because
316 /// a graph coordinates many agent hashes and has none at its head. The graph
317 /// engine calls this once, then frames each node with
318 /// [`node_entered`](Self::node_entered) / [`node_exited`](Self::node_exited)
319 /// and records the single terminal itself after the last node.
320 ///
321 /// # Errors
322 ///
323 /// [`RuntimeError::Replay`] on a graph-hash mismatch or any other
324 /// divergence; [`RuntimeError::InvalidLabels`] when the labels set through
325 /// [`with_labels`](Self::with_labels) violate the sanity bounds (only
326 /// checked on the live path, exactly as [`begin`](Self::begin) does);
327 /// [`RuntimeError::Store`] when persistence fails.
328 pub async fn begin_graph(
329 &mut self,
330 graph_hash: &str,
331 input: &Value,
332 ) -> Result<Value, RuntimeError> {
333 match self
334 .cursor
335 .begin_graph(graph_hash, self.labels.clone(), None)?
336 {
337 Outcome::Replayed(recorded) => Ok(recorded),
338 Outcome::Live(permit) => {
339 if let Some(labels) = &self.labels {
340 validate_labels(labels).map_err(RuntimeError::InvalidLabels)?;
341 }
342 let emitted = permit.record(input.clone());
343 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await?;
344 Ok(input.clone())
345 }
346 }
347 }
348
349 /// Records (or replays) entry into a graph node. A graph node's own events
350 /// (an agent loop's model calls, a tool call) are recorded between this and
351 /// the matching [`node_exited`](Self::node_exited).
352 ///
353 /// # Errors
354 ///
355 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
356 /// persistence fails.
357 pub async fn node_entered(&mut self, node: &str) -> Result<(), RuntimeError> {
358 match self.cursor.node_entered(node)? {
359 Outcome::Replayed(()) => Ok(()),
360 Outcome::Live(emitted) => {
361 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
362 }
363 }
364 }
365
366 /// Records (or replays) exit from a graph node, having produced its output.
367 /// The counterpart of [`node_entered`](Self::node_entered).
368 ///
369 /// # Errors
370 ///
371 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
372 /// persistence fails.
373 pub async fn node_exited(&mut self, node: &str) -> Result<(), RuntimeError> {
374 match self.cursor.node_exited(node)? {
375 Outcome::Replayed(()) => Ok(()),
376 Outcome::Live(emitted) => {
377 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
378 }
379 }
380 }
381
382 /// Records (or replays) that a graph node was skipped: reached on the walk
383 /// but deliberately not run (a branch routed past it). Unlike an executed
384 /// node there is no [`node_entered`](Self::node_entered)/[`node_exited`](Self::node_exited)
385 /// pair; the skip is the node's sole marker, which is what lets a projection
386 /// tell "skipped" apart from "never reached". `reason` must be a pure
387 /// function of the document and recorded values so it reproduces on replay.
388 ///
389 /// # Errors
390 ///
391 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
392 /// persistence fails.
393 pub async fn node_skipped(&mut self, node: &str, reason: &str) -> Result<(), RuntimeError> {
394 match self.cursor.node_skipped(node, reason)? {
395 Outcome::Replayed(()) => Ok(()),
396 Outcome::Live(emitted) => {
397 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
398 }
399 }
400 }
401
402 /// Records (or replays) that a branch node routed: the named `case` fired.
403 /// Recorded between the branch's [`node_entered`](Self::node_entered) and
404 /// [`node_exited`](Self::node_exited), it is the sole authority for which way
405 /// the branch went. The chosen `case` must be a deterministic function of
406 /// recorded values (a pure expression over the routed value, or a decision
407 /// recomputed from a replayed model reply) so replay reproduces the route.
408 ///
409 /// # Errors
410 ///
411 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
412 /// persistence fails.
413 pub async fn branch_taken(&mut self, node: &str, case: &str) -> Result<(), RuntimeError> {
414 match self.cursor.branch_taken(node, case)? {
415 Outcome::Replayed(()) => Ok(()),
416 Outcome::Live(emitted) => {
417 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
418 }
419 }
420 }
421
422 /// Records (or replays) that a map node fanned out over a resolved item list.
423 ///
424 /// Recorded between the map node's [`node_entered`](Self::node_entered) and its
425 /// per-iteration markers. The `items` must be a deterministic function of
426 /// recorded values — the map's `over` reference resolved against the recorded
427 /// routed value — so replay reproduces the identical fan-out, which is what
428 /// makes the derived per-iteration child ids reproducible.
429 ///
430 /// # Errors
431 ///
432 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
433 /// persistence fails.
434 pub async fn map_fanned_out(&mut self, node: &str, items: &Value) -> Result<(), RuntimeError> {
435 match self.cursor.map_fanned_out(node, items)? {
436 Outcome::Replayed(()) => Ok(()),
437 Outcome::Live(emitted) => {
438 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
439 }
440 }
441 }
442
443 /// Records (or replays) that one iteration of a map fan-out started, as a child
444 /// run with the derived id `child_run`. The `child_run` is derived from the
445 /// parent run id, the node id, and the index. On replay the RECORDED id wins
446 /// and the match is on `node` + `index` alone, so a fork — which replays the
447 /// origin's prefix under a new run id and thus re-derives a different id —
448 /// still replays its inherited map markers cleanly.
449 ///
450 /// # Errors
451 ///
452 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
453 /// persistence fails.
454 pub async fn map_iteration_started(
455 &mut self,
456 node: &str,
457 index: u64,
458 child_run: &str,
459 ) -> Result<(), RuntimeError> {
460 match self.cursor.map_iteration_started(node, index, child_run)? {
461 Outcome::Replayed(()) => Ok(()),
462 Outcome::Live(emitted) => {
463 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
464 }
465 }
466 }
467
468 /// Records (or replays) that one iteration of a map fan-out joined back into
469 /// the map node's output. Joins must be recorded in index order, never
470 /// completion order, so the concurrency of the fan-out never influences the
471 /// parent log's byte sequence.
472 ///
473 /// # Errors
474 ///
475 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
476 /// persistence fails.
477 pub async fn map_iteration_joined(
478 &mut self,
479 node: &str,
480 index: u64,
481 ) -> Result<(), RuntimeError> {
482 match self.cursor.map_iteration_joined(node, index)? {
483 Outcome::Replayed(()) => Ok(()),
484 Outcome::Live(emitted) => {
485 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
486 }
487 }
488 }
489
490 /// The recorded clock: reads the injected clock once, live, and replays
491 /// the identical instant forever after.
492 ///
493 /// # Errors
494 ///
495 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
496 /// persistence fails.
497 pub async fn now(&mut self) -> Result<OffsetDateTime, RuntimeError> {
498 match self.cursor.now()? {
499 Outcome::Replayed(instant) => Ok(instant),
500 Outcome::Live(permit) => {
501 let instant = (self.clock)();
502 let emitted = permit.record(instant);
503 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await?;
504 Ok(instant)
505 }
506 }
507 }
508
509 /// The recorded random source: draws 64 bits from the injected source
510 /// once, live, and replays the identical bits forever after. Richer
511 /// random values must be derived from these bits deterministically.
512 ///
513 /// # Errors
514 ///
515 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
516 /// persistence fails.
517 pub async fn random(&mut self) -> Result<u64, RuntimeError> {
518 match self.cursor.random()? {
519 Outcome::Replayed(bits) => Ok(bits),
520 Outcome::Live(permit) => {
521 let bits = (self.random)();
522 let emitted = permit.record(bits);
523 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await?;
524 Ok(bits)
525 }
526 }
527 }
528
529 /// A recorded model call.
530 ///
531 /// The request is identified by its content hash
532 /// (`sha256:` over the canonical serialization; see [`crate::hash`]).
533 /// Replayed: the recorded response is decoded and returned; the provider
534 /// is never contacted. Live: the intent event is persisted, the provider
535 /// is called through `client`, and the completion (response plus usage)
536 /// is persisted. A recorded intent with no completion (a call the
537 /// process died inside) is re-issued safely: the fresh completion
538 /// correlates to the recorded intent.
539 ///
540 /// When [`with_record_prompts`](Self::with_record_prompts) is on, the exact
541 /// request body is recorded alongside the hash on the fresh live intent.
542 /// It is the same value the hash was computed over, it never feeds into the
543 /// hash, and replay ignores it, so recording it changes nothing about how
544 /// the run replays.
545 ///
546 /// # Errors
547 ///
548 /// [`RuntimeError::Replay`] on divergence, [`RuntimeError::Store`] when
549 /// persistence fails, [`RuntimeError::Model`] when the live provider
550 /// call fails (the log stays intact and the run is recoverable),
551 /// [`RuntimeError::RequestEncode`] / [`RuntimeError::RecordedResponseDecode`]
552 /// on the JSON edges.
553 pub async fn model_call(
554 &mut self,
555 client: &Client,
556 request: &MessageRequest,
557 ) -> Result<ModelTurn, RuntimeError> {
558 let request_value = serde_json::to_value(request).map_err(RuntimeError::RequestEncode)?;
559 let request_hash = hash_value(&request_value);
560 // The hash is computed above from `request_value` and is unaffected by
561 // what follows. When prompt recording is on, the body handed to the
562 // cursor is that same `request_value`, so the recorded body is exactly
563 // what was hashed; when off it is `None` and nothing is recorded.
564 let request_body = if self.record_prompts {
565 Some(request_value)
566 } else {
567 None
568 };
569 match self.cursor.model_call(&request_hash, request_body)? {
570 Outcome::Replayed(ModelReply { response, usage }) => {
571 let response = serde_json::from_value(response)
572 .map_err(RuntimeError::RecordedResponseDecode)?;
573 Ok(ModelTurn { response, usage })
574 }
575 Outcome::Live(permit) => {
576 if let Some(intent) = permit.intent().cloned() {
577 persist(self.store.as_ref(), self.run_id, &self.clock, &intent).await?;
578 }
579 let response = client.send_message(request).await?;
580 let usage = usage_of(&response);
581 let completion = permit.record(response_value(&response), usage);
582 persist(self.store.as_ref(), self.run_id, &self.clock, &completion).await?;
583 Ok(ModelTurn { response, usage })
584 }
585 }
586 }
587
588 /// A recorded model call that streams live events to `on_event` while it
589 /// runs, recording the identical completion [`model_call`](Self::model_call)
590 /// would record.
591 ///
592 /// This is a live-progress affordance layered on top of the durable record,
593 /// not a different kind of call. The recorded log is byte-for-byte what
594 /// [`model_call`](Self::model_call) writes for the same underlying response:
595 /// the request is hashed the same way (see [`crate::hash`]), the intent is
596 /// the same `ModelCallRequested`, and the completion carries the same
597 /// `response` value and `usage`. A run does not care which path recorded it,
598 /// and replay is deterministic either way.
599 ///
600 /// Replayed: the recorded response is decoded and returned, exactly as
601 /// [`model_call`](Self::model_call) does. The provider is never contacted and
602 /// `on_event` never fires, because there are no live tokens to report; the
603 /// caller gets the final result at once.
604 ///
605 /// Live: the intent event is persisted first (write-ahead, the same ordering
606 /// [`model_call`](Self::model_call) uses), then the provider stream is opened
607 /// through `client`. Each [`StreamEvent`] is handed to `on_event` for a live
608 /// ticker (text deltas ride [`StreamEvent::ContentBlockDelta`], token counts
609 /// ride [`StreamEvent::MessageDelta`]) and, in the same pass, applied to a
610 /// [`MessageAccumulator`]. When the stream ends, the assembled
611 /// [`MessageResponse`] is converted with the same `response_value` and usage
612 /// logic [`model_call`](Self::model_call) uses, the completion is persisted,
613 /// and the [`ModelTurn`] is returned.
614 ///
615 /// All persistence lives inside this method, so a caller cannot record a
616 /// partial or wrong completion: the completion is written only after the
617 /// stream is fully assembled. A caller that drops the returned future before
618 /// the stream completes leaves a dangling model intent (the write-ahead
619 /// intent with no completion), exactly like a live [`model_call`](Self::model_call)
620 /// the process died inside. That intent is re-issued safely on resume: the
621 /// fresh completion correlates to the recorded intent. `on_event` firing is
622 /// not part of the durable record, so a ticker that saw partial tokens before
623 /// the drop has no effect on what replay produces.
624 ///
625 /// When [`with_record_prompts`](Self::with_record_prompts) is on, the exact
626 /// request body is recorded on the fresh live intent, identically to
627 /// [`model_call`](Self::model_call).
628 ///
629 /// # Errors
630 ///
631 /// [`RuntimeError::Replay`] on divergence, [`RuntimeError::Store`] when
632 /// persistence fails, [`RuntimeError::Model`] when the live stream fails
633 /// (opening it, an error event or transport fault mid-stream, or a
634 /// tool-call fragment that does not parse) surfaced as the same error type
635 /// [`model_call`](Self::model_call) returns, with the log left intact and the
636 /// run recoverable, and [`RuntimeError::RequestEncode`] /
637 /// [`RuntimeError::RecordedResponseDecode`] on the JSON edges.
638 pub async fn model_call_streaming(
639 &mut self,
640 client: &Client,
641 request: &MessageRequest,
642 mut on_event: impl FnMut(&StreamEvent),
643 ) -> Result<ModelTurn, RuntimeError> {
644 let request_value = serde_json::to_value(request).map_err(RuntimeError::RequestEncode)?;
645 let request_hash = hash_value(&request_value);
646 // Hashing and body recording are identical to `model_call`: the hash is
647 // computed from `request_value` above, and the body handed to the cursor
648 // is that same value when recording is on, `None` when off. Streaming
649 // changes nothing here, which is half of why the recorded intent matches.
650 let request_body = if self.record_prompts {
651 Some(request_value)
652 } else {
653 None
654 };
655 match self.cursor.model_call(&request_hash, request_body)? {
656 Outcome::Replayed(ModelReply { response, usage }) => {
657 // No live call, so `on_event` never fires: replay has no tokens.
658 let response = serde_json::from_value(response)
659 .map_err(RuntimeError::RecordedResponseDecode)?;
660 Ok(ModelTurn { response, usage })
661 }
662 Outcome::Live(permit) => {
663 if let Some(intent) = permit.intent().cloned() {
664 persist(self.store.as_ref(), self.run_id, &self.clock, &intent).await?;
665 }
666 // Pump the stream once: every event feeds the ticker and the
667 // accumulator in the same pass. The accumulator assembles the
668 // exact `MessageResponse` `send_message` would have returned
669 // (salvor-llm guarantees this), so the recorded completion below
670 // is byte-identical to the non-streaming path.
671 let mut stream = client.stream_message(request).await?;
672 let mut accumulator = MessageAccumulator::new();
673 while let Some(event) = stream.next_event().await {
674 let event = event?;
675 on_event(&event);
676 accumulator.apply(&event)?;
677 }
678 let response = accumulator.into_message()?;
679 let usage = usage_of(&response);
680 let completion = permit.record(response_value(&response), usage);
681 persist(self.store.as_ref(), self.run_id, &self.clock, &completion).await?;
682 Ok(ModelTurn { response, usage })
683 }
684 }
685 }
686
687 /// A recorded tool call: one intent/completion pair, whatever happens in
688 /// between.
689 ///
690 /// Replayed: the recorded completion output is decoded (an output, a
691 /// failure object, or a suspension sentinel; see [`crate::wire`]) and
692 /// the tool is never executed. Live: the intent is persisted **before**
693 /// the tool executes (write-ahead), the tool runs with retries per its
694 /// effect's `RetryPolicy` (see [`MAX_TOOL_ATTEMPTS`]), and the
695 /// completion is persisted. A recorded `Read`/`Idempotent` intent with
696 /// no completion re-executes here under its recorded idempotency key; a
697 /// dangling `Write` intent fails with
698 /// `ReplayError::NeedsReconciliation` before anything runs.
699 ///
700 /// `idempotency_key` is the key for a *fresh* call; the built-in loop
701 /// derives it from [`random`](Self::random) for `Idempotent` tools so it
702 /// reproduces on replay. For a re-executed recorded intent the recorded
703 /// key wins, whatever is passed here must match it (the cursor checks).
704 ///
705 /// # Errors
706 ///
707 /// [`RuntimeError::Replay`] on divergence or a dangling write intent;
708 /// [`RuntimeError::Store`] when persistence fails. A failing *tool* is
709 /// not an `Err`: it returns [`ToolCallResult::Failed`], because the
710 /// failure is a recorded outcome the orchestration must handle
711 /// deterministically.
712 pub async fn tool_call(
713 &mut self,
714 tool: &dyn DynTool,
715 input: &Value,
716 idempotency_key: Option<&str>,
717 ) -> Result<ToolCallResult, RuntimeError> {
718 let effect = tool.effect();
719 match self
720 .cursor
721 .tool_call(tool.name(), input, effect, idempotency_key)?
722 {
723 Outcome::Replayed(output) => Ok(decode_tool_output(output)),
724 Outcome::Live(permit) => {
725 if let Some(intent) = permit.intent().cloned() {
726 persist(self.store.as_ref(), self.run_id, &self.clock, &intent).await?;
727 }
728 let key = permit.idempotency_key().map(ToOwned::to_owned);
729 let tool_ctx = ToolCtx::new(key);
730 let policy = RetryPolicy::for_effect(effect);
731 let mut attempts: u32 = 0;
732 let outcome = loop {
733 attempts += 1;
734 match tool.call_json(&tool_ctx, input.clone()).await {
735 Ok(outcome) => break Ok(outcome),
736 Err(error) => {
737 // Only a handler failure is retryable, and only
738 // when the effect's policy allows a re-attempt.
739 let may_retry = matches!(error, ToolError::Handler { .. })
740 && policy.allows_retry()
741 && attempts < MAX_TOOL_ATTEMPTS;
742 if may_retry {
743 continue;
744 }
745 break Err(error);
746 }
747 }
748 };
749 let (output, result) = match outcome {
750 Ok(ToolOutcome::Output(value)) => {
751 (value.clone(), ToolCallResult::Output(value))
752 }
753 Ok(ToolOutcome::Suspend(suspension)) => (
754 encode_suspension(&suspension),
755 ToolCallResult::Suspended(suspension),
756 ),
757 Err(error) => {
758 let failure = ToolFailure::from_error(&error, attempts);
759 (encode_failure(&failure), ToolCallResult::Failed(failure))
760 }
761 };
762 let completion = permit.record(output);
763 persist(self.store.as_ref(), self.run_id, &self.clock, &completion).await?;
764 Ok(result)
765 }
766 }
767 }
768
769 /// Parks the run: records `Suspended { reason, input_schema }`. Follow
770 /// with [`await_resume`](Self::await_resume).
771 ///
772 /// # Errors
773 ///
774 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
775 /// persistence fails.
776 pub async fn suspend(
777 &mut self,
778 reason: &str,
779 input_schema: &Value,
780 ) -> Result<(), RuntimeError> {
781 match self.cursor.suspend(reason, input_schema)? {
782 Outcome::Replayed(()) => Ok(()),
783 Outcome::Live(emitted) => {
784 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
785 }
786 }
787 }
788
789 /// Obtains the input a parked run was resumed with.
790 ///
791 /// Replayed: the recorded `Resumed` input. Live: when a resume input was
792 /// provided through [`set_resume_input`](Self::set_resume_input), it is
793 /// recorded and returned; otherwise the run stays parked and
794 /// [`Resumption::Parked`] tells the caller to stop driving.
795 ///
796 /// # Errors
797 ///
798 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
799 /// persistence fails.
800 pub async fn await_resume(&mut self) -> Result<Resumption, RuntimeError> {
801 match self.cursor.await_resume()? {
802 Outcome::Replayed(input) => Ok(Resumption::Resumed(input)),
803 Outcome::Live(parked) => match self.resume_input.take() {
804 Some(input) => {
805 let emitted = parked.resume(input.clone());
806 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await?;
807 Ok(Resumption::Resumed(input))
808 }
809 None => Ok(Resumption::Parked),
810 },
811 }
812 }
813
814 /// Records a budget crossing. The check that led here must be computed
815 /// from replayed data (recorded usage, recorded `now` observations) so
816 /// it re-fires identically on replay. Follow with
817 /// [`await_resume`](Self::await_resume), exactly like a suspension.
818 ///
819 /// # Errors
820 ///
821 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
822 /// persistence fails.
823 pub async fn budget_exceeded(
824 &mut self,
825 budget: Budget,
826 observed: f64,
827 ) -> Result<(), RuntimeError> {
828 match self.cursor.budget_exceeded(budget, observed)? {
829 Outcome::Replayed(()) => Ok(()),
830 Outcome::Live(emitted) => {
831 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
832 }
833 }
834 }
835
836 /// Completes the run with `output`. Every request after this is a
837 /// divergence.
838 ///
839 /// # Errors
840 ///
841 /// [`RuntimeError::Replay`] on divergence (including an output that does
842 /// not match the recorded one); [`RuntimeError::Store`] when persistence
843 /// fails.
844 pub async fn complete_run(&mut self, output: &Value) -> Result<(), RuntimeError> {
845 match self.cursor.complete_run(output)? {
846 Outcome::Replayed(()) => Ok(()),
847 Outcome::Live(emitted) => {
848 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
849 }
850 }
851 }
852
853 /// Fails the run with `error`. Every request after this is a divergence.
854 ///
855 /// # Errors
856 ///
857 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
858 /// persistence fails.
859 pub async fn fail_run(&mut self, error: &str) -> Result<(), RuntimeError> {
860 match self.cursor.fail_run(error)? {
861 Outcome::Replayed(()) => Ok(()),
862 Outcome::Live(emitted) => {
863 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
864 }
865 }
866 }
867}
868
869/// Wraps an emitted event in an envelope (timestamp from the injected clock,
870/// at this IO edge) and appends it durably. When this returns `Ok`, the
871/// event is in the store.
872async fn persist(
873 store: &dyn EventStore,
874 run_id: RunId,
875 clock: &ClockFn,
876 emitted: &Emitted,
877) -> Result<(), RuntimeError> {
878 let envelope = EventEnvelope::new(run_id, emitted.seq, (clock)(), emitted.event.clone());
879 store.append(&envelope).await?;
880 // The event is durable now, so this is the honest moment to report it.
881 // Live progress streams from here as the run drives; replayed events take
882 // the cursor's early return above and never reach this edge, so they never
883 // re-emit. The detail is truncated (see `crate::progress`), so no full
884 // payload rides the progress stream.
885 crate::progress::emit_step(run_id, envelope.seq, &envelope.event);
886 Ok(())
887}
888
889/// Decodes a recorded completion output into the same [`ToolCallResult`] the
890/// live path produced, so replayed orchestration takes the identical branch.
891fn decode_tool_output(output: Value) -> ToolCallResult {
892 if let Some(suspension) = decode_suspension(&output) {
893 return ToolCallResult::Suspended(suspension);
894 }
895 if let Some(failure) = decode_failure(&output) {
896 return ToolCallResult::Failed(failure);
897 }
898 ToolCallResult::Output(output)
899}
900
901/// The default random source: 64 bits folded from a freshly drawn version 4
902/// UUID, which the `uuid` crate fills from operating-system randomness. Non
903/// cryptographic by design; recorded bits only ever seed idempotency keys
904/// and user-level derivations.
905pub(crate) fn os_random() -> u64 {
906 let bits = Uuid::new_v4().as_u128();
907 (bits as u64) ^ ((bits >> 64) as u64)
908}