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}