Skip to main content

salvor_tools/
handler.rs

1//! The typed tool contract: [`ToolMeta`] (a tool's identity and effect) and
2//! [`ToolHandler`] (its typed input, output, and behavior).
3//!
4//! These two traits are split along the seam the future `#[derive(Tool)]`
5//! macro will cut, described under [the derive seam](#the-derive-seam).
6
7use async_trait::async_trait;
8use salvor_core::Effect;
9use schemars::JsonSchema;
10use serde::Serialize;
11use serde::de::DeserializeOwned;
12use serde_json::Value;
13
14use crate::context::ToolCtx;
15use crate::error::HandlerError;
16use crate::outcome::ToolOutcome;
17
18/// A tool's static identity: the name a model calls it by, a human
19/// description, and its side-effect [`Effect`] class.
20///
21/// This is the metadata half of the tool contract, split out from
22/// [`ToolHandler`] on purpose (see [the derive seam](#the-derive-seam)). It
23/// carries no behavior and no associated types, so a proc-macro can generate
24/// it from struct-level attributes with nothing to infer.
25///
26/// The three members are associated constants because a native Rust tool knows
27/// all three at compile time. (Tools whose identity is known only at runtime,
28/// such as MCP-backed tools, do not implement this trait at all; they
29/// implement the type-erased [`DynTool`](crate::DynTool) directly, whose
30/// name/description/effect are methods.)
31///
32/// # The derive seam
33///
34/// Tool definition is a derive plus a hand-written impl:
35///
36/// ```ignore
37/// #[derive(Tool)]
38/// #[tool(effect = "write", description = "Create a Jira ticket")]
39/// struct CreateTicket;
40///
41/// impl ToolHandler for CreateTicket {
42///     type Input = TicketRequest;
43///     type Output = TicketRef;
44///     async fn call(&self, ctx: &ToolCtx, input: TicketRequest) -> Result<...> { ... }
45/// }
46/// ```
47///
48/// The split between these two traits is exactly the split between what the
49/// macro writes and what the user writes:
50///
51/// - **`#[derive(Tool)]` generates the `ToolMeta` impl.** It reads the struct
52///   name for [`NAME`](Self::NAME) (overridable by a `name = "..."` attribute),
53///   the `description = "..."` attribute for [`DESCRIPTION`](Self::DESCRIPTION),
54///   and the `effect = "read" | "idempotent" | "write"` attribute for
55///   [`EFFECT`](Self::EFFECT). It generates no `call` body and touches none of
56///   the typed input or output.
57/// - **The user writes the `ToolHandler` impl.** They choose the `Input` and
58///   `Output` types and write the async `call`. The input JSON Schema is not
59///   hand-written: it is derived from `Input` by `schemars`, surfaced by the
60///   provided [`ToolHandler::input_schema`] method.
61///
62/// Because the metadata lives in its own trait with no reference to `Input`,
63/// `Output`, or `call`, the macro never has to parse or reason about the
64/// handler body. That is the whole reason for the split, and it is why this
65/// trait must stay behavior-free.
66pub trait ToolMeta {
67    /// The name a model calls this tool by. Unique within a
68    /// [`ToolSet`](crate::ToolSet).
69    const NAME: &'static str;
70    /// A human-readable description handed to the model alongside the schema.
71    const DESCRIPTION: &'static str;
72    /// The side-effect class that governs this tool's retry and resume
73    /// behavior. See [`Effect`] and [`RetryPolicy`](crate::RetryPolicy).
74    const EFFECT: Effect;
75}
76
77/// The behavior half of the tool contract: typed input, typed output, and the
78/// async `call` that turns one into the other.
79///
80/// A type implements this by hand (the macro only generates its
81/// [`ToolMeta`] supertrait; see [the derive seam](ToolMeta#the-derive-seam)).
82/// The associated types carry the bounds the rest of the system needs:
83///
84/// - `Input: DeserializeOwned` so the type-erased layer can turn the model's
85///   JSON into it, and `Input: JsonSchema` so a schema can be generated to
86///   hand the model in the first place.
87/// - `Output: Serialize` so the type-erased layer can turn the handler's
88///   result back into JSON for the event log.
89///
90/// The `Send`/`Sync` bounds and the `Send` bound on `Input` are what let a
91/// handler be wrapped as a [`DynTool`](crate::DynTool) and dispatched from an
92/// async runtime that may move the work across threads.
93///
94/// `call` returns a [`ToolOutcome`], not a bare `Output`: a human-in-the-loop
95/// tool may return [`ToolOutcome::Suspend`] to park the run. Its error type is
96/// [`HandlerError`], the tool's own failure; a schema mismatch is impossible
97/// here because `call` only ever receives an already-deserialized `Input`.
98#[async_trait]
99pub trait ToolHandler: ToolMeta + Send + Sync {
100    /// The typed input the model must supply. `DeserializeOwned` drives erased
101    /// dispatch; `JsonSchema` drives the schema handed to the model.
102    type Input: DeserializeOwned + JsonSchema + Send;
103    /// The typed output the tool produces on success.
104    type Output: Serialize;
105
106    /// Runs the tool for one attempt.
107    ///
108    /// `ctx` carries the idempotency key for this attempt (see [`ToolCtx`]).
109    /// The input is already validated and typed. Return
110    /// [`ToolOutcome::Output`] on success, [`ToolOutcome::Suspend`] to park the
111    /// run for a human, or `Err(`[`HandlerError`]`)` on a genuine failure.
112    async fn call(
113        &self,
114        ctx: &ToolCtx,
115        input: Self::Input,
116    ) -> Result<ToolOutcome<Self::Output>, HandlerError>;
117
118    /// The JSON Schema for [`Input`](Self::Input), generated by `schemars`.
119    ///
120    /// This is a provided method: it lives on the handler trait because it
121    /// needs the `Input` type, and no tool should override it. The runtime
122    /// hands this schema to the model so the model knows how to shape its tool
123    /// call. It is surfaced without erasure here and, after wrapping, through
124    /// [`DynTool::input_schema`](crate::DynTool::input_schema).
125    fn input_schema() -> Value
126    where
127        Self: Sized,
128    {
129        serde_json::to_value(schemars::schema_for!(Self::Input))
130            .expect("a schemars-generated schema always serializes to JSON")
131    }
132
133    /// The JSON Schema a *completion* for this tool must satisfy, if the tool
134    /// declares one. `None` by default.
135    ///
136    /// This is not a schema for `Output`, and nothing validates against it
137    /// yet: stage one only lets a tool declare it. What it is for is the
138    /// stage that follows. A tool call performed outside the salvor process
139    /// (a client recording work it did itself, rather than salvor dispatching
140    /// it) has no handler run to trust; the only thing standing between that
141    /// and a bare unverifiable claim of "I did the work" is a schema the
142    /// completion is required to match, one shaped to demand something a
143    /// forger cannot cheaply produce, such as a provider transaction
144    /// reference or a settlement id. Declaring the schema here is what makes
145    /// that requirement expressible; nothing in this crate checks a
146    /// completion against it yet.
147    ///
148    /// `Self::Output` carries no `JsonSchema` bound (unlike `Self::Input`,
149    /// which does), so this cannot default to deriving one: adding that bound
150    /// would break every existing implementor of this trait outside this
151    /// crate, most of which have no need for an output schema at all. A tool
152    /// whose `Output` does implement `JsonSchema` can opt in with one line:
153    ///
154    /// ```ignore
155    /// fn output_schema() -> Option<Value> {
156    ///     Some(serde_json::to_value(schemars::schema_for!(Self::Output)).unwrap())
157    /// }
158    /// ```
159    fn output_schema() -> Option<Value>
160    where
161        Self: Sized,
162    {
163        None
164    }
165
166    /// The idempotency key this tool declares for `input`, if it declares one.
167    /// `None` by default.
168    ///
169    /// This is the typed half of
170    /// [`DynTool::idempotency_key`](crate::DynTool::idempotency_key), which is
171    /// where the full argument lives; the wrapper forwards to this method with
172    /// the input already deserialized. It takes `&self` rather than being an
173    /// associated function so a tool configured at construction (an account, a
174    /// tenant, an environment) can fold that into the key.
175    ///
176    /// The key must be a pure function of `input` and `self`, and it should be
177    /// as specific as the effect it names:
178    ///
179    /// ```ignore
180    /// fn idempotency_key(&self, input: &PayClaim) -> Option<String> {
181    ///     Some(format!("pay_claim:{}", input.claim_id))
182    /// }
183    /// ```
184    fn idempotency_key(&self, input: &Self::Input) -> Option<String> {
185        let _ = input;
186        None
187    }
188}