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 sentinel, the structured tool-error object,
3//! and the deterministic rendering of JSON values into model-visible text.
4//!
5//! # Why sentinels exist
6//!
7//! The replay cursor requires every tool intent to be followed by
8//! exactly one `ToolCallCompleted`. Two tool outcomes do not produce a plain
9//! output value, and both are encoded *inside* the completion's `output`
10//! field as a reserved-key JSON object:
11//!
12//! - **Suspension.** A live tool that returns `ToolOutcome::Suspend` still
13//!   gets a completion; its output is the sentinel
14//!
15//!   ```json
16//!   {"__salvor_suspend": {"reason": "<reason>", "input_schema": { ... }}}
17//!   ```
18//!
19//!   The runtime then records `Suspended` and parks. On replay the same
20//!   completion replays through the cursor, decodes back into a suspension,
21//!   and the orchestration takes the identical path.
22//!
23//! - **Failure.** A tool call that exhausted its retries (or was never
24//!   retryable) also gets a completion; its output is
25//!
26//!   ```json
27//!   {"__salvor_error": {"is_error": true, "kind": "handler",
28//!                      "message": "<full error chain>", "attempts": 2}}
29//!   ```
30//!
31//!   `kind` is one of `"invalid_input"`, `"handler"`, or
32//!   `"output_serialization"` (the three `ToolError` variants), `message` is
33//!   the **full** error chain (sources joined with `": "`), and `attempts`
34//!   counts executions including retries. The full error always lives here,
35//!   in the log; what reaches the model is compacted separately (see
36//!   [`crate::compact`]).
37//!
38//! The two keys are reserved: a tool's own output must not be a one-key
39//! object using either name at the top level, or replay would misread it.
40//! Decoding only triggers on a JSON object with exactly one key equal to the
41//! sentinel, which keeps ordinary outputs (including objects that merely
42//! *contain* these names deeper down) unaffected.
43
44use salvor_tools::Suspension;
45use serde_json::{Value, json};
46
47use crate::hash::canonical_json;
48
49/// The reserved key marking a completion output as a recorded suspension.
50pub const SUSPEND_SENTINEL_KEY: &str = "__salvor_suspend";
51
52/// The reserved key marking a completion output as a recorded tool failure.
53pub const ERROR_SENTINEL_KEY: &str = "__salvor_error";
54
55/// Which layer of the tool dispatch produced a recorded failure. Mirrors the
56/// three `salvor_tools::ToolError` variants.
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub enum ToolFailureKind {
59    /// The model's arguments did not match the tool's input schema; the tool
60    /// never ran.
61    InvalidInput,
62    /// The tool ran and its handler failed.
63    Handler,
64    /// The tool succeeded but its output could not be serialized.
65    OutputSerialization,
66}
67
68impl ToolFailureKind {
69    /// The wire string recorded in the failure object's `kind` field.
70    #[must_use]
71    pub fn as_str(self) -> &'static str {
72        match self {
73            Self::InvalidInput => "invalid_input",
74            Self::Handler => "handler",
75            Self::OutputSerialization => "output_serialization",
76        }
77    }
78
79    /// Parses a recorded `kind` string back into the enum.
80    #[must_use]
81    pub fn from_wire(kind: &str) -> Option<Self> {
82        match kind {
83            "invalid_input" => Some(Self::InvalidInput),
84            "handler" => Some(Self::Handler),
85            "output_serialization" => Some(Self::OutputSerialization),
86            _ => None,
87        }
88    }
89}
90
91/// A tool failure as recorded in (and decoded from) a completion output.
92#[derive(Clone, Debug, PartialEq, Eq)]
93pub struct ToolFailure {
94    /// Which dispatch layer failed.
95    pub kind: ToolFailureKind,
96    /// The full error chain: the top error's message and every source,
97    /// joined with `": "`. Never truncated here; compaction happens only on
98    /// the way into model context.
99    pub message: String,
100    /// How many times the call executed, counting retries.
101    pub attempts: u32,
102}
103
104impl ToolFailure {
105    /// Builds a failure record from a dispatch error, capturing the full
106    /// source chain into [`message`](Self::message).
107    #[must_use]
108    pub fn from_error(error: &salvor_tools::ToolError, attempts: u32) -> Self {
109        let kind = match error {
110            salvor_tools::ToolError::InvalidInput { .. } => ToolFailureKind::InvalidInput,
111            // A call refused for want of its declared idempotency key never
112            // reached the tool, and the fault is in the arguments, which is
113            // exactly what `invalid_input` records. It shares that wire kind
114            // rather than minting a new one: the recorded `kind` strings are a
115            // stable format that replay parses, and this failure needs no new
116            // handling, only its own message.
117            salvor_tools::ToolError::MissingIdempotencyKey { .. } => ToolFailureKind::InvalidInput,
118            salvor_tools::ToolError::Handler { .. } => ToolFailureKind::Handler,
119            salvor_tools::ToolError::OutputSerialization { .. } => {
120                ToolFailureKind::OutputSerialization
121            }
122        };
123        Self {
124            kind,
125            message: error_chain(error),
126            attempts,
127        }
128    }
129}
130
131/// Joins an error's `Display` with every `source()` below it, separated by
132/// `": "`, so the recorded message keeps the information `thiserror` spreads
133/// across the chain.
134#[must_use]
135pub fn error_chain(error: &dyn std::error::Error) -> String {
136    let mut message = error.to_string();
137    let mut source = error.source();
138    while let Some(inner) = source {
139        message.push_str(": ");
140        message.push_str(&inner.to_string());
141        source = inner.source();
142    }
143    message
144}
145
146/// Encodes a suspension as the sentinel completion output.
147#[must_use]
148pub fn encode_suspension(suspension: &Suspension) -> Value {
149    json!({
150        SUSPEND_SENTINEL_KEY: {
151            "reason": suspension.reason,
152            "input_schema": suspension.input_schema,
153        }
154    })
155}
156
157/// Encodes a tool failure as the sentinel completion output.
158#[must_use]
159pub fn encode_failure(failure: &ToolFailure) -> Value {
160    json!({
161        ERROR_SENTINEL_KEY: {
162            "is_error": true,
163            "kind": failure.kind.as_str(),
164            "message": failure.message,
165            "attempts": failure.attempts,
166        }
167    })
168}
169
170/// Decodes a completion output that is the suspension sentinel, if it is one.
171#[must_use]
172pub fn decode_suspension(output: &Value) -> Option<Suspension> {
173    let body = sentinel_body(output, SUSPEND_SENTINEL_KEY)?;
174    Some(Suspension {
175        reason: body.get("reason")?.as_str()?.to_owned(),
176        input_schema: body.get("input_schema")?.clone(),
177    })
178}
179
180/// Decodes a completion output that is the failure sentinel, if it is one.
181#[must_use]
182pub fn decode_failure(output: &Value) -> Option<ToolFailure> {
183    let body = sentinel_body(output, ERROR_SENTINEL_KEY)?;
184    Some(ToolFailure {
185        kind: ToolFailureKind::from_wire(body.get("kind")?.as_str()?)?,
186        message: body.get("message")?.as_str()?.to_owned(),
187        attempts: u32::try_from(body.get("attempts")?.as_u64()?).ok()?,
188    })
189}
190
191/// The sentinel's body when `output` is an object with exactly one key equal
192/// to `key`; `None` for every other value.
193fn sentinel_body<'v>(output: &'v Value, key: &str) -> Option<&'v Value> {
194    let map = output.as_object()?;
195    if map.len() != 1 {
196        return None;
197    }
198    map.get(key)
199}
200
201/// Renders a JSON value as the text handed to the model (an initial input or
202/// a `tool_result` content string).
203///
204/// A JSON string renders as its bare text; anything else renders as
205/// canonical JSON. The canonical form matters: this text flows into the next
206/// model request, the request is hashed, and the hash must reproduce on
207/// replay, so the rendering cannot depend on map iteration order.
208#[must_use]
209pub fn content_string(value: &Value) -> String {
210    match value {
211        Value::String(text) => text.clone(),
212        other => canonical_json(other),
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    /// Both sentinels survive an encode/decode round trip.
221    #[test]
222    fn sentinels_round_trip() {
223        let suspension = Suspension {
224            reason: "needs approval".to_owned(),
225            input_schema: json!({"type": "object", "required": ["approved"]}),
226        };
227        assert_eq!(
228            decode_suspension(&encode_suspension(&suspension)),
229            Some(suspension)
230        );
231
232        let failure = ToolFailure {
233            kind: ToolFailureKind::Handler,
234            message: "tool `x` failed: connection reset".to_owned(),
235            attempts: 3,
236        };
237        assert_eq!(decode_failure(&encode_failure(&failure)), Some(failure));
238    }
239
240    /// Ordinary outputs never decode as sentinels, even when they contain
241    /// the reserved names below the top level or alongside other keys.
242    #[test]
243    fn ordinary_outputs_are_not_sentinels() {
244        assert_eq!(decode_suspension(&json!({"result": 1})), None);
245        assert_eq!(
246            decode_suspension(&json!({"__salvor_suspend": {}, "other": 1})),
247            None
248        );
249        assert_eq!(
250            decode_failure(&json!({"nested": {"__salvor_error": {}}})),
251            None
252        );
253        assert_eq!(decode_failure(&json!("__salvor_error")), None);
254    }
255
256    /// String values render bare; structured values render canonically.
257    #[test]
258    fn content_string_renders_deterministically() {
259        assert_eq!(content_string(&json!("plain")), "plain");
260        let a: Value = serde_json::from_str(r#"{"b": 1, "a": 2}"#).unwrap();
261        assert_eq!(content_string(&a), r#"{"a":2,"b":1}"#);
262    }
263}