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}