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}