Skip to main content

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}