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}