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}
38
39/// A tool with its types erased: `Value` in, `Value` out, dispatched by name.
40///
41/// This is the object-safe (dyn-compatible) trait the registry stores as
42/// `Box<dyn DynTool>` and the runtime loop calls through. Erasure is what lets
43/// tools with different `Input`/`Output` types sit in one collection and be
44/// dispatched uniformly.
45///
46/// Two kinds of tool implement it:
47///
48/// - Native Rust tools, indirectly: a [`ToolHandler`] is wrapped in
49/// [`TypedTool`], which implements `DynTool` by deserializing the input,
50/// calling the typed handler, and serializing the output.
51/// - MCP-backed tools (a later task), directly: their name, description, and
52/// schema are known only at runtime, so they implement these methods rather
53/// than the compile-time-constant [`ToolMeta`](crate::ToolMeta).
54///
55/// The metadata accessors are methods (not associated constants) precisely so
56/// the second kind is expressible. `async fn call_json` uses `#[async_trait]`,
57/// which rewrites it to return a boxed future; a native `async fn` in a trait
58/// is not yet dyn-compatible, and this trait must be `dyn`.
59#[async_trait]
60pub trait DynTool: Send + Sync {
61 /// The name the model calls this tool by.
62 fn name(&self) -> &str;
63 /// The human-readable description handed to the model.
64 fn description(&self) -> &str;
65 /// The side-effect class governing retry and resume behavior.
66 fn effect(&self) -> Effect;
67 /// The JSON Schema the tool's input must satisfy.
68 fn input_schema(&self) -> Value;
69
70 /// Dispatches the tool with JSON input, returning JSON output or a
71 /// suspension.
72 ///
73 /// The contract every implementation honors:
74 ///
75 /// - Validate `input` against the tool's input type **first**. If it does
76 /// not deserialize, return [`ToolError::InvalidInput`] and do **not** run
77 /// the tool's work. This is what lets the runtime loop feed a bad-arguments
78 /// error back to the model without any side effect having happened.
79 /// - On valid input, run the tool and return
80 /// [`ToolOutcome::Output`]`(value)` or [`ToolOutcome::Suspend`]. A
81 /// suspension is a success outcome, not an error.
82 async fn call_json(&self, ctx: &ToolCtx, input: Value)
83 -> Result<ToolOutcome<Value>, ToolError>;
84
85 /// A cloneable metadata snapshot for handing to a model. Provided; built
86 /// from the four accessors above.
87 fn descriptor(&self) -> ToolDescriptor {
88 ToolDescriptor {
89 name: self.name().to_owned(),
90 description: self.description().to_owned(),
91 effect: self.effect(),
92 input_schema: self.input_schema(),
93 }
94 }
95}
96
97/// Wraps a typed [`ToolHandler`] as a type-erased [`DynTool`].
98///
99/// This adapter is where erasure happens for native Rust tools:
100/// [`call_json`](DynTool::call_json) deserializes the incoming `Value` into the
101/// handler's `Input`, calls the typed handler, and serializes its `Output` back
102/// to a `Value`. A [`Suspension`](crate::Suspension) passes through untouched.
103///
104/// A newtype wrapper is used rather than a blanket `impl<H: ToolHandler>
105/// DynTool for H`, so that MCP-backed tools remain free to implement `DynTool`
106/// directly for their own types without a coherence conflict.
107///
108/// [`ToolSet::register`](crate::ToolSet::register) wraps handlers in this
109/// automatically, so most callers never name it.
110pub struct TypedTool<H>(pub H);
111
112impl<H> TypedTool<H> {
113 /// Wraps a handler.
114 pub fn new(handler: H) -> Self {
115 Self(handler)
116 }
117}
118
119#[async_trait]
120impl<H: ToolHandler> DynTool for TypedTool<H> {
121 fn name(&self) -> &str {
122 H::NAME
123 }
124
125 fn description(&self) -> &str {
126 H::DESCRIPTION
127 }
128
129 fn effect(&self) -> Effect {
130 H::EFFECT
131 }
132
133 fn input_schema(&self) -> Value {
134 H::input_schema()
135 }
136
137 async fn call_json(
138 &self,
139 ctx: &ToolCtx,
140 input: Value,
141 ) -> Result<ToolOutcome<Value>, ToolError> {
142 // Validate first: a deserialization failure is the model's bad input,
143 // and the handler must not run in that case.
144 let typed: H::Input =
145 serde_json::from_value(input).map_err(|source| ToolError::InvalidInput {
146 tool: H::NAME.to_owned(),
147 source,
148 })?;
149
150 let outcome = self
151 .0
152 .call(ctx, typed)
153 .await
154 .map_err(|source| ToolError::Handler {
155 tool: H::NAME.to_owned(),
156 source,
157 })?;
158
159 match outcome {
160 ToolOutcome::Output(output) => {
161 let value = serde_json::to_value(output).map_err(|source| {
162 ToolError::OutputSerialization {
163 tool: H::NAME.to_owned(),
164 source,
165 }
166 })?;
167 Ok(ToolOutcome::Output(value))
168 }
169 // A suspension is identical on both sides of the erasure boundary,
170 // so it crosses unchanged.
171 ToolOutcome::Suspend(suspension) => Ok(ToolOutcome::Suspend(suspension)),
172 }
173 }
174}