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            salvor_tools::ToolError::Handler { .. } => ToolFailureKind::Handler,
112            salvor_tools::ToolError::OutputSerialization { .. } => {
113                ToolFailureKind::OutputSerialization
114            }
115        };
116        Self {
117            kind,
118            message: error_chain(error),
119            attempts,
120        }
121    }
122}
123
124/// Joins an error's `Display` with every `source()` below it, separated by
125/// `": "`, so the recorded message keeps the information `thiserror` spreads
126/// across the chain.
127#[must_use]
128pub fn error_chain(error: &dyn std::error::Error) -> String {
129    let mut message = error.to_string();
130    let mut source = error.source();
131    while let Some(inner) = source {
132        message.push_str(": ");
133        message.push_str(&inner.to_string());
134        source = inner.source();
135    }
136    message
137}
138
139/// Encodes a suspension as the sentinel completion output.
140#[must_use]
141pub fn encode_suspension(suspension: &Suspension) -> Value {
142    json!({
143        SUSPEND_SENTINEL_KEY: {
144            "reason": suspension.reason,
145            "input_schema": suspension.input_schema,
146        }
147    })
148}
149
150/// Encodes a tool failure as the sentinel completion output.
151#[must_use]
152pub fn encode_failure(failure: &ToolFailure) -> Value {
153    json!({
154        ERROR_SENTINEL_KEY: {
155            "is_error": true,
156            "kind": failure.kind.as_str(),
157            "message": failure.message,
158            "attempts": failure.attempts,
159        }
160    })
161}
162
163/// Decodes a completion output that is the suspension sentinel, if it is one.
164#[must_use]
165pub fn decode_suspension(output: &Value) -> Option<Suspension> {
166    let body = sentinel_body(output, SUSPEND_SENTINEL_KEY)?;
167    Some(Suspension {
168        reason: body.get("reason")?.as_str()?.to_owned(),
169        input_schema: body.get("input_schema")?.clone(),
170    })
171}
172
173/// Decodes a completion output that is the failure sentinel, if it is one.
174#[must_use]
175pub fn decode_failure(output: &Value) -> Option<ToolFailure> {
176    let body = sentinel_body(output, ERROR_SENTINEL_KEY)?;
177    Some(ToolFailure {
178        kind: ToolFailureKind::from_wire(body.get("kind")?.as_str()?)?,
179        message: body.get("message")?.as_str()?.to_owned(),
180        attempts: u32::try_from(body.get("attempts")?.as_u64()?).ok()?,
181    })
182}
183
184/// The sentinel's body when `output` is an object with exactly one key equal
185/// to `key`; `None` for every other value.
186fn sentinel_body<'v>(output: &'v Value, key: &str) -> Option<&'v Value> {
187    let map = output.as_object()?;
188    if map.len() != 1 {
189        return None;
190    }
191    map.get(key)
192}
193
194/// Renders a JSON value as the text handed to the model (an initial input or
195/// a `tool_result` content string).
196///
197/// A JSON string renders as its bare text; anything else renders as
198/// canonical JSON. The canonical form matters: this text flows into the next
199/// model request, the request is hashed, and the hash must reproduce on
200/// replay, so the rendering cannot depend on map iteration order.
201#[must_use]
202pub fn content_string(value: &Value) -> String {
203    match value {
204        Value::String(text) => text.clone(),
205        other => canonical_json(other),
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    /// Both sentinels survive an encode/decode round trip.
214    #[test]
215    fn sentinels_round_trip() {
216        let suspension = Suspension {
217            reason: "needs approval".to_owned(),
218            input_schema: json!({"type": "object", "required": ["approved"]}),
219        };
220        assert_eq!(
221            decode_suspension(&encode_suspension(&suspension)),
222            Some(suspension)
223        );
224
225        let failure = ToolFailure {
226            kind: ToolFailureKind::Handler,
227            message: "tool `x` failed: connection reset".to_owned(),
228            attempts: 3,
229        };
230        assert_eq!(decode_failure(&encode_failure(&failure)), Some(failure));
231    }
232
233    /// Ordinary outputs never decode as sentinels, even when they contain
234    /// the reserved names below the top level or alongside other keys.
235    #[test]
236    fn ordinary_outputs_are_not_sentinels() {
237        assert_eq!(decode_suspension(&json!({"result": 1})), None);
238        assert_eq!(
239            decode_suspension(&json!({"__salvor_suspend": {}, "other": 1})),
240            None
241        );
242        assert_eq!(
243            decode_failure(&json!({"nested": {"__salvor_error": {}}})),
244            None
245        );
246        assert_eq!(decode_failure(&json!("__salvor_error")), None);
247    }
248
249    /// String values render bare; structured values render canonically.
250    #[test]
251    fn content_string_renders_deterministically() {
252        assert_eq!(content_string(&json!("plain")), "plain");
253        let a: Value = serde_json::from_str(r#"{"b": 1, "a": 2}"#).unwrap();
254        assert_eq!(content_string(&a), r#"{"a":2,"b":1}"#);
255    }
256}