Skip to main content

salvor_tools/
error.rs

1//! The two error surfaces of the tool layer: [`HandlerError`], which a tool's
2//! own code returns, and [`ToolError`], which the type-erased dispatch layer
3//! returns.
4//!
5//! Keeping them separate is the point. The runtime loop treats a bad
6//! input from the model differently from a tool that ran and failed: the first
7//! is fed back to the model so it can correct its arguments, the second is
8//! recorded and surfaced per the effect's retry policy. That difference is
9//! encoded as distinct [`ToolError`] variants, not left to string matching.
10
11use thiserror::Error;
12
13/// The error a [`ToolHandler`](crate::ToolHandler) returns from its own code.
14///
15/// A handler is only ever called with an already-typed, already-validated
16/// input, so it never has to report a schema mismatch. What it reports is a
17/// genuine failure of the work it does: a provider rejected the request, a
18/// precondition did not hold, and so on. This type carries a human-readable
19/// message and an optional underlying source error.
20///
21/// The dispatch layer wraps a returned `HandlerError` in
22/// [`ToolError::Handler`], tagging it with the tool name.
23#[derive(Debug, Error)]
24#[error("{message}")]
25pub struct HandlerError {
26    message: String,
27    #[source]
28    source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
29}
30
31impl HandlerError {
32    /// Builds a `HandlerError` from a message alone, with no underlying source.
33    pub fn message(message: impl Into<String>) -> Self {
34        Self {
35            message: message.into(),
36            source: None,
37        }
38    }
39
40    /// Builds a `HandlerError` that wraps an underlying error as its source.
41    ///
42    /// The message is taken from the source's `Display` output, so the wrapped
43    /// error's own text is preserved. Use this to forward a `?`-propagated
44    /// error out of a handler: `some_call().map_err(HandlerError::new)?`.
45    pub fn new(source: impl Into<Box<dyn std::error::Error + Send + Sync + 'static>>) -> Self {
46        let source = source.into();
47        Self {
48            message: source.to_string(),
49            source: Some(source),
50        }
51    }
52}
53
54/// The error the type-erased dispatch layer
55/// ([`DynTool::call_json`](crate::DynTool::call_json)) returns.
56///
57/// The variants are deliberately distinct so the runtime loop can route on them:
58///
59/// - [`ToolError::InvalidInput`] means the JSON the model produced did not
60///   deserialize into the tool's `Input` type. The handler was **not** called.
61///   The loop feeds this back to the model to let it fix its arguments.
62/// - [`ToolError::Handler`] means the handler ran and returned a
63///   [`HandlerError`]. This is a real execution failure, subject to the
64///   effect's [retry policy](crate::RetryPolicy).
65/// - [`ToolError::MalformedResult`] means the tool ran and returned something
66///   this layer could not read as a result. Like `OutputSerialization` it is
67///   the tool author's own bug, so it is not retryable: the same bytes decode
68///   the same way on a second attempt, and the only thing a retry buys is a
69///   slower answer to a question already settled.
70/// - [`ToolError::OutputSerialization`] means the handler succeeded but its
71///   `Output` value could not be serialized to JSON. This is an internal fault
72///   in the tool definition, not the model's doing and not a retryable failure.
73/// - [`ToolError::MissingIdempotencyKey`] means the operator declared which
74///   input field identifies this tool's calls and the call does not carry it.
75///   The tool was **not** called, and it will not be called unkeyed. Like
76///   `InvalidInput` this is the arguments being wrong, so the loop feeds it back
77///   to the model rather than retrying it.
78#[derive(Debug, Error)]
79pub enum ToolError {
80    /// The model's JSON input did not match the tool's input schema. The
81    /// handler was never invoked.
82    #[error("input for tool `{tool}` did not match its schema: {source}")]
83    InvalidInput {
84        /// The tool that rejected the input.
85        tool: String,
86        /// The deserialization error describing why the input was rejected.
87        #[source]
88        source: serde_json::Error,
89    },
90    /// The handler ran and returned a failure.
91    #[error("tool `{tool}` failed")]
92    Handler {
93        /// The tool that failed.
94        tool: String,
95        /// The handler's own error.
96        #[source]
97        source: HandlerError,
98    },
99    /// The tool has a declared idempotency key path and this call's input does
100    /// not yield a key from it. Nothing ran.
101    ///
102    /// See [`IdempotencyPath::derive`](crate::IdempotencyPath::derive) for what
103    /// counts as a key and why falling back to an unkeyed call is not an option
104    /// here.
105    #[error(
106        "tool `{tool}` declares idempotency key path `{path}`, but this call's input yields no key: {detail}. Nothing ran: a tool whose calls carry an identity is never called without one"
107    )]
108    MissingIdempotencyKey {
109        /// The tool whose call was refused.
110        tool: String,
111        /// The declared path, as the operator wrote it.
112        path: String,
113        /// What went wrong at that path, including the keys the input carries.
114        detail: String,
115    },
116    /// The tool ran and handed back a result the dispatch layer could not
117    /// read.
118    ///
119    /// Distinct from [`Handler`](Self::Handler), which is the tool saying its
120    /// own work failed. Here the work may well have succeeded; what arrived
121    /// with it is unreadable, which is a mistake in the tool rather than in
122    /// the world it talked to. That is the whole reason for the separate
123    /// variant: a handler failure is worth another attempt under the right
124    /// effect, and an unreadable result never is.
125    #[error("tool `{tool}` returned a result that could not be read: {detail}")]
126    MalformedResult {
127        /// The tool whose result could not be read.
128        tool: String,
129        /// What was wrong with it, in the words of whatever tried to read it.
130        detail: String,
131    },
132    /// The handler succeeded but its output could not be serialized to JSON.
133    #[error("tool `{tool}` produced output that could not be serialized: {source}")]
134    OutputSerialization {
135        /// The tool whose output failed to serialize.
136        tool: String,
137        /// The serialization error.
138        #[source]
139        source: serde_json::Error,
140    },
141}