Skip to main content

llm_tool/
rust_tool.rs

1//! Strongly-typed Rust tool trait and type-erasure machinery.
2
3use alloc::{borrow::Cow, boxed::Box, format, string::ToString};
4use core::{future::Future, pin::Pin};
5
6use super::types::{
7    OutputSanitization, ToolContext, ToolDefinition, ToolEffect, ToolError, ToolOutput,
8    ToolProvenance,
9};
10
11/// Convenience type for tools that take no parameters.
12#[derive(
13    Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
14)]
15pub struct EmptyParams {}
16
17/// A custom tool implemented entirely in Rust with strongly-typed parameters.
18///
19/// Define your parameters as a struct deriving [`serde::Deserialize`] and
20/// `JsonSchema`, then implement this trait to provide the tool's logic.
21/// The JSON Schema sent to the model is derived automatically from the
22/// params struct — doc comments on fields become parameter descriptions.
23///
24/// Tools are async: for I/O-bound work (HTTP, filesystem, subprocess) the
25/// runtime stays unblocked. Sync tools just don't `.await` anything — the
26/// compiler optimizes the state machine to an immediate return.
27///
28/// # Example
29///
30/// ```rust
31/// use llm_tool::{JsonSchema, RustTool, ToolContext, ToolError, ToolOutput};
32/// use serde::Deserialize;
33///
34/// #[derive(Deserialize, JsonSchema)]
35/// struct FlashParams {
36///     /// Target device identifier.
37///     device_id: String,
38///     /// Path to the firmware image.
39///     image_path: String,
40/// }
41///
42/// struct FlashDevice;
43///
44/// impl RustTool for FlashDevice {
45///     type Params = FlashParams;
46///     const NAME: &'static str = "flash_device";
47///     const DESCRIPTION: &'static str = "Flashes firmware to a connected device.";
48///
49///     async fn call(
50///         &self,
51///         params: Self::Params,
52///         _ctx: &ToolContext,
53///     ) -> Result<ToolOutput, ToolError> {
54///         Ok(format!("Flashed {} to {}", params.image_path, params.device_id).into())
55///     }
56/// }
57/// ```
58pub trait RustTool: Send + Sync {
59    /// The strongly-typed parameters struct.
60    ///
61    /// Derive [`serde::Deserialize`] and `JsonSchema` on your params struct.
62    /// `JsonSchema` auto-generates the parameter schema sent to the model;
63    /// `Deserialize` parses the model's JSON arguments into your struct.
64    type Params: serde::de::DeserializeOwned + schemars::JsonSchema + Send;
65
66    /// Unique tool name (e.g. `"flash_device"`).
67    const NAME: &'static str;
68
69    /// Human-readable description shown to the model.
70    const DESCRIPTION: &'static str;
71
72    /// Execution effect of the tool.
73    const EFFECT: ToolEffect = if Self::IDEMPOTENT {
74        ToolEffect::ReadOnly
75    } else {
76        ToolEffect::Mutating
77    };
78
79    /// Whether the tool is idempotent / read-only safe for concurrent wave execution.
80    const IDEMPOTENT: bool = false;
81
82    /// Data-provenance and trust classification of the tool.
83    ///
84    /// Defaults to [`ToolProvenance::Trusted`]. Tools with [`ToolProvenance::ExternalMcp`]
85    /// or [`ToolProvenance::UntrustedNetwork`] automatically default [`Self::OUTPUT_SANITIZATION`]
86    /// to [`OutputSanitization::Quarantine`] unless overridden.
87    const PROVENANCE: ToolProvenance = ToolProvenance::Trusted;
88
89    /// Shorthand: neutralize control tokens in this tool's output.
90    ///
91    /// Defaults to `true` (secure by default). Set to `false` to opt out, or prefer
92    /// setting [`Self::OUTPUT_SANITIZATION`] directly.
93    const SANITIZE_OUTPUT: bool = true;
94
95    /// Shorthand: wrap this tool's output in a `<tool_output_quarantine>` envelope.
96    ///
97    /// Takes precedence over [`Self::SANITIZE_OUTPUT`]. Prefer setting
98    /// [`Self::OUTPUT_SANITIZATION`] directly.
99    const QUARANTINE_OUTPUT: bool = false;
100
101    /// Automatic prompt-injection sanitization policy applied to this tool's output.
102    ///
103    /// Derived from the shorthands and provenance: [`OutputSanitization::Quarantine`] if
104    /// [`Self::QUARANTINE_OUTPUT`] or [`Self::PROVENANCE`] is an untrusted external source,
105    /// else [`OutputSanitization::SanitizeTokens`] if [`Self::SANITIZE_OUTPUT`] (the default),
106    /// else [`OutputSanitization::None`].
107    /// Override it to pick a tagged envelope, or [`OutputSanitization::None`] only for
108    /// output you fully control.
109    const OUTPUT_SANITIZATION: OutputSanitization =
110        if Self::QUARANTINE_OUTPUT || Self::PROVENANCE.is_untrusted_source() {
111            OutputSanitization::Quarantine
112        } else if Self::SANITIZE_OUTPUT {
113            OutputSanitization::SanitizeTokens
114        } else {
115            OutputSanitization::None
116        };
117
118    /// Return the execution effect of the tool.
119    fn effect(&self) -> ToolEffect {
120        Self::EFFECT
121    }
122
123    /// Return the data-provenance tier of the tool.
124    fn provenance(&self) -> ToolProvenance {
125        Self::PROVENANCE
126    }
127
128    /// Return whether the tool is idempotent (read-only).
129    fn is_idempotent(&self) -> bool {
130        matches!(self.effect(), ToolEffect::ReadOnly)
131    }
132
133    /// Return the automatic prompt-injection sanitization policy for this tool's output.
134    fn output_sanitization(&self) -> OutputSanitization {
135        Self::OUTPUT_SANITIZATION
136    }
137
138    /// Return the tool description used in [`ToolDefinition`].
139    ///
140    /// The default returns [`Self::DESCRIPTION`] (the static string from a
141    /// doc comment or template body). When using
142    /// `#[llm_tool(description_file = "...", context = ...)]`, the generated
143    /// implementation overrides this to render the template with runtime
144    /// variables on each call (parsed once via `LazyLock`), falling back to
145    /// [`Self::DESCRIPTION`] if a render ever fails.
146    fn description(&self) -> Cow<'static, str> {
147        Cow::Borrowed(Self::DESCRIPTION)
148    }
149
150    /// Execute the tool with typed parameters and an execution context.
151    ///
152    /// Async to support I/O-bound tools (HTTP, filesystem, subprocess).
153    /// Sync tools just compute and return — the async wrapper is zero-cost.
154    ///
155    /// The `ctx` parameter provides access to conversation metadata and a
156    /// shared key-value state store. Tools that don't need context can simply
157    /// ignore it with `_ctx`.
158    ///
159    /// # Errors
160    ///
161    /// Returns `Err(ToolError)` if the tool execution fails.
162    fn call(
163        &self,
164        params: Self::Params,
165        ctx: &ToolContext,
166    ) -> impl Future<Output = Result<ToolOutput, ToolError>> + Send;
167}
168
169/// Shared [`alloc::sync::Arc`]-backed wrapper for any [`RustTool`] implementation,
170/// enabling cheap cloning across registries and subagent scopes.
171#[derive(Debug)]
172pub struct SharedRustTool<T>(pub alloc::sync::Arc<T>);
173
174impl<T> Clone for SharedRustTool<T> {
175    fn clone(&self) -> Self {
176        Self(alloc::sync::Arc::clone(&self.0))
177    }
178}
179
180impl<T> SharedRustTool<T> {
181    /// Wrap `tool` in a new [`SharedRustTool`].
182    #[must_use]
183    pub fn new(tool: T) -> Self {
184        Self(alloc::sync::Arc::new(tool))
185    }
186
187    /// Borrow the underlying tool.
188    #[must_use]
189    pub fn as_inner(&self) -> &T {
190        &self.0
191    }
192}
193
194impl<T> From<alloc::sync::Arc<T>> for SharedRustTool<T> {
195    fn from(arc: alloc::sync::Arc<T>) -> Self {
196        Self(arc)
197    }
198}
199
200impl<T: RustTool> RustTool for alloc::sync::Arc<T> {
201    type Params = T::Params;
202    const NAME: &'static str = T::NAME;
203    const DESCRIPTION: &'static str = T::DESCRIPTION;
204    const EFFECT: ToolEffect = T::EFFECT;
205    const IDEMPOTENT: bool = T::IDEMPOTENT;
206    const PROVENANCE: ToolProvenance = T::PROVENANCE;
207    const SANITIZE_OUTPUT: bool = T::SANITIZE_OUTPUT;
208    const QUARANTINE_OUTPUT: bool = T::QUARANTINE_OUTPUT;
209    const OUTPUT_SANITIZATION: OutputSanitization = T::OUTPUT_SANITIZATION;
210
211    fn effect(&self) -> ToolEffect {
212        (**self).effect()
213    }
214
215    fn provenance(&self) -> ToolProvenance {
216        (**self).provenance()
217    }
218
219    fn is_idempotent(&self) -> bool {
220        (**self).is_idempotent()
221    }
222
223    fn output_sanitization(&self) -> OutputSanitization {
224        (**self).output_sanitization()
225    }
226
227    fn description(&self) -> Cow<'static, str> {
228        (**self).description()
229    }
230
231    fn call(
232        &self,
233        params: Self::Params,
234        ctx: &ToolContext,
235    ) -> impl Future<Output = Result<ToolOutput, ToolError>> + Send {
236        (**self).call(params, ctx)
237    }
238}
239
240impl<T: RustTool> RustTool for SharedRustTool<T> {
241    type Params = T::Params;
242    const NAME: &'static str = T::NAME;
243    const DESCRIPTION: &'static str = T::DESCRIPTION;
244    const EFFECT: ToolEffect = T::EFFECT;
245    const IDEMPOTENT: bool = T::IDEMPOTENT;
246    const PROVENANCE: ToolProvenance = T::PROVENANCE;
247    const SANITIZE_OUTPUT: bool = T::SANITIZE_OUTPUT;
248    const QUARANTINE_OUTPUT: bool = T::QUARANTINE_OUTPUT;
249    const OUTPUT_SANITIZATION: OutputSanitization = T::OUTPUT_SANITIZATION;
250
251    fn effect(&self) -> ToolEffect {
252        self.0.effect()
253    }
254
255    fn provenance(&self) -> ToolProvenance {
256        self.0.provenance()
257    }
258
259    fn is_idempotent(&self) -> bool {
260        self.0.is_idempotent()
261    }
262
263    fn output_sanitization(&self) -> OutputSanitization {
264        self.0.output_sanitization()
265    }
266
267    fn description(&self) -> Cow<'static, str> {
268        self.0.description()
269    }
270
271    fn call(
272        &self,
273        params: Self::Params,
274        ctx: &ToolContext,
275    ) -> impl Future<Output = Result<ToolOutput, ToolError>> + Send {
276        self.0.call(params, ctx)
277    }
278}
279
280/// Build a [`ToolDefinition`] from any [`RustTool`] implementor.
281///
282/// The generated schema is sanitized for broad compatibility with LLM
283/// SDKs that expect `"type"` to always be a single string
284/// (not the array form `["string", "null"]` that schemars emits for
285/// `Option<T>` fields).
286///
287/// # Errors
288///
289/// Returns `Err` if the JSON schema serialization fails.
290pub fn definition_of<T: RustTool>(tool: &T) -> Result<ToolDefinition, ToolError> {
291    let schema = schemars::schema_for!(T::Params);
292    let mut parameter_schema = serde_json::to_value(schema).map_err(|e| {
293        ToolError::new(format!(
294            "Failed to serialize schema for tool '{}': {e}",
295            T::NAME
296        ))
297    })?;
298    sanitize_schema_types(&mut parameter_schema);
299    Ok(ToolDefinition::with_effect(
300        T::NAME,
301        tool.description().into_owned(),
302        parameter_schema,
303        tool.effect(),
304    )
305    .with_provenance(tool.provenance())
306    .with_output_sanitization(tool.output_sanitization()))
307}
308
309/// Recursively sanitize JSON Schema `"type"` fields for Go genai compatibility.
310///
311/// `schemars` emits `"type": ["string", "null"]` for `Option<String>` fields
312/// (the nullable type-array form). The Go genai SDK's `Schema.Type`
313/// is a single `genai.Type` enum, so it can't unmarshal an array.
314///
315/// This function walks the schema tree and replaces any array `type` with the
316/// first non-`"null"` element. For example:
317/// - `["string", "null"]` → `"string"`
318/// - `["integer", "null"]` → `"integer"`
319const SCHEMA_TYPE_KEY: &str = "type";
320const SCHEMA_NULL_TYPE: &str = "null";
321
322fn sanitize_schema_types(value: &mut serde_json::Value) {
323    match value {
324        serde_json::Value::Object(map) => {
325            // If "type" is an array (e.g. ["string", "null"]), pick the first
326            // non-"null" element and replace with it as a scalar type.
327            let replacement = match map.get(SCHEMA_TYPE_KEY) {
328                Some(serde_json::Value::Array(arr)) => {
329                    let non_null = arr
330                        .iter()
331                        .find(
332                            |v| !matches!(v, serde_json::Value::String(s) if s == SCHEMA_NULL_TYPE),
333                        )
334                        .cloned();
335                    non_null.or_else(|| arr.first().cloned())
336                }
337                _ => None,
338            };
339            if let Some(val) = replacement {
340                map.insert(SCHEMA_TYPE_KEY.to_string(), val);
341            }
342            for val in map.values_mut() {
343                sanitize_schema_types(val);
344            }
345        }
346        serde_json::Value::Array(arr) => {
347            for item in arr {
348                sanitize_schema_types(item);
349            }
350        }
351        _ => {}
352    }
353}
354
355/// Type-erased future returned when dispatching a boxed tool.
356///
357/// Produced by the crate-internal type-erasure layer that lets heterogeneous
358/// [`RustTool`] implementations share one registry.
359pub type BoxToolFuture<'a> =
360    Pin<Box<dyn Future<Output = Result<ToolOutput, ToolError>> + Send + 'a>>;
361
362/// Type-erased wrapper enabling heterogeneous tool storage.
363///
364/// Boxes the future from [`RustTool::call`] so we can store different tool
365/// types in the same `HashMap<String, Box<dyn ErasedTool>>`.
366pub trait ErasedTool: Send + Sync {
367    /// Deserialize `args` and call the handler, returning a boxed future.
368    fn call_erased<'a>(
369        &'a self,
370        args: serde_json::Value,
371        ctx: &'a ToolContext,
372    ) -> BoxToolFuture<'a>;
373
374    /// Deserialize `args_json` directly from a JSON string and call the handler,
375    /// returning a boxed future without intermediate DOM allocation.
376    fn call_erased_str<'a>(&'a self, args_json: &'a str, ctx: &'a ToolContext)
377    -> BoxToolFuture<'a>;
378}
379
380impl<T: RustTool> ErasedTool for T {
381    fn call_erased<'a>(
382        &'a self,
383        args: serde_json::Value,
384        ctx: &'a ToolContext,
385    ) -> BoxToolFuture<'a> {
386        Box::pin(async move {
387            let params: T::Params = serde_json::from_value(args).map_err(|e| {
388                ToolError::new(format!("Failed to deserialize tool parameters: {e}"))
389            })?;
390            let output = self.call(params, ctx).await?;
391            Ok(self.output_sanitization().apply(output))
392        })
393    }
394
395    fn call_erased_str<'a>(
396        &'a self,
397        args_json: &'a str,
398        ctx: &'a ToolContext,
399    ) -> BoxToolFuture<'a> {
400        Box::pin(async move {
401            let params: T::Params = serde_json::from_str(args_json).map_err(|e| {
402                ToolError::new(format!("Failed to deserialize tool parameters: {e}"))
403            })?;
404            let output = self.call(params, ctx).await?;
405            Ok(self.output_sanitization().apply(output))
406        })
407    }
408}