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//! # Nothing happens twice, across runs as well as within one
52//!
53//! Replay is what keeps a resumed run from repeating itself: a recorded
54//! completion is read back, never re-executed. Two *independent* runs share no
55//! log, so replay has nothing to say about them, and something else has to
56//! hold the line. That something is the idempotency key, arbitrated by the
57//! store.
58//!
59//! The whole decision happens in [`tool_call`](RunCtx::tool_call), live,
60//! before the intent is written and before the tool runs; see that method for
61//! the mechanism and its boundaries. What matters here is the boundary it does
62//! not cross: **replay never consults the store about another run.** A
63//! recorded log is a complete description of its run, and folding it back
64//! produces the same result on a machine that has never seen the store the run
65//! was recorded against.
66//!
67//! # Retries inside one tool call
68//!
69//! One `tool_call` is one intent/completion pair, so retries of a failed
70//! live execution happen *inside* the call, between the two events, honoring
71//! `RetryPolicy`: `Read` and `Idempotent` handler failures re-execute up to
72//! [`MAX_TOOL_ATTEMPTS`] total attempts (idempotent retries reuse the same
73//! key, carried on `ToolCtx`), `Write` failures never re-execute, and input
74//! validation or output serialization failures never retry because they
75//! would fail identically again. Whatever the final result, the completion
76//! is recorded: an output, a suspension sentinel, or a failure object (see
77//! [`crate::wire`]).
78
79use std::collections::BTreeMap;
80use std::sync::Arc;
81
82use salvor_core::{
83 Budget, DedupOrigin, Effect, Emitted, Event, EventEnvelope, ModelReply, Outcome, PendingCall,
84 ReplayCursor, RunId, SequenceNumber, TokenUsage,
85};
86use salvor_llm::{Client, MessageAccumulator, MessageRequest, MessageResponse, StreamEvent};
87use salvor_store::{CallClaim, CallClaimant, CallCommitment, EventStore};
88use salvor_tools::{DynTool, RetryPolicy, Suspension, ToolCtx, ToolError, ToolOutcome};
89use serde_json::Value;
90use time::OffsetDateTime;
91use uuid::Uuid;
92
93use crate::error::RuntimeError;
94use crate::hash::hash_value;
95use crate::labels::validate_labels;
96use crate::model::{response_value, usage_of};
97use crate::wire::{
98 ToolFailure, decode_failure, decode_suspension, encode_failure, encode_suspension,
99};
100
101/// The injected clock: called once per persisted event (for the envelope
102/// timestamp) and once per live [`RunCtx::now`] observation.
103pub type ClockFn = Arc<dyn Fn() -> OffsetDateTime + Send + Sync>;
104
105/// The injected random source: called once per live [`RunCtx::random`]
106/// observation, returning 64 raw bits.
107pub type RandomFn = Arc<dyn Fn() -> u64 + Send + Sync>;
108
109/// The cap on total executions of one live tool call, counting the first
110/// attempt. Applies only where `RetryPolicy` allows retrying at all.
111pub const MAX_TOOL_ATTEMPTS: u32 = 3;
112
113/// A model call's result: the typed response plus the token usage recorded
114/// for it. Identical whether the call was executed live or replayed.
115#[derive(Debug, Clone)]
116pub struct ModelTurn {
117 /// The model's response.
118 pub response: MessageResponse,
119 /// The recorded token usage for this call.
120 pub usage: TokenUsage,
121}
122
123/// A tool call's result, decoded from the recorded completion output (the
124/// decoding is identical live and on replay, which is what keeps a resumed
125/// orchestration on the recorded path).
126#[derive(Debug, Clone)]
127pub enum ToolCallResult {
128 /// The tool produced this output.
129 Output(Value),
130 /// The tool failed after exhausting its retry policy; the full error is
131 /// recorded in the completion. See [`crate::wire`] for the shape.
132 Failed(ToolFailure),
133 /// The tool asked to park the run. Follow with [`RunCtx::suspend`] and
134 /// [`RunCtx::await_resume`].
135 Suspended(Suspension),
136}
137
138/// What [`RunCtx::await_resume`] produced.
139#[derive(Debug, Clone)]
140pub enum Resumption {
141 /// The resume input, recorded or just persisted. Continue the run.
142 Resumed(Value),
143 /// No resume input exists yet. The run is parked durably; the log
144 /// already holds everything, so the process may simply stop driving it.
145 Parked,
146}
147
148/// The public durability substrate for one run. See the module docs.
149pub struct RunCtx {
150 cursor: ReplayCursor,
151 store: Arc<dyn EventStore>,
152 run_id: RunId,
153 clock: ClockFn,
154 random: RandomFn,
155 resume_input: Option<Value>,
156 /// Whether to record the full model request body on each
157 /// `ModelCallRequested`. Off unless [`with_record_prompts`](Self::with_record_prompts)
158 /// turns it on. See that method for the PII rationale.
159 record_prompts: bool,
160 /// Correlation tags to stamp on a genuinely fresh `RunStarted`. Unset
161 /// unless [`with_labels`](Self::with_labels) sets them. See that method.
162 labels: Option<BTreeMap<String, String>>,
163}
164
165impl RunCtx {
166 /// Builds a context over a run's recorded log (empty for a fresh run),
167 /// with the default clock (the real UTC clock) and the default random
168 /// source (operating-system randomness).
169 ///
170 /// # Errors
171 ///
172 /// Returns [`RuntimeError::Replay`] when the log is not a well-formed
173 /// run history.
174 pub fn new(
175 store: Arc<dyn EventStore>,
176 run_id: RunId,
177 log: Vec<EventEnvelope>,
178 ) -> Result<Self, RuntimeError> {
179 Self::with_hooks(
180 store,
181 run_id,
182 log,
183 Arc::new(OffsetDateTime::now_utc),
184 Arc::new(os_random),
185 )
186 }
187
188 /// Builds a context with an injected clock and random source.
189 ///
190 /// The clock stamps every persisted envelope and answers live
191 /// [`now`](Self::now) observations; the random source answers live
192 /// [`random`](Self::random) observations. Injecting deterministic
193 /// functions makes complete event logs comparable across runs, which is
194 /// how the kill/resume tests prove byte-identical recovery.
195 ///
196 /// # Errors
197 ///
198 /// Returns [`RuntimeError::Replay`] when the log is not a well-formed
199 /// run history.
200 pub fn with_hooks(
201 store: Arc<dyn EventStore>,
202 run_id: RunId,
203 log: Vec<EventEnvelope>,
204 clock: ClockFn,
205 random: RandomFn,
206 ) -> Result<Self, RuntimeError> {
207 let cursor = ReplayCursor::new(log)?;
208 Ok(Self {
209 cursor,
210 store,
211 run_id,
212 clock,
213 random,
214 resume_input: None,
215 record_prompts: false,
216 labels: None,
217 })
218 }
219
220 /// Turns on recording of the full model request body into the durable log.
221 ///
222 /// Additive and off by default: the existing [`new`](Self::new) and
223 /// [`with_hooks`](Self::with_hooks) constructors leave it off, so no
224 /// caller that predates this method changes behavior. Chained builder
225 /// style keeps those signatures intact, which is why the flag arrives this
226 /// way rather than as a new constructor argument.
227 ///
228 /// When on, each live [`model_call`](Self::model_call) records the exact
229 /// request it sent on the `ModelCallRequested` event, so the v0.3 dashboard
230 /// inspector can show the prompt. This is PII-sensitive: the body can hold
231 /// user data and secrets, which is why the default is off and turning it on
232 /// is a deliberate per-agent or operator choice. The recorded body lands
233 /// only in the event log; it never reaches the progress stream or any
234 /// console output. It does not affect replay: the request hash is computed
235 /// the same either way, and replay ignores the body.
236 #[must_use]
237 pub fn with_record_prompts(mut self, record_prompts: bool) -> Self {
238 self.record_prompts = record_prompts;
239 self
240 }
241
242 /// Sets the correlation tags to stamp on a genuinely fresh `RunStarted`.
243 ///
244 /// Additive and unset by default: the existing [`new`](Self::new) and
245 /// [`with_hooks`](Self::with_hooks) constructors leave it unset, so no
246 /// caller that predates this method changes behavior. Chained builder
247 /// style, mirroring [`with_record_prompts`](Self::with_record_prompts).
248 ///
249 /// Labels are checked against the sanity bounds (see
250 /// [`crate::validate_labels`]) only on [`begin`](Self::begin)'s live path,
251 /// the moment a `RunStarted` is actually about to be created;
252 /// [`RuntimeError::InvalidLabels`] surfaces there, not here, so this
253 /// setter itself is infallible. A replayed `begin` never re-checks them:
254 /// whatever the log already holds is trusted and returned as recorded.
255 /// Labels never enter `agent_def_hash` or any request hash; they are a
256 /// tag on the run, not part of its identity.
257 #[must_use]
258 pub fn with_labels(mut self, labels: BTreeMap<String, String>) -> Self {
259 self.labels = Some(labels);
260 self
261 }
262
263 /// Provides the input a parked run is being resumed with. The next
264 /// [`await_resume`](Self::await_resume) that reaches live mode records
265 /// it as the `Resumed` event and returns it; without one, a live
266 /// `await_resume` reports [`Resumption::Parked`].
267 pub fn set_resume_input(&mut self, input: Value) {
268 self.resume_input = Some(input);
269 }
270
271 /// The resume input staged by [`set_resume_input`](Self::set_resume_input)
272 /// and not yet consumed, without consuming it.
273 ///
274 /// This is the read-only half of the accept edge. A driver that needs to
275 /// vet a resume input against something only it knows (the graph engine
276 /// checks a gate's declared `approval_schema`) has to see the value BEFORE
277 /// [`await_resume`](Self::await_resume) turns it into a `Resumed` event,
278 /// because after that it is history and refusing it would mean an appended
279 /// event the run has to live with. Peeking here and refusing leaves the log
280 /// untouched and the run parked exactly as it was.
281 ///
282 /// `None` once `await_resume` has taken the value, or when none was staged.
283 #[must_use]
284 pub fn staged_resume_input(&self) -> Option<&Value> {
285 self.resume_input.as_ref()
286 }
287
288 /// The run this context drives.
289 #[must_use]
290 pub fn run_id(&self) -> RunId {
291 self.run_id
292 }
293
294 /// Whether recorded history remains to be consumed.
295 #[must_use]
296 pub fn is_replaying(&self) -> bool {
297 self.cursor.is_replaying()
298 }
299
300 /// The log position the next consumed or emitted event occupies.
301 #[must_use]
302 pub fn next_seq(&self) -> SequenceNumber {
303 self.cursor.next_seq()
304 }
305
306 /// Starts (or replays the start of) the run.
307 ///
308 /// Live: records `RunStarted` with `input` and the labels set through
309 /// [`with_labels`](Self::with_labels) (if any), and returns `input`.
310 /// Replayed: verifies `agent_def_hash` against the recorded event and
311 /// returns the *recorded* input, which always wins; the `input` argument
312 /// is only used when the log is empty, exactly like `labels`.
313 ///
314 /// # Errors
315 ///
316 /// [`RuntimeError::Replay`] on a definition-hash mismatch or any other
317 /// divergence; [`RuntimeError::InvalidLabels`] when the labels set
318 /// through [`with_labels`](Self::with_labels) violate the sanity bounds
319 /// (only checked on the live path; see that method); [`RuntimeError::Store`]
320 /// when persistence fails.
321 pub async fn begin(
322 &mut self,
323 agent_def_hash: &str,
324 input: &Value,
325 ) -> Result<Value, RuntimeError> {
326 match self.cursor.begin(agent_def_hash, self.labels.clone())? {
327 Outcome::Replayed(recorded) => Ok(recorded),
328 Outcome::Live(permit) => {
329 if let Some(labels) = &self.labels {
330 validate_labels(labels).map_err(RuntimeError::InvalidLabels)?;
331 }
332 let emitted = permit.record(input.clone());
333 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await?;
334 Ok(input.clone())
335 }
336 }
337 }
338
339 /// Starts (or replays the start of) a graph run: the graph-document
340 /// counterpart of [`begin`](Self::begin).
341 ///
342 /// Live: records [`salvor_core::Event::GraphRunStarted`] with `input`, the
343 /// labels set through [`with_labels`](Self::with_labels) (if any), and no
344 /// fork origin, then returns `input`. Replayed: verifies `graph_hash`
345 /// against the recorded head (a changed graph document must not silently
346 /// resume an old run) and returns the *recorded* input, which always wins.
347 ///
348 /// A graph run's log opens with this event rather than `RunStarted` because
349 /// a graph coordinates many agent hashes and has none at its head. The graph
350 /// engine calls this once, then frames each node with
351 /// [`node_entered`](Self::node_entered) / [`node_exited`](Self::node_exited)
352 /// and records the single terminal itself after the last node.
353 ///
354 /// # Errors
355 ///
356 /// [`RuntimeError::Replay`] on a graph-hash mismatch or any other
357 /// divergence; [`RuntimeError::InvalidLabels`] when the labels set through
358 /// [`with_labels`](Self::with_labels) violate the sanity bounds (only
359 /// checked on the live path, exactly as [`begin`](Self::begin) does);
360 /// [`RuntimeError::Store`] when persistence fails.
361 pub async fn begin_graph(
362 &mut self,
363 graph_hash: &str,
364 input: &Value,
365 ) -> Result<Value, RuntimeError> {
366 match self
367 .cursor
368 .begin_graph(graph_hash, self.labels.clone(), None)?
369 {
370 Outcome::Replayed(recorded) => Ok(recorded),
371 Outcome::Live(permit) => {
372 if let Some(labels) = &self.labels {
373 validate_labels(labels).map_err(RuntimeError::InvalidLabels)?;
374 }
375 let emitted = permit.record(input.clone());
376 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await?;
377 Ok(input.clone())
378 }
379 }
380 }
381
382 /// Records (or replays) entry into a graph node. A graph node's own events
383 /// (an agent loop's model calls, a tool call) are recorded between this and
384 /// the matching [`node_exited`](Self::node_exited).
385 ///
386 /// # Errors
387 ///
388 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
389 /// persistence fails.
390 pub async fn node_entered(&mut self, node: &str) -> Result<(), RuntimeError> {
391 match self.cursor.node_entered(node)? {
392 Outcome::Replayed(()) => Ok(()),
393 Outcome::Live(emitted) => {
394 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
395 }
396 }
397 }
398
399 /// Records (or replays) exit from a graph node, having produced its output.
400 /// The counterpart of [`node_entered`](Self::node_entered).
401 ///
402 /// # Errors
403 ///
404 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
405 /// persistence fails.
406 pub async fn node_exited(&mut self, node: &str) -> Result<(), RuntimeError> {
407 match self.cursor.node_exited(node)? {
408 Outcome::Replayed(()) => Ok(()),
409 Outcome::Live(emitted) => {
410 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
411 }
412 }
413 }
414
415 /// Records (or replays) that a graph node was skipped: reached on the walk
416 /// but deliberately not run (a branch routed past it). Unlike an executed
417 /// node there is no [`node_entered`](Self::node_entered)/[`node_exited`](Self::node_exited)
418 /// pair; the skip is the node's sole marker, which is what lets a projection
419 /// tell "skipped" apart from "never reached". `reason` must be a pure
420 /// function of the document and recorded values so it reproduces on replay.
421 ///
422 /// # Errors
423 ///
424 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
425 /// persistence fails.
426 pub async fn node_skipped(&mut self, node: &str, reason: &str) -> Result<(), RuntimeError> {
427 match self.cursor.node_skipped(node, reason)? {
428 Outcome::Replayed(()) => Ok(()),
429 Outcome::Live(emitted) => {
430 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
431 }
432 }
433 }
434
435 /// Records (or replays) that a branch node routed: the named `case` fired.
436 /// Recorded between the branch's [`node_entered`](Self::node_entered) and
437 /// [`node_exited`](Self::node_exited), it is the sole authority for which way
438 /// the branch went. The chosen `case` must be a deterministic function of
439 /// recorded values (a pure expression over the routed value, or a decision
440 /// recomputed from a replayed model reply) so replay reproduces the route.
441 ///
442 /// # Errors
443 ///
444 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
445 /// persistence fails.
446 pub async fn branch_taken(&mut self, node: &str, case: &str) -> Result<(), RuntimeError> {
447 match self.cursor.branch_taken(node, case)? {
448 Outcome::Replayed(()) => Ok(()),
449 Outcome::Live(emitted) => {
450 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
451 }
452 }
453 }
454
455 /// Records (or replays) that a map node fanned out over a resolved item list.
456 ///
457 /// Recorded between the map node's [`node_entered`](Self::node_entered) and its
458 /// per-iteration markers. The `items` must be a deterministic function of
459 /// recorded values (the map's `over` reference resolved against the recorded
460 /// routed value), so replay reproduces the identical fan-out, which is what
461 /// makes the derived per-iteration child ids reproducible.
462 ///
463 /// # Errors
464 ///
465 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
466 /// persistence fails.
467 pub async fn map_fanned_out(&mut self, node: &str, items: &Value) -> Result<(), RuntimeError> {
468 match self.cursor.map_fanned_out(node, items)? {
469 Outcome::Replayed(()) => Ok(()),
470 Outcome::Live(emitted) => {
471 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
472 }
473 }
474 }
475
476 /// Records (or replays) that one iteration of a map fan-out started, as a child
477 /// run with the derived id `child_run`. The `child_run` is derived from the
478 /// parent run id, the node id, and the index. On replay the RECORDED id wins
479 /// and the match is on `node` + `index` alone, so a fork (which replays the
480 /// origin's prefix under a new run id and thus re-derives a different id)
481 /// still replays its inherited map markers cleanly.
482 ///
483 /// # Errors
484 ///
485 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
486 /// persistence fails.
487 pub async fn map_iteration_started(
488 &mut self,
489 node: &str,
490 index: u64,
491 child_run: &str,
492 ) -> Result<(), RuntimeError> {
493 match self.cursor.map_iteration_started(node, index, child_run)? {
494 Outcome::Replayed(()) => Ok(()),
495 Outcome::Live(emitted) => {
496 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
497 }
498 }
499 }
500
501 /// Records (or replays) that one iteration of a map fan-out joined back into
502 /// the map node's output. Joins must be recorded in index order, never
503 /// completion order, so the concurrency of the fan-out never influences the
504 /// parent log's byte sequence.
505 ///
506 /// # Errors
507 ///
508 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
509 /// persistence fails.
510 pub async fn map_iteration_joined(
511 &mut self,
512 node: &str,
513 index: u64,
514 ) -> Result<(), RuntimeError> {
515 match self.cursor.map_iteration_joined(node, index)? {
516 Outcome::Replayed(()) => Ok(()),
517 Outcome::Live(emitted) => {
518 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
519 }
520 }
521 }
522
523 /// The recorded clock: reads the injected clock once, live, and replays
524 /// the identical instant forever after.
525 ///
526 /// # Errors
527 ///
528 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
529 /// persistence fails.
530 pub async fn now(&mut self) -> Result<OffsetDateTime, RuntimeError> {
531 match self.cursor.now()? {
532 Outcome::Replayed(instant) => Ok(instant),
533 Outcome::Live(permit) => {
534 let instant = (self.clock)();
535 let emitted = permit.record(instant);
536 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await?;
537 Ok(instant)
538 }
539 }
540 }
541
542 /// The recorded random source: draws 64 bits from the injected source
543 /// once, live, and replays the identical bits forever after. Richer
544 /// random values must be derived from these bits deterministically.
545 ///
546 /// # Errors
547 ///
548 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
549 /// persistence fails.
550 pub async fn random(&mut self) -> Result<u64, RuntimeError> {
551 match self.cursor.random()? {
552 Outcome::Replayed(bits) => Ok(bits),
553 Outcome::Live(permit) => {
554 let bits = (self.random)();
555 let emitted = permit.record(bits);
556 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await?;
557 Ok(bits)
558 }
559 }
560 }
561
562 /// A recorded model call.
563 ///
564 /// The request is identified by its content hash
565 /// (`sha256:` over the canonical serialization; see [`crate::hash`]).
566 /// Replayed: the recorded response is decoded and returned; the provider
567 /// is never contacted. Live: the intent event is persisted, the provider
568 /// is called through `client`, and the completion (response plus usage)
569 /// is persisted. A recorded intent with no completion (a call the
570 /// process died inside) is re-issued safely: the fresh completion
571 /// correlates to the recorded intent.
572 ///
573 /// When [`with_record_prompts`](Self::with_record_prompts) is on, the exact
574 /// request body is recorded alongside the hash on the fresh live intent.
575 /// It is the same value the hash was computed over, it never feeds into the
576 /// hash, and replay ignores it, so recording it changes nothing about how
577 /// the run replays.
578 ///
579 /// # Errors
580 ///
581 /// [`RuntimeError::Replay`] on divergence, [`RuntimeError::Store`] when
582 /// persistence fails, [`RuntimeError::Model`] when the live provider
583 /// call fails (the log stays intact and the run is recoverable),
584 /// [`RuntimeError::RequestEncode`] / [`RuntimeError::RecordedResponseDecode`]
585 /// on the JSON edges.
586 pub async fn model_call(
587 &mut self,
588 client: &Client,
589 request: &MessageRequest,
590 ) -> Result<ModelTurn, RuntimeError> {
591 let request_value = serde_json::to_value(request).map_err(RuntimeError::RequestEncode)?;
592 let request_hash = hash_value(&request_value);
593 // The hash is computed above from `request_value` and is unaffected by
594 // what follows. When prompt recording is on, the body handed to the
595 // cursor is that same `request_value`, so the recorded body is exactly
596 // what was hashed; when off it is `None` and nothing is recorded.
597 let request_body = if self.record_prompts {
598 Some(request_value)
599 } else {
600 None
601 };
602 match self.cursor.model_call(&request_hash, request_body)? {
603 Outcome::Replayed(ModelReply { response, usage }) => {
604 let response = serde_json::from_value(response)
605 .map_err(RuntimeError::RecordedResponseDecode)?;
606 Ok(ModelTurn { response, usage })
607 }
608 Outcome::Live(permit) => {
609 if let Some(intent) = permit.intent().cloned() {
610 persist(self.store.as_ref(), self.run_id, &self.clock, &intent).await?;
611 }
612 let response = client.send_message(request).await?;
613 let usage = usage_of(&response);
614 let completion = permit.record(response_value(&response), usage);
615 persist(self.store.as_ref(), self.run_id, &self.clock, &completion).await?;
616 Ok(ModelTurn { response, usage })
617 }
618 }
619 }
620
621 /// A recorded model call that streams live events to `on_event` while it
622 /// runs, recording the identical completion [`model_call`](Self::model_call)
623 /// would record.
624 ///
625 /// This is a live-progress affordance layered on top of the durable record,
626 /// not a different kind of call. The recorded log is byte-for-byte what
627 /// [`model_call`](Self::model_call) writes for the same underlying response:
628 /// the request is hashed the same way (see [`crate::hash`]), the intent is
629 /// the same `ModelCallRequested`, and the completion carries the same
630 /// `response` value and `usage`. A run does not care which path recorded it,
631 /// and replay is deterministic either way.
632 ///
633 /// Replayed: the recorded response is decoded and returned, exactly as
634 /// [`model_call`](Self::model_call) does. The provider is never contacted and
635 /// `on_event` never fires, because there are no live tokens to report; the
636 /// caller gets the final result at once.
637 ///
638 /// Live: the intent event is persisted first (write-ahead, the same ordering
639 /// [`model_call`](Self::model_call) uses), then the provider stream is opened
640 /// through `client`. Each [`StreamEvent`] is handed to `on_event` for a live
641 /// ticker (text deltas ride [`StreamEvent::ContentBlockDelta`], token counts
642 /// ride [`StreamEvent::MessageDelta`]) and, in the same pass, applied to a
643 /// [`MessageAccumulator`]. When the stream ends, the assembled
644 /// [`MessageResponse`] is converted with the same `response_value` and usage
645 /// logic [`model_call`](Self::model_call) uses, the completion is persisted,
646 /// and the [`ModelTurn`] is returned.
647 ///
648 /// All persistence lives inside this method, so a caller cannot record a
649 /// partial or wrong completion: the completion is written only after the
650 /// stream is fully assembled. A caller that drops the returned future before
651 /// the stream completes leaves a dangling model intent (the write-ahead
652 /// intent with no completion), exactly like a live [`model_call`](Self::model_call)
653 /// the process died inside. That intent is re-issued safely on resume: the
654 /// fresh completion correlates to the recorded intent. `on_event` firing is
655 /// not part of the durable record, so a ticker that saw partial tokens before
656 /// the drop has no effect on what replay produces.
657 ///
658 /// When [`with_record_prompts`](Self::with_record_prompts) is on, the exact
659 /// request body is recorded on the fresh live intent, identically to
660 /// [`model_call`](Self::model_call).
661 ///
662 /// # Errors
663 ///
664 /// [`RuntimeError::Replay`] on divergence, [`RuntimeError::Store`] when
665 /// persistence fails, [`RuntimeError::Model`] when the live stream fails
666 /// (opening it, an error event or transport fault mid-stream, or a
667 /// tool-call fragment that does not parse) surfaced as the same error type
668 /// [`model_call`](Self::model_call) returns, with the log left intact and the
669 /// run recoverable, and [`RuntimeError::RequestEncode`] /
670 /// [`RuntimeError::RecordedResponseDecode`] on the JSON edges.
671 pub async fn model_call_streaming(
672 &mut self,
673 client: &Client,
674 request: &MessageRequest,
675 mut on_event: impl FnMut(&StreamEvent),
676 ) -> Result<ModelTurn, RuntimeError> {
677 let request_value = serde_json::to_value(request).map_err(RuntimeError::RequestEncode)?;
678 let request_hash = hash_value(&request_value);
679 // Hashing and body recording are identical to `model_call`: the hash is
680 // computed from `request_value` above, and the body handed to the cursor
681 // is that same value when recording is on, `None` when off. Streaming
682 // changes nothing here, which is half of why the recorded intent matches.
683 let request_body = if self.record_prompts {
684 Some(request_value)
685 } else {
686 None
687 };
688 match self.cursor.model_call(&request_hash, request_body)? {
689 Outcome::Replayed(ModelReply { response, usage }) => {
690 // No live call, so `on_event` never fires: replay has no tokens.
691 let response = serde_json::from_value(response)
692 .map_err(RuntimeError::RecordedResponseDecode)?;
693 Ok(ModelTurn { response, usage })
694 }
695 Outcome::Live(permit) => {
696 if let Some(intent) = permit.intent().cloned() {
697 persist(self.store.as_ref(), self.run_id, &self.clock, &intent).await?;
698 }
699 // Pump the stream once: every event feeds the ticker and the
700 // accumulator in the same pass. The accumulator assembles the
701 // exact `MessageResponse` `send_message` would have returned
702 // (salvor-llm guarantees this), so the recorded completion below
703 // is byte-identical to the non-streaming path.
704 let mut stream = client.stream_message(request).await?;
705 let mut accumulator = MessageAccumulator::new();
706 while let Some(event) = stream.next_event().await {
707 let event = event?;
708 on_event(&event);
709 accumulator.apply(&event)?;
710 }
711 let response = accumulator.into_message()?;
712 let usage = usage_of(&response);
713 let completion = permit.record(response_value(&response), usage);
714 persist(self.store.as_ref(), self.run_id, &self.clock, &completion).await?;
715 Ok(ModelTurn { response, usage })
716 }
717 }
718 }
719
720 /// A recorded tool call: one intent/completion pair, whatever happens in
721 /// between.
722 ///
723 /// Replayed: the recorded completion output is decoded (an output, a
724 /// failure object, or a suspension sentinel; see [`crate::wire`]) and
725 /// the tool is never executed. Live: the intent is persisted **before**
726 /// the tool executes (write-ahead), the tool runs with retries per its
727 /// effect's `RetryPolicy` (see [`MAX_TOOL_ATTEMPTS`]), and the
728 /// completion is persisted. A recorded `Read`/`Idempotent` intent with
729 /// no completion re-executes here under its recorded idempotency key; a
730 /// dangling `Write` intent fails with
731 /// `ReplayError::NeedsReconciliation` before anything runs.
732 ///
733 /// `idempotency_key` is the key for a *fresh* call; the built-in loop
734 /// derives it from [`random`](Self::random) for `Idempotent` tools so it
735 /// reproduces on replay. A key the tool declares for itself
736 /// ([`DynTool::idempotency_key`]) takes precedence over the one passed
737 /// here, because only the tool can say what effect a call *is*. For a
738 /// re-executed recorded intent the recorded key wins, and whatever is
739 /// presented must match it (the cursor checks).
740 ///
741 /// # Cross-run deduplication
742 ///
743 /// Within one run, nothing happens twice because a recorded completion is
744 /// replayed rather than re-executed. Across independent runs there is no
745 /// log to replay, so something else has to hold the line, and that
746 /// something is the idempotency key.
747 ///
748 /// ## Which keys count
749 ///
750 /// Only a key the tool **declares** for itself, through
751 /// [`DynTool::idempotency_key`], is an identity to deduplicate on. A key
752 /// the runtime derives on a tool's behalf is not, and the difference is not
753 /// a technicality.
754 ///
755 /// A hand-written tool makes that declaration in Rust. An MCP or wasm tool
756 /// has no code here to make it in, so its operator does, by naming the
757 /// input field that identifies a call in the agent file
758 /// (`idempotency_keys`); the tool derives the key from that field on every
759 /// call and answers through the same trait method. Nothing below this
760 /// distinguishes the two, because there is no distinction to make: both are
761 /// a statement about what the call does in the world, from someone in a
762 /// position to know.
763 ///
764 /// A declared key is a statement about the world: `"pay_claim:wreck-9931"`
765 /// means *this is the payout for claim 9931*, and two calls carrying it are
766 /// the same payment no matter which run asked for them. A derived key says
767 /// something much weaker. The built-in loop draws one from recorded
768 /// randomness so a retry within a run reuses it; the graph engine derives
769 /// one from a node's position so a fork re-executing a node reuses it.
770 /// Both are *attempt* identifiers, scoped to one run or one lineage, and
771 /// two unrelated runs can hold the same derived key over completely
772 /// different arguments. Treating one as an effect identity would let a
773 /// second run collect the first run's output for a call it never made,
774 /// which is a worse failure than the duplicate execution this is meant to
775 /// stop.
776 ///
777 /// So a derived key keeps doing exactly what it always did, at the provider
778 /// and inside its own run, and is recorded exactly as before. Cross-run
779 /// deduplication waits for a tool to say what its calls *are*.
780 ///
781 /// A declared key is also what a call records, in preference to a derived
782 /// one, since only the tool can name its own effect.
783 ///
784 /// ## The mechanism
785 ///
786 /// **The decision is made here, live, before the intent is recorded and
787 /// before the tool runs.** For a [`Effect::Write`] or
788 /// [`Effect::Idempotent`] call carrying a declared key, this method claims
789 /// the identity `(tool name, idempotency key)` in the store
790 /// ([`EventStore::claim_call`](salvor_store::EventStore::claim_call)),
791 /// which is the arbiter:
792 ///
793 /// - **Claimed.** This run is the one execution. The intent is recorded,
794 /// the tool runs, and the completion is appended *and* settles the
795 /// commitment as one atomic step, so no crash can leave a committed
796 /// completion the store still calls unfinished.
797 /// - **Held, and settled.** An equal call is already committed. The origin
798 /// run's log is read back (through
799 /// [`read_log`](salvor_store::EventStore::read_log), so its hash chain is
800 /// verified before a single byte is copied), its recorded input is
801 /// checked against this call's, and its output becomes this call's
802 /// output. The intent is still recorded, because an intent that resolves
803 /// as a duplicate is an honest thing to have recorded, and the completion
804 /// carries a [`DedupOrigin`] naming what it copied. **The tool does not
805 /// run.**
806 /// - **Held, and unfinished.** Refused with
807 /// [`RuntimeError::CallInFlight`], before anything is recorded. See that
808 /// variant for why refusing beats guessing.
809 ///
810 /// A call with no declared key is untouched by any of this: there is no
811 /// identity to deduplicate on, so a keyless write behaves exactly as it
812 /// always has, and so does a write carrying only a derived key. So does
813 /// every [`Effect::Read`], which has no effect worth naming.
814 ///
815 /// **Replay never participates.** A recorded completion replays from the
816 /// log, whether it was witnessed or copied, with no store lookup of any
817 /// kind; the [`DedupOrigin`] on it is read by humans and audits, never by
818 /// the cursor. That is what keeps a recorded log a self-contained
819 /// description of a run.
820 ///
821 /// The one place resume consults the store is the gap a crash can leave
822 /// between a deduplicated intent and its copied completion. That intent
823 /// executed nothing (this run never held the identity, so it never held the
824 /// right to execute), and the store can prove it, so the call is finished
825 /// as the duplicate it was rather than parked for a human. Every other
826 /// dangling write still parks: see [`recover_deduplicated_intent`](Self::recover_deduplicated_intent).
827 ///
828 /// # Errors
829 ///
830 /// [`RuntimeError::Replay`] on divergence or a dangling write intent;
831 /// [`RuntimeError::Store`] when persistence fails;
832 /// [`RuntimeError::CallInFlight`] when another run holds this call's
833 /// identity and has not finished with it;
834 /// [`RuntimeError::IdempotencyKeyCollision`] when one key names two
835 /// different calls; [`RuntimeError::CommitmentUnreadable`] when the store
836 /// points at a completion its own log does not hold. A failing *tool* is
837 /// not an `Err`: it returns [`ToolCallResult::Failed`], because the
838 /// failure is a recorded outcome the orchestration must handle
839 /// deterministically.
840 pub async fn tool_call(
841 &mut self,
842 tool: &dyn DynTool,
843 input: &Value,
844 idempotency_key: Option<&str>,
845 ) -> Result<ToolCallResult, RuntimeError> {
846 let effect = tool.effect();
847 let declared = tool.idempotency_key(input);
848 // What is recorded on the wire: the tool's own declaration when it
849 // makes one, otherwise the attempt key the caller derived.
850 let recorded = declared
851 .clone()
852 .or_else(|| idempotency_key.map(ToOwned::to_owned));
853 let key = recorded.as_deref();
854 // What deduplication is arbitrated on: a declared key only. See the
855 // method docs for why an attempt key is not an identity.
856 let identity = declared.as_deref().filter(|_| deduplicates(effect));
857
858 // Before the cursor is asked to take a step, because `tool_call` either
859 // advances it or fails, with nothing in between where a store lookup
860 // could go.
861 if let Some(resolved) = self
862 .recover_deduplicated_intent(tool, input, effect, identity)
863 .await?
864 {
865 return Ok(resolved);
866 }
867
868 match self.cursor.tool_call(tool.name(), input, effect, key)? {
869 Outcome::Replayed(output) => Ok(decode_tool_output(output)),
870 Outcome::Live(permit) => {
871 // THE DECISION POINT. Live, before the write-ahead intent is
872 // persisted and before the tool is touched. Nothing below this
873 // block consults the store about other runs, and replay never
874 // reaches it at all.
875 let claimant = identity.map(|key| CallClaimant {
876 tool: tool.name(),
877 idempotency_key: key,
878 run_id: self.run_id,
879 intent_seq: permit.seq(),
880 });
881 let mut copied = None;
882 if let Some(claimant) = claimant {
883 match self.store.claim_call(claimant).await? {
884 // This run is the one execution.
885 CallClaim::Claimed => {}
886 CallClaim::Held(commitment) if commitment.completion_seq.is_some() => {
887 copied = Some(
888 committed_call(
889 self.store.as_ref(),
890 tool.name(),
891 claimant.idempotency_key,
892 commitment,
893 input,
894 )
895 .await?,
896 );
897 }
898 // Held by a run that has not finished. Nothing is
899 // recorded, so this run can simply be run again once
900 // the holder is resolved.
901 CallClaim::Held(commitment) => {
902 return Err(RuntimeError::CallInFlight {
903 tool: tool.name().to_owned(),
904 idempotency_key: claimant.idempotency_key.to_owned(),
905 holder: commitment.run_id,
906 holder_seq: commitment.intent_seq.get(),
907 });
908 }
909 }
910 }
911
912 // Write-ahead, on both paths. An intent that resolves as a
913 // duplicate is still an honest record of what this run asked
914 // for.
915 if let Some(intent) = permit.intent().cloned() {
916 persist(self.store.as_ref(), self.run_id, &self.clock, &intent).await?;
917 }
918
919 if let Some((output, origin)) = copied {
920 let completion = permit.record_deduplicated(output.clone(), origin);
921 persist(self.store.as_ref(), self.run_id, &self.clock, &completion).await?;
922 return Ok(decode_tool_output(output));
923 }
924
925 let key = permit.idempotency_key().map(ToOwned::to_owned);
926 let tool_ctx = ToolCtx::new(key);
927 let policy = RetryPolicy::for_effect(effect);
928 let mut attempts: u32 = 0;
929 let outcome = loop {
930 attempts += 1;
931 match tool.call_json(&tool_ctx, input.clone()).await {
932 Ok(outcome) => break Ok(outcome),
933 Err(error) => {
934 // Only a handler failure is retryable, and only
935 // when the effect's policy allows a re-attempt.
936 let may_retry = matches!(error, ToolError::Handler { .. })
937 && policy.allows_retry()
938 && attempts < MAX_TOOL_ATTEMPTS;
939 if may_retry {
940 continue;
941 }
942 break Err(error);
943 }
944 }
945 };
946 let (output, result) = match outcome {
947 Ok(ToolOutcome::Output(value)) => {
948 (value.clone(), ToolCallResult::Output(value))
949 }
950 Ok(ToolOutcome::Suspend(suspension)) => (
951 encode_suspension(&suspension),
952 ToolCallResult::Suspended(suspension),
953 ),
954 Err(error) => {
955 let failure = ToolFailure::from_error(&error, attempts);
956 (encode_failure(&failure), ToolCallResult::Failed(failure))
957 }
958 };
959 let completion = permit.record(output);
960 match claimant {
961 // The completion and the settlement land together, so the
962 // store never believes an identity is still in flight when
963 // its result is already recorded.
964 Some(claimant) => {
965 persist_settling(
966 self.store.as_ref(),
967 self.run_id,
968 &self.clock,
969 &completion,
970 claimant,
971 )
972 .await?;
973 }
974 None => {
975 persist(self.store.as_ref(), self.run_id, &self.clock, &completion).await?;
976 }
977 }
978 Ok(result)
979 }
980 }
981 }
982
983 /// Finishes a deduplicated call whose process died between recording the
984 /// intent and recording the copied completion, or reports that this is not
985 /// that situation.
986 ///
987 /// This is the only place a resume consults the store about another run,
988 /// and it turns on a fact the store can settle: a call executes only under
989 /// a claim, so an identity held by a **different** run is proof that this
990 /// run never executed. The intent it left behind is then not a dangling
991 /// write at all, it is an unfinished copy, and parking it would ask a human
992 /// to reconcile an effect that provably never happened.
993 ///
994 /// Every other reading falls through to [`tool_call`](Self::tool_call)'s
995 /// normal path and its normal consequences. In particular an identity held
996 /// by *this* run is exactly the reconciliation hazard it has always been:
997 /// this run did hold the right to execute, so nobody can say from the
998 /// outside whether it did, and the run parks with
999 /// [`ReplayError::NeedsReconciliation`](salvor_core::ReplayError::NeedsReconciliation).
1000 ///
1001 /// Returns `Ok(None)` when the situation does not apply, which is the
1002 /// overwhelmingly common case.
1003 async fn recover_deduplicated_intent(
1004 &mut self,
1005 tool: &dyn DynTool,
1006 input: &Value,
1007 effect: Effect,
1008 key: Option<&str>,
1009 ) -> Result<Option<ToolCallResult>, RuntimeError> {
1010 let Some(key) = key.filter(|_| deduplicates(effect)) else {
1011 return Ok(None);
1012 };
1013 let Some(PendingCall::Tool {
1014 tool: recorded_tool,
1015 input: recorded_input,
1016 effect: recorded_effect,
1017 idempotency_key: Some(recorded_key),
1018 ..
1019 }) = self.cursor.dangling_intent()
1020 else {
1021 return Ok(None);
1022 };
1023 // Only the call the orchestration is asking for right now. Anything
1024 // else is a divergence for the cursor to report, not ours to smooth
1025 // over.
1026 if recorded_tool != tool.name()
1027 || recorded_input != *input
1028 || recorded_effect != effect
1029 || recorded_key != key
1030 {
1031 return Ok(None);
1032 }
1033
1034 let Some(commitment) = self.store.lookup_call(tool.name(), key).await? else {
1035 return Ok(None);
1036 };
1037 // The proof, and the whole reason this is safe: the identity belongs to
1038 // some other run, and that run finished. This run never held it, so it
1039 // never had the right to execute, so it did not.
1040 if commitment.run_id == self.run_id || commitment.completion_seq.is_none() {
1041 return Ok(None);
1042 }
1043
1044 let (output, origin) =
1045 committed_call(self.store.as_ref(), tool.name(), key, commitment, input).await?;
1046 let permit = self
1047 .cursor
1048 .resume_unexecuted_tool_call(tool.name(), input, effect, key)?;
1049 let completion = permit.record_deduplicated(output.clone(), origin);
1050 persist(self.store.as_ref(), self.run_id, &self.clock, &completion).await?;
1051 Ok(Some(decode_tool_output(output)))
1052 }
1053
1054 /// Parks the run: records `Suspended { reason, input_schema }`. Follow
1055 /// with [`await_resume`](Self::await_resume).
1056 ///
1057 /// # Errors
1058 ///
1059 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
1060 /// persistence fails.
1061 pub async fn suspend(
1062 &mut self,
1063 reason: &str,
1064 input_schema: &Value,
1065 ) -> Result<(), RuntimeError> {
1066 match self.cursor.suspend(reason, input_schema)? {
1067 Outcome::Replayed(()) => Ok(()),
1068 Outcome::Live(emitted) => {
1069 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
1070 }
1071 }
1072 }
1073
1074 /// Obtains the input a parked run was resumed with.
1075 ///
1076 /// Replayed: the recorded `Resumed` input. Live: when a resume input was
1077 /// provided through [`set_resume_input`](Self::set_resume_input), it is
1078 /// recorded and returned; otherwise the run stays parked and
1079 /// [`Resumption::Parked`] tells the caller to stop driving.
1080 ///
1081 /// # Errors
1082 ///
1083 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
1084 /// persistence fails.
1085 pub async fn await_resume(&mut self) -> Result<Resumption, RuntimeError> {
1086 match self.cursor.await_resume()? {
1087 Outcome::Replayed(input) => Ok(Resumption::Resumed(input)),
1088 Outcome::Live(parked) => match self.resume_input.take() {
1089 Some(input) => {
1090 let emitted = parked.resume(input.clone());
1091 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await?;
1092 Ok(Resumption::Resumed(input))
1093 }
1094 None => Ok(Resumption::Parked),
1095 },
1096 }
1097 }
1098
1099 /// Records a budget crossing. The check that led here must be computed
1100 /// from replayed data (recorded usage, recorded `now` observations) so
1101 /// it re-fires identically on replay. Follow with
1102 /// [`await_resume`](Self::await_resume), exactly like a suspension.
1103 ///
1104 /// # Errors
1105 ///
1106 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
1107 /// persistence fails.
1108 pub async fn budget_exceeded(
1109 &mut self,
1110 budget: Budget,
1111 observed: f64,
1112 ) -> Result<(), RuntimeError> {
1113 match self.cursor.budget_exceeded(budget, observed)? {
1114 Outcome::Replayed(()) => Ok(()),
1115 Outcome::Live(emitted) => {
1116 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
1117 }
1118 }
1119 }
1120
1121 /// Completes the run with `output`. Every request after this is a
1122 /// divergence.
1123 ///
1124 /// # Errors
1125 ///
1126 /// [`RuntimeError::Replay`] on divergence (including an output that does
1127 /// not match the recorded one); [`RuntimeError::Store`] when persistence
1128 /// fails.
1129 pub async fn complete_run(&mut self, output: &Value) -> Result<(), RuntimeError> {
1130 match self.cursor.complete_run(output)? {
1131 Outcome::Replayed(()) => Ok(()),
1132 Outcome::Live(emitted) => {
1133 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
1134 }
1135 }
1136 }
1137
1138 /// Fails the run with `error`. Every request after this is a divergence.
1139 ///
1140 /// # Errors
1141 ///
1142 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
1143 /// persistence fails.
1144 pub async fn fail_run(&mut self, error: &str) -> Result<(), RuntimeError> {
1145 match self.cursor.fail_run(error)? {
1146 Outcome::Replayed(()) => Ok(()),
1147 Outcome::Live(emitted) => {
1148 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
1149 }
1150 }
1151 }
1152}
1153
1154/// Wraps an emitted event in an envelope (timestamp from the injected clock,
1155/// at this IO edge) and appends it durably. When this returns `Ok`, the
1156/// event is in the store.
1157async fn persist(
1158 store: &dyn EventStore,
1159 run_id: RunId,
1160 clock: &ClockFn,
1161 emitted: &Emitted,
1162) -> Result<(), RuntimeError> {
1163 let envelope = EventEnvelope::new(run_id, emitted.seq, (clock)(), emitted.event.clone());
1164 store.append(&envelope).await?;
1165 // The event is durable now, so this is the honest moment to report it.
1166 // Live progress streams from here as the run drives; replayed events take
1167 // the cursor's early return above and never reach this edge, so they never
1168 // re-emit. The detail is truncated (see `crate::progress`), so no full
1169 // payload rides the progress stream.
1170 crate::progress::emit_step(run_id, envelope.seq, &envelope.event);
1171 Ok(())
1172}
1173
1174/// Whether an effect class participates in cross-run deduplication at all.
1175///
1176/// A [`Effect::Read`] performs nothing worth naming, so there is nothing for a
1177/// second run to avoid repeating. The other two classes both do something to
1178/// the world, and a key on them is a claim about which something.
1179fn deduplicates(effect: Effect) -> bool {
1180 matches!(effect, Effect::Write | Effect::Idempotent)
1181}
1182
1183/// Reads back the call a commitment points at, and checks it really is the same
1184/// call.
1185///
1186/// The read goes through [`EventStore::read_log`], never around it, so the
1187/// origin run's hash chain is verified before any of its recorded bytes are
1188/// copied into this run's log. A commitment is a pointer and nothing else,
1189/// which is what makes that unavoidable rather than merely encouraged.
1190///
1191/// The input check is what keeps a key honest. If the origin's recorded input
1192/// differs from this call's, one key is naming two different calls, and both
1193/// available answers are wrong: copying would return an output computed from
1194/// somebody else's arguments, executing would repeat an effect the key says has
1195/// already happened. So neither happens.
1196async fn committed_call(
1197 store: &dyn EventStore,
1198 tool: &str,
1199 idempotency_key: &str,
1200 commitment: CallCommitment,
1201 input: &Value,
1202) -> Result<(Value, DedupOrigin), RuntimeError> {
1203 let log = store.read_log(commitment.run_id).await?;
1204 let correlation = commitment.intent_seq;
1205 let unreadable = || RuntimeError::CommitmentUnreadable {
1206 tool: tool.to_owned(),
1207 idempotency_key: idempotency_key.to_owned(),
1208 origin: commitment.run_id,
1209 origin_seq: correlation.get(),
1210 };
1211
1212 let recorded_input = log
1213 .iter()
1214 .find_map(|envelope| match &envelope.event {
1215 Event::ToolCallRequested {
1216 seq,
1217 tool: recorded_tool,
1218 input,
1219 ..
1220 } if *seq == correlation && recorded_tool == tool => Some(input),
1221 _ => None,
1222 })
1223 .ok_or_else(unreadable)?;
1224 if recorded_input != input {
1225 return Err(RuntimeError::IdempotencyKeyCollision {
1226 tool: tool.to_owned(),
1227 idempotency_key: idempotency_key.to_owned(),
1228 origin: commitment.run_id,
1229 origin_seq: correlation.get(),
1230 });
1231 }
1232
1233 let output = log
1234 .iter()
1235 .find_map(|envelope| match &envelope.event {
1236 Event::ToolCallCompleted { seq, output, .. } if *seq == correlation => Some(output),
1237 _ => None,
1238 })
1239 .ok_or_else(unreadable)?
1240 .clone();
1241
1242 Ok((
1243 output,
1244 DedupOrigin {
1245 run_id: commitment.run_id,
1246 seq: correlation,
1247 },
1248 ))
1249}
1250
1251/// Persists a completion and settles its call commitment in one indivisible
1252/// store operation. The settling counterpart of [`persist`].
1253async fn persist_settling(
1254 store: &dyn EventStore,
1255 run_id: RunId,
1256 clock: &ClockFn,
1257 emitted: &Emitted,
1258 claimant: CallClaimant<'_>,
1259) -> Result<(), RuntimeError> {
1260 let envelope = EventEnvelope::new(run_id, emitted.seq, (clock)(), emitted.event.clone());
1261 store.append_settling_call(&envelope, claimant).await?;
1262 crate::progress::emit_step(run_id, envelope.seq, &envelope.event);
1263 Ok(())
1264}
1265
1266/// Decodes a recorded completion output into the same [`ToolCallResult`] the
1267/// live path produced, so replayed orchestration takes the identical branch.
1268fn decode_tool_output(output: Value) -> ToolCallResult {
1269 if let Some(suspension) = decode_suspension(&output) {
1270 return ToolCallResult::Suspended(suspension);
1271 }
1272 if let Some(failure) = decode_failure(&output) {
1273 return ToolCallResult::Failed(failure);
1274 }
1275 ToolCallResult::Output(output)
1276}
1277
1278/// The default random source: 64 bits folded from a freshly drawn version 4
1279/// UUID, which the `uuid` crate fills from operating-system randomness. Non
1280/// cryptographic by design; recorded bits only ever seed idempotency keys
1281/// and user-level derivations.
1282pub(crate) fn os_random() -> u64 {
1283 let bits = Uuid::new_v4().as_u128();
1284 (bits as u64) ^ ((bits >> 64) as u64)
1285}