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