Skip to main content

salvor_runtime/
wire.rs

1//! The recorded wire shapes this crate layers on top of the core event
2//! vocabulary: the suspension and sleep sentinels, the structured tool-error
3//! object, and the deterministic rendering of JSON values into model-visible
4//! text.
5//!
6//! # Why sentinels exist
7//!
8//! The replay cursor requires every tool intent to be followed by
9//! exactly one `ToolCallCompleted`. Three tool outcomes do not produce a plain
10//! output value, and all three are encoded *inside* the completion's `output`
11//! field as a reserved-key JSON object:
12//!
13//! - **Suspension.** A live tool that returns `ToolOutcome::Suspend` still
14//!   gets a completion; its output is the sentinel
15//!
16//!   ```json
17//!   {"__salvor_suspend": {"reason": "<reason>", "input_schema": { ... }}}
18//!   ```
19//!
20//!   The runtime then records `Suspended` and parks. On replay the same
21//!   completion replays through the cursor, decodes back into a suspension,
22//!   and the orchestration takes the identical path.
23//!
24//!   A tool that parks on a webhook rather than a person adds one key,
25//!   `"kind": "signal"`, which the runtime carries onto the `Suspended` event.
26//!   It is written only when the tool named it, so a gate's completion is the
27//!   two-key object above and nothing else, and it must round-trip: the
28//!   replayed suspension has to ask the cursor for the same discriminator the
29//!   log recorded, or the cursor reports a divergence.
30//!
31//! - **Sleep.** A live tool that returns `ToolOutcome::Sleep` gets a
32//!   completion too; its output is
33//!
34//!   ```json
35//!   {"__salvor_sleep": {"wake_at": "2026-08-14T09:00:00Z"}}
36//!   ```
37//!
38//!   `wake_at` is RFC 3339 normalized to UTC, the same reading rule every
39//!   recorded instant in a log follows. The runtime then records
40//!   `SleepStarted` and parks.
41//!
42//!   **Why this is a sentinel and not an event.** The completion settles the
43//!   tool call: it closes the intent, and for a keyed call it settles the
44//!   store's claim in the same atomic append. Only then is `SleepStarted`
45//!   recorded. So the recorded order is intent, completion, `SleepStarted`,
46//!   and a run that sleeps for a week holds no claim while it sleeps and
47//!   leaves no dangling write intent if the process dies. A sleep event
48//!   emitted *instead of* a completion would invert that: the claim would be
49//!   held for the length of the timer and every other run under that
50//!   idempotency key would get `CallInFlight` until it expired.
51//!
52//! - **Failure.** A tool call that exhausted its retries (or was never
53//!   retryable) also gets a completion; its output is
54//!
55//!   ```json
56//!   {"__salvor_error": {"is_error": true, "kind": "handler",
57//!                      "message": "<full error chain>", "attempts": 2}}
58//!   ```
59//!
60//!   `kind` is one of `"invalid_input"`, `"handler"`, or
61//!   `"output_serialization"`: the layer that failed, not the `ToolError`
62//!   variant, so a new variant that fails at a layer already named records
63//!   under that layer's string rather than widening the format. `message` is
64//!   the **full** error chain (sources joined with `": "`), and `attempts`
65//!   counts executions including retries. The full error always lives here,
66//!   in the log; what reaches the model is compacted separately (see
67//!   [`crate::compact`]).
68//!
69//! The three keys are reserved: a tool's own output must not be a one-key
70//! object using any of these names at the top level, or replay would misread
71//! it. Decoding only triggers on a JSON object with exactly one key equal to
72//! the sentinel, which keeps ordinary outputs (including objects that merely
73//! *contain* these names deeper down) unaffected.
74
75use salvor_tools::{Sleep, Suspension};
76use serde_json::{Value, json};
77use time::OffsetDateTime;
78use time::format_description::well_known::Rfc3339;
79
80use crate::hash::canonical_json;
81
82/// The reserved key marking a completion output as a recorded suspension.
83pub const SUSPEND_SENTINEL_KEY: &str = "__salvor_suspend";
84
85/// The reserved key marking a completion output as a recorded sleep request.
86pub const SLEEP_SENTINEL_KEY: &str = "__salvor_sleep";
87
88/// The reserved key marking a completion output as a recorded tool failure.
89pub const ERROR_SENTINEL_KEY: &str = "__salvor_error";
90
91/// Which layer of the tool dispatch produced a recorded failure.
92///
93/// A layer, not a `ToolError` variant. More than one variant can fail at the
94/// same layer, and where that happens they share the wire string: the strings
95/// are a stable format replay parses, and a reader wanting the particulars
96/// reads the message, which carries them in full.
97#[derive(Clone, Copy, Debug, PartialEq, Eq)]
98pub enum ToolFailureKind {
99    /// The model's arguments did not match the tool's input schema; the tool
100    /// never ran.
101    InvalidInput,
102    /// The tool ran and its handler failed.
103    Handler,
104    /// The tool ran but what came back could not be turned into a usable
105    /// result: an output that would not serialize, or a park request the
106    /// client could not read.
107    OutputSerialization,
108}
109
110impl ToolFailureKind {
111    /// The wire string recorded in the failure object's `kind` field.
112    #[must_use]
113    pub fn as_str(self) -> &'static str {
114        match self {
115            Self::InvalidInput => "invalid_input",
116            Self::Handler => "handler",
117            Self::OutputSerialization => "output_serialization",
118        }
119    }
120
121    /// Parses a recorded `kind` string back into the enum.
122    #[must_use]
123    pub fn from_wire(kind: &str) -> Option<Self> {
124        match kind {
125            "invalid_input" => Some(Self::InvalidInput),
126            "handler" => Some(Self::Handler),
127            "output_serialization" => Some(Self::OutputSerialization),
128            _ => None,
129        }
130    }
131}
132
133/// A tool failure as recorded in (and decoded from) a completion output.
134#[derive(Clone, Debug, PartialEq, Eq)]
135pub struct ToolFailure {
136    /// Which dispatch layer failed.
137    pub kind: ToolFailureKind,
138    /// The full error chain: the top error's message and every source,
139    /// joined with `": "`. Never truncated here; compaction happens only on
140    /// the way into model context.
141    pub message: String,
142    /// How many times the call executed, counting retries.
143    pub attempts: u32,
144}
145
146impl ToolFailure {
147    /// Builds a failure record from a dispatch error, capturing the full
148    /// source chain into [`message`](Self::message).
149    #[must_use]
150    pub fn from_error(error: &salvor_tools::ToolError, attempts: u32) -> Self {
151        let kind = match error {
152            salvor_tools::ToolError::InvalidInput { .. } => ToolFailureKind::InvalidInput,
153            // A call refused for want of its declared idempotency key never
154            // reached the tool, and the fault is in the arguments, which is
155            // exactly what `invalid_input` records. It shares that wire kind
156            // rather than minting a new one: the recorded `kind` strings are a
157            // stable format that replay parses, and this failure needs no new
158            // handling, only its own message.
159            salvor_tools::ToolError::MissingIdempotencyKey { .. } => ToolFailureKind::InvalidInput,
160            salvor_tools::ToolError::Handler { .. } => ToolFailureKind::Handler,
161            // A result the dispatch layer could not read failed on the same
162            // side of the call as an output that would not serialize: the tool
163            // ran, and what it handed back is unusable. It shares that wire
164            // kind for the reason the line above shares `invalid_input`, and
165            // its own message says which of the two happened.
166            salvor_tools::ToolError::MalformedResult { .. } => ToolFailureKind::OutputSerialization,
167            salvor_tools::ToolError::OutputSerialization { .. } => {
168                ToolFailureKind::OutputSerialization
169            }
170        };
171        Self {
172            kind,
173            message: error_chain(error),
174            attempts,
175        }
176    }
177}
178
179/// Joins an error's `Display` with every `source()` below it, separated by
180/// `": "`, so the recorded message keeps the information `thiserror` spreads
181/// across the chain.
182#[must_use]
183pub fn error_chain(error: &dyn std::error::Error) -> String {
184    let mut message = error.to_string();
185    let mut source = error.source();
186    while let Some(inner) = source {
187        message.push_str(": ");
188        message.push_str(&inner.to_string());
189        source = inner.source();
190    }
191    message
192}
193
194/// Encodes a suspension as the sentinel completion output.
195///
196/// `kind` is written only when the tool named one. The absent discriminator is
197/// the human gate, and omitting the key is what keeps a gate's recorded
198/// completion byte-identical to the one this function produced before
199/// suspensions could name what they wait on.
200#[must_use]
201pub fn encode_suspension(suspension: &Suspension) -> Value {
202    let mut body = json!({
203        "reason": suspension.reason,
204        "input_schema": suspension.input_schema,
205    });
206    if let Some(kind) = suspension.kind {
207        body.as_object_mut()
208            .expect("the sentinel body is an object")
209            .insert("kind".to_owned(), json!(kind));
210    }
211    json!({ SUSPEND_SENTINEL_KEY: body })
212}
213
214/// Encodes a sleep request as the sentinel completion output.
215///
216/// The instant is normalized to UTC before it is formatted, so a tool that
217/// returns `wake_at` in some other offset records the same instant every other
218/// recorded instant in the log is written in, and the value decoded back is
219/// the value the run sleeps on. It also removes the one way RFC 3339
220/// formatting can fail on a representable instant (an offset carrying
221/// seconds), which is what lets this return a `Value` rather than a `Result`.
222#[must_use]
223pub fn encode_sleep(sleep: &Sleep) -> Value {
224    json!({ SLEEP_SENTINEL_KEY: { "wake_at": rfc3339(sleep.wake_at) } })
225}
226
227/// Encodes a tool failure as the sentinel completion output.
228#[must_use]
229pub fn encode_failure(failure: &ToolFailure) -> Value {
230    json!({
231        ERROR_SENTINEL_KEY: {
232            "is_error": true,
233            "kind": failure.kind.as_str(),
234            "message": failure.message,
235            "attempts": failure.attempts,
236        }
237    })
238}
239
240/// Decodes a completion output that is the suspension sentinel, if it is one.
241///
242/// The discriminator round-trips because replay depends on it. A later drive
243/// reads the tool's outcome back out of this recorded completion and asks the
244/// cursor to suspend again; a decode that dropped `kind` would ask for a gate
245/// where the log holds a signal, and the cursor calls that a divergence.
246/// A body with no `kind` decodes to `None`, the gate every completion written
247/// before this key meant.
248#[must_use]
249pub fn decode_suspension(output: &Value) -> Option<Suspension> {
250    let body = sentinel_body(output, SUSPEND_SENTINEL_KEY)?;
251    let kind = match body.get("kind") {
252        None | Some(Value::Null) => None,
253        Some(kind) => Some(serde_json::from_value(kind.clone()).ok()?),
254    };
255    Some(Suspension {
256        reason: body.get("reason")?.as_str()?.to_owned(),
257        input_schema: body.get("input_schema")?.clone(),
258        kind,
259    })
260}
261
262/// Decodes a completion output that is the sleep sentinel, if it is one.
263#[must_use]
264pub fn decode_sleep(output: &Value) -> Option<Sleep> {
265    let body = sentinel_body(output, SLEEP_SENTINEL_KEY)?;
266    let wake_at = OffsetDateTime::parse(body.get("wake_at")?.as_str()?, &Rfc3339).ok()?;
267    Some(Sleep { wake_at })
268}
269
270/// What a woken tool call reports as its result: the tool asked to park until
271/// an instant, the run parked, and the instant came.
272///
273/// A slept call has no output of its own to hand back (the tool returned a
274/// deadline, not a value), and there is no resume input to stand in for one
275/// either, so the result is derived: a fixed key over the recorded wake
276/// instant. Both drivers use this one function, so a `tool` node's recorded
277/// output and the `tool_result` the built-in loop feeds the model say the same
278/// thing, and both are pure functions of the log.
279#[must_use]
280pub fn slept_output(wake_at: OffsetDateTime) -> Value {
281    json!({ "slept_until": rfc3339(wake_at) })
282}
283
284/// Formats an instant as RFC 3339 in UTC, the encoding every recorded instant
285/// in a log uses.
286///
287/// Infallible in practice, and deliberately: normalizing to UTC rules out an
288/// offset with seconds, and without the `time` crate's `large-dates` feature
289/// an `OffsetDateTime` cannot hold a year outside 0000..=9999. Those are the
290/// only two ways RFC 3339 formatting fails.
291fn rfc3339(instant: OffsetDateTime) -> String {
292    instant
293        .to_offset(time::UtcOffset::UTC)
294        .format(&Rfc3339)
295        .expect("an instant a run can hold formats as RFC 3339 in UTC")
296}
297
298/// Decodes a completion output that is the failure sentinel, if it is one.
299#[must_use]
300pub fn decode_failure(output: &Value) -> Option<ToolFailure> {
301    let body = sentinel_body(output, ERROR_SENTINEL_KEY)?;
302    Some(ToolFailure {
303        kind: ToolFailureKind::from_wire(body.get("kind")?.as_str()?)?,
304        message: body.get("message")?.as_str()?.to_owned(),
305        attempts: u32::try_from(body.get("attempts")?.as_u64()?).ok()?,
306    })
307}
308
309/// The sentinel's body when `output` is an object with exactly one key equal
310/// to `key`; `None` for every other value.
311fn sentinel_body<'v>(output: &'v Value, key: &str) -> Option<&'v Value> {
312    let map = output.as_object()?;
313    if map.len() != 1 {
314        return None;
315    }
316    map.get(key)
317}
318
319/// Renders a JSON value as the text handed to the model (an initial input or
320/// a `tool_result` content string).
321///
322/// A JSON string renders as its bare text; anything else renders as
323/// canonical JSON. The canonical form matters: this text flows into the next
324/// model request, the request is hashed, and the hash must reproduce on
325/// replay, so the rendering cannot depend on map iteration order.
326#[must_use]
327pub fn content_string(value: &Value) -> String {
328    match value {
329        Value::String(text) => text.clone(),
330        other => canonical_json(other),
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    /// Every sentinel survives an encode/decode round trip.
339    #[test]
340    fn sentinels_round_trip() {
341        let suspension = Suspension::new(
342            "needs approval",
343            json!({"type": "object", "required": ["approved"]}),
344        );
345        assert_eq!(
346            decode_suspension(&encode_suspension(&suspension)),
347            Some(suspension.clone())
348        );
349
350        // The signal discriminator survives the same round trip, and the gate
351        // above proves the key is absent when there is nothing to say.
352        let signal = suspension.on_signal();
353        let encoded = encode_suspension(&signal);
354        assert_eq!(encoded[SUSPEND_SENTINEL_KEY]["kind"], json!("signal"));
355        assert_eq!(decode_suspension(&encoded), Some(signal));
356
357        let sleep = Sleep::until(time::macros::datetime!(2026-08-14 09:00:00 UTC));
358        assert_eq!(decode_sleep(&encode_sleep(&sleep)), Some(sleep));
359
360        let failure = ToolFailure {
361            kind: ToolFailureKind::Handler,
362            message: "tool `x` failed: connection reset".to_owned(),
363            attempts: 3,
364        };
365        assert_eq!(decode_failure(&encode_failure(&failure)), Some(failure));
366    }
367
368    /// Ordinary outputs never decode as sentinels, even when they contain
369    /// the reserved names below the top level or alongside other keys.
370    #[test]
371    fn ordinary_outputs_are_not_sentinels() {
372        assert_eq!(decode_suspension(&json!({"result": 1})), None);
373        assert_eq!(
374            decode_suspension(&json!({"__salvor_suspend": {}, "other": 1})),
375            None
376        );
377        assert_eq!(
378            decode_failure(&json!({"nested": {"__salvor_error": {}}})),
379            None
380        );
381        assert_eq!(decode_failure(&json!("__salvor_error")), None);
382        assert_eq!(
383            decode_sleep(&json!({"wake_at": "2026-08-14T09:00:00Z"})),
384            None
385        );
386        assert_eq!(
387            decode_sleep(&json!({SLEEP_SENTINEL_KEY: {"wake_at": "tomorrow"}})),
388            None
389        );
390    }
391
392    /// A wake instant recorded in another offset comes back as the same
393    /// instant in UTC, so the deadline the run sleeps on is the deadline the
394    /// tool asked for however the tool spelled it.
395    #[test]
396    fn a_sleep_instant_records_in_utc() {
397        let sleep = Sleep::until(time::macros::datetime!(2026-08-14 11:00:00 +02:00));
398        assert_eq!(
399            encode_sleep(&sleep),
400            json!({SLEEP_SENTINEL_KEY: {"wake_at": "2026-08-14T09:00:00Z"}})
401        );
402        assert_eq!(
403            decode_sleep(&encode_sleep(&sleep)).map(|decoded| decoded.wake_at),
404            Some(time::macros::datetime!(2026-08-14 09:00:00 UTC))
405        );
406    }
407
408    /// String values render bare; structured values render canonically.
409    #[test]
410    fn content_string_renders_deterministically() {
411        assert_eq!(content_string(&json!("plain")), "plain");
412        let a: Value = serde_json::from_str(r#"{"b": 1, "a": 2}"#).unwrap();
413        assert_eq!(content_string(&a), r#"{"a":2,"b":1}"#);
414    }
415}