Skip to main content

salvor_tools/
erased.rs

1//! The type-erased dispatch layer: [`DynTool`], the `Value`-in/`Value`-out
2//! trait every registered tool is stored behind, and [`TypedTool`], the
3//! adapter that turns any typed [`ToolHandler`] into a `DynTool`.
4//!
5//! The runtime loop cannot be generic over each tool's `Input`/`Output`: it
6//! dispatches whichever tool the model named, by string, with JSON arguments.
7//! So it works against `dyn DynTool`. This module is the bridge from the typed
8//! world (where `#[derive(Tool)]` tools live) to that erased world, and it is
9//! the same erased shape the future MCP-backed tools implement directly.
10
11use async_trait::async_trait;
12use salvor_core::Effect;
13use serde_json::Value;
14
15use crate::context::ToolCtx;
16use crate::error::ToolError;
17use crate::handler::ToolHandler;
18use crate::outcome::ToolOutcome;
19
20/// The metadata a [`ToolSet`](crate::ToolSet) exposes for one tool when
21/// enumerating tools for a model: everything the model needs to decide whether
22/// and how to call it.
23///
24/// This is a snapshot, owned and cloneable, so it can be serialized into a
25/// model request or sent over a future control plane without holding a borrow
26/// on the registry.
27#[derive(Clone, Debug, PartialEq)]
28pub struct ToolDescriptor {
29    /// The name the model calls the tool by.
30    pub name: String,
31    /// The human-readable description handed to the model.
32    pub description: String,
33    /// The side-effect class governing retry and resume behavior.
34    pub effect: Effect,
35    /// The JSON Schema the tool's input must satisfy.
36    pub input_schema: Value,
37    /// The JSON Schema a completion for this tool must satisfy, if the tool
38    /// declares one. See [`DynTool::output_schema`] for what this is for and
39    /// why it is `None` unless a tool opts in.
40    pub output_schema: Option<Value>,
41}
42
43/// A tool with its types erased: `Value` in, `Value` out, dispatched by name.
44///
45/// This is the object-safe (dyn-compatible) trait the registry stores as
46/// `Box<dyn DynTool>` and the runtime loop calls through. Erasure is what lets
47/// tools with different `Input`/`Output` types sit in one collection and be
48/// dispatched uniformly.
49///
50/// Two kinds of tool implement it:
51///
52/// - Native Rust tools, indirectly: a [`ToolHandler`] is wrapped in
53///   [`TypedTool`], which implements `DynTool` by deserializing the input,
54///   calling the typed handler, and serializing the output.
55/// - MCP-backed tools (a later task), directly: their name, description, and
56///   schema are known only at runtime, so they implement these methods rather
57///   than the compile-time-constant [`ToolMeta`](crate::ToolMeta).
58///
59/// The metadata accessors are methods (not associated constants) precisely so
60/// the second kind is expressible. `async fn call_json` uses `#[async_trait]`,
61/// which rewrites it to return a boxed future; a native `async fn` in a trait
62/// is not yet dyn-compatible, and this trait must be `dyn`.
63#[async_trait]
64pub trait DynTool: Send + Sync {
65    /// The name the model calls this tool by.
66    fn name(&self) -> &str;
67    /// The human-readable description handed to the model.
68    fn description(&self) -> &str;
69    /// The side-effect class governing retry and resume behavior.
70    fn effect(&self) -> Effect;
71    /// The JSON Schema the tool's input must satisfy.
72    fn input_schema(&self) -> Value;
73
74    /// The JSON Schema a completion for this tool must satisfy, if the tool
75    /// declares one. Provided, defaulting to `None`, so no existing
76    /// implementor of this trait breaks.
77    ///
78    /// See [`ToolHandler::output_schema`](crate::ToolHandler::output_schema)
79    /// for what this is for: it declares the shape a completion for this
80    /// tool must have, which is the mechanism by which a tool call performed
81    /// outside the salvor process can be required to carry a verifiable
82    /// receipt rather than a bare claim. Stage one only declares it; nothing
83    /// in this crate validates a completion against it yet.
84    fn output_schema(&self) -> Option<Value> {
85        None
86    }
87
88    /// The idempotency key this tool declares for `input`, if it declares one.
89    /// Provided, defaulting to `None`, so no existing implementor breaks.
90    ///
91    /// This is the tool naming the effect a call *is*, in its own terms:
92    /// `"pay_claim:wreck-9931"` says "this is the payout for claim 9931",
93    /// whoever asks for it and whenever. That is a stronger statement than the
94    /// keys the runtime derives on a tool's behalf, which only ever say "this
95    /// is the same attempt as that attempt" within one run, and it is the
96    /// statement the runtime needs before it can promise that a second run will
97    /// not pay the claim again. See
98    /// [`RunCtx::tool_call`](../../salvor_runtime/struct.RunCtx.html#method.tool_call)
99    /// for what the runtime does with it.
100    ///
101    /// The key must be a pure function of `input`: the same input yields the
102    /// same key, on this process and the next one, or the promise it carries is
103    /// worth nothing. Do not fold a clock, a counter, or randomness into it.
104    /// Return `None` for a call that has no business identity, which is the
105    /// honest answer for most tools and the default here.
106    ///
107    /// A key naming an effect that money or people depend on should be as
108    /// specific as the effect is. `"pay_claim"` alone would collapse every
109    /// payout the system ever makes into one; the claim id is what makes the
110    /// key mean one payment.
111    ///
112    /// A tool whose code is not here (an MCP server's, a wasm component's)
113    /// still gets to make this statement: its operator names the identifying
114    /// input field in the agent file and the tool derives the key from it. See
115    /// [`IdempotencyPath`](crate::IdempotencyPath) for the format and for what
116    /// a call missing that field gets, which is a refusal rather than a
117    /// quietly unkeyed call.
118    fn idempotency_key(&self, input: &Value) -> Option<String> {
119        let _ = input;
120        None
121    }
122
123    /// Dispatches the tool with JSON input, returning JSON output or a
124    /// suspension.
125    ///
126    /// The contract every implementation honors:
127    ///
128    /// - Validate `input` against the tool's input type **first**. If it does
129    ///   not deserialize, return [`ToolError::InvalidInput`] and do **not** run
130    ///   the tool's work. This is what lets the runtime loop feed a bad-arguments
131    ///   error back to the model without any side effect having happened.
132    /// - On valid input, run the tool and return
133    ///   [`ToolOutcome::Output`]`(value)` or [`ToolOutcome::Suspend`]. A
134    ///   suspension is a success outcome, not an error.
135    async fn call_json(&self, ctx: &ToolCtx, input: Value)
136    -> Result<ToolOutcome<Value>, ToolError>;
137
138    /// A cloneable metadata snapshot for handing to a model. Provided; built
139    /// from the accessors above.
140    fn descriptor(&self) -> ToolDescriptor {
141        ToolDescriptor {
142            name: self.name().to_owned(),
143            description: self.description().to_owned(),
144            effect: self.effect(),
145            input_schema: self.input_schema(),
146            output_schema: self.output_schema(),
147        }
148    }
149}
150
151/// Wraps a typed [`ToolHandler`] as a type-erased [`DynTool`].
152///
153/// This adapter is where erasure happens for native Rust tools:
154/// [`call_json`](DynTool::call_json) deserializes the incoming `Value` into the
155/// handler's `Input`, calls the typed handler, and serializes its `Output` back
156/// to a `Value`. A [`Suspension`](crate::Suspension) passes through untouched.
157///
158/// A newtype wrapper is used rather than a blanket `impl<H: ToolHandler>
159/// DynTool for H`, so that MCP-backed tools remain free to implement `DynTool`
160/// directly for their own types without a coherence conflict.
161///
162/// [`ToolSet::register`](crate::ToolSet::register) wraps handlers in this
163/// automatically, so most callers never name it.
164pub struct TypedTool<H>(pub H);
165
166impl<H> TypedTool<H> {
167    /// Wraps a handler.
168    pub fn new(handler: H) -> Self {
169        Self(handler)
170    }
171}
172
173#[async_trait]
174impl<H: ToolHandler> DynTool for TypedTool<H> {
175    fn name(&self) -> &str {
176        H::NAME
177    }
178
179    fn description(&self) -> &str {
180        H::DESCRIPTION
181    }
182
183    fn effect(&self) -> Effect {
184        H::EFFECT
185    }
186
187    fn input_schema(&self) -> Value {
188        H::input_schema()
189    }
190
191    fn output_schema(&self) -> Option<Value> {
192        H::output_schema()
193    }
194
195    /// Forwards to the typed [`ToolHandler::idempotency_key`], which sees the
196    /// deserialized input rather than raw JSON.
197    ///
198    /// The input is deserialized a second time here, once per keyed dispatch,
199    /// because the key is needed *before*
200    /// [`call_json`](DynTool::call_json) runs and that is where the first
201    /// deserialization happens. Input that does not deserialize yields `None`:
202    /// the dispatch below is about to reject it as
203    /// [`ToolError::InvalidInput`](crate::ToolError::InvalidInput) anyway, and a
204    /// call that cannot run has no effect to name.
205    fn idempotency_key(&self, input: &Value) -> Option<String> {
206        let typed: H::Input = serde_json::from_value(input.clone()).ok()?;
207        self.0.idempotency_key(&typed)
208    }
209
210    async fn call_json(
211        &self,
212        ctx: &ToolCtx,
213        input: Value,
214    ) -> Result<ToolOutcome<Value>, ToolError> {
215        // Validate first: a deserialization failure is the model's bad input,
216        // and the handler must not run in that case.
217        let typed: H::Input =
218            serde_json::from_value(input).map_err(|source| ToolError::InvalidInput {
219                tool: H::NAME.to_owned(),
220                source,
221            })?;
222
223        let outcome = self
224            .0
225            .call(ctx, typed)
226            .await
227            .map_err(|source| ToolError::Handler {
228                tool: H::NAME.to_owned(),
229                source,
230            })?;
231
232        match outcome {
233            ToolOutcome::Output(output) => {
234                let value = serde_json::to_value(output).map_err(|source| {
235                    ToolError::OutputSerialization {
236                        tool: H::NAME.to_owned(),
237                        source,
238                    }
239                })?;
240                Ok(ToolOutcome::Output(value))
241            }
242            // A suspension is identical on both sides of the erasure boundary,
243            // so it crosses unchanged.
244            ToolOutcome::Suspend(suspension) => Ok(ToolOutcome::Suspend(suspension)),
245        }
246    }
247}