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::{ToolContext, ToolDefinition, ToolEffect, ToolError, ToolOutput};
7
8/// Convenience type for tools that take no parameters.
9#[derive(
10    Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
11)]
12pub struct EmptyParams {}
13
14/// A custom tool implemented entirely in Rust with strongly-typed parameters.
15///
16/// Define your parameters as a struct deriving [`serde::Deserialize`] and
17/// `JsonSchema`, then implement this trait to provide the tool's logic.
18/// The JSON Schema sent to the model is derived automatically from the
19/// params struct — doc comments on fields become parameter descriptions.
20///
21/// Tools are async: for I/O-bound work (HTTP, filesystem, subprocess) the
22/// runtime stays unblocked. Sync tools just don't `.await` anything — the
23/// compiler optimizes the state machine to an immediate return.
24///
25/// # Example
26///
27/// ```rust
28/// use llm_tool::{JsonSchema, RustTool, ToolContext, ToolError, ToolOutput};
29/// use serde::Deserialize;
30///
31/// #[derive(Deserialize, JsonSchema)]
32/// struct FlashParams {
33///     /// Target device identifier.
34///     device_id: String,
35///     /// Path to the firmware image.
36///     image_path: String,
37/// }
38///
39/// struct FlashDevice;
40///
41/// impl RustTool for FlashDevice {
42///     type Params = FlashParams;
43///     const NAME: &'static str = "flash_device";
44///     const DESCRIPTION: &'static str = "Flashes firmware to a connected device.";
45///
46///     async fn call(
47///         &self,
48///         params: Self::Params,
49///         _ctx: &ToolContext,
50///     ) -> Result<ToolOutput, ToolError> {
51///         Ok(format!("Flashed {} to {}", params.image_path, params.device_id).into())
52///     }
53/// }
54/// ```
55pub trait RustTool: Send + Sync {
56    /// The strongly-typed parameters struct.
57    ///
58    /// Derive [`serde::Deserialize`] and `JsonSchema` on your params struct.
59    /// `JsonSchema` auto-generates the parameter schema sent to the model;
60    /// `Deserialize` parses the model's JSON arguments into your struct.
61    type Params: serde::de::DeserializeOwned + schemars::JsonSchema + Send;
62
63    /// Unique tool name (e.g. `"flash_device"`).
64    const NAME: &'static str;
65
66    /// Human-readable description shown to the model.
67    const DESCRIPTION: &'static str;
68
69    /// Execution effect of the tool.
70    const EFFECT: ToolEffect = if Self::IDEMPOTENT {
71        ToolEffect::ReadOnly
72    } else {
73        ToolEffect::Mutating
74    };
75
76    /// Whether the tool is idempotent / read-only safe for concurrent wave execution.
77    const IDEMPOTENT: bool = false;
78
79    /// Whether the tool output should be sanitized.
80    const SANITIZE_OUTPUT: bool = false;
81
82    /// Return the execution effect of the tool.
83    fn effect(&self) -> ToolEffect {
84        Self::EFFECT
85    }
86
87    /// Return whether the tool is idempotent (read-only).
88    fn is_idempotent(&self) -> bool {
89        matches!(self.effect(), ToolEffect::ReadOnly)
90    }
91
92    /// Return the tool description used in [`ToolDefinition`].
93    ///
94    /// The default returns [`Self::DESCRIPTION`] (the static string from a
95    /// doc comment or template body). When using
96    /// `#[llm_tool(description_file = "...", context = ...)]`, the generated
97    /// implementation overrides this to render the template with runtime
98    /// variables on each call (parsed once via `LazyLock`), falling back to
99    /// [`Self::DESCRIPTION`] if a render ever fails.
100    fn description(&self) -> Cow<'static, str> {
101        Cow::Borrowed(Self::DESCRIPTION)
102    }
103
104    /// Execute the tool with typed parameters and an execution context.
105    ///
106    /// Async to support I/O-bound tools (HTTP, filesystem, subprocess).
107    /// Sync tools just compute and return — the async wrapper is zero-cost.
108    ///
109    /// The `ctx` parameter provides access to conversation metadata and a
110    /// shared key-value state store. Tools that don't need context can simply
111    /// ignore it with `_ctx`.
112    ///
113    /// # Errors
114    ///
115    /// Returns `Err(ToolError)` if the tool execution fails.
116    fn call(
117        &self,
118        params: Self::Params,
119        ctx: &ToolContext,
120    ) -> impl Future<Output = Result<ToolOutput, ToolError>> + Send;
121}
122
123/// Build a [`ToolDefinition`] from any [`RustTool`] implementor.
124///
125/// The generated schema is sanitized for broad compatibility with LLM
126/// SDKs that expect `"type"` to always be a single string
127/// (not the array form `["string", "null"]` that schemars emits for
128/// `Option<T>` fields).
129///
130/// # Errors
131///
132/// Returns `Err` if the JSON schema serialization fails.
133pub fn definition_of<T: RustTool>(tool: &T) -> Result<ToolDefinition, ToolError> {
134    let schema = schemars::schema_for!(T::Params);
135    let mut parameter_schema = serde_json::to_value(schema).map_err(|e| {
136        ToolError::new(format!(
137            "Failed to serialize schema for tool '{}': {e}",
138            T::NAME
139        ))
140    })?;
141    sanitize_schema_types(&mut parameter_schema);
142    Ok(ToolDefinition::with_effect(
143        T::NAME,
144        tool.description().into_owned(),
145        parameter_schema,
146        tool.effect(),
147    ))
148}
149
150/// Recursively sanitize JSON Schema `"type"` fields for Go genai compatibility.
151///
152/// `schemars` emits `"type": ["string", "null"]` for `Option<String>` fields
153/// (the nullable type-array form). The Go genai SDK's `Schema.Type`
154/// is a single `genai.Type` enum, so it can't unmarshal an array.
155///
156/// This function walks the schema tree and replaces any array `type` with the
157/// first non-`"null"` element. For example:
158/// - `["string", "null"]` → `"string"`
159/// - `["integer", "null"]` → `"integer"`
160fn sanitize_schema_types(value: &mut serde_json::Value) {
161    match value {
162        serde_json::Value::Object(map) => {
163            // If "type" is an array (e.g. ["string", "null"]), pick the first
164            // non-"null" element and replace with it as a scalar type.
165            let replacement = match map.get("type") {
166                Some(serde_json::Value::Array(arr)) => {
167                    let non_null = arr.iter().find(|v| v.as_str() != Some("null")).cloned();
168                    non_null.or_else(|| arr.first().cloned())
169                }
170                _ => None,
171            };
172            if let Some(val) = replacement {
173                map.insert("type".to_string(), val);
174            }
175            for val in map.values_mut() {
176                sanitize_schema_types(val);
177            }
178        }
179        serde_json::Value::Array(arr) => {
180            for item in arr {
181                sanitize_schema_types(item);
182            }
183        }
184        _ => {}
185    }
186}
187
188/// Type-erased future returned when dispatching a boxed tool.
189///
190/// Produced by the crate-internal type-erasure layer that lets heterogeneous
191/// [`RustTool`] implementations share one registry.
192pub type BoxToolFuture<'a> =
193    Pin<Box<dyn Future<Output = Result<ToolOutput, ToolError>> + Send + 'a>>;
194
195/// Type-erased wrapper enabling heterogeneous tool storage.
196///
197/// Boxes the future from [`RustTool::call`] so we can store different tool
198/// types in the same `HashMap<String, Box<dyn ErasedTool>>`.
199pub(crate) trait ErasedTool: Send + Sync {
200    /// Deserialize `args` and call the handler, returning a boxed future.
201    fn call_erased<'a>(
202        &'a self,
203        args: serde_json::Value,
204        ctx: &'a ToolContext,
205    ) -> BoxToolFuture<'a>;
206
207    /// Deserialize `args_json` directly from a JSON string and call the handler,
208    /// returning a boxed future without intermediate DOM allocation.
209    fn call_erased_str<'a>(&'a self, args_json: &'a str, ctx: &'a ToolContext)
210    -> BoxToolFuture<'a>;
211}
212
213impl<T: RustTool> ErasedTool for T {
214    fn call_erased<'a>(
215        &'a self,
216        args: serde_json::Value,
217        ctx: &'a ToolContext,
218    ) -> BoxToolFuture<'a> {
219        Box::pin(async move {
220            let params: T::Params = serde_json::from_value(args).map_err(|e| {
221                ToolError::new(format!("Failed to deserialize tool parameters: {e}"))
222            })?;
223            self.call(params, ctx).await
224        })
225    }
226
227    fn call_erased_str<'a>(
228        &'a self,
229        args_json: &'a str,
230        ctx: &'a ToolContext,
231    ) -> BoxToolFuture<'a> {
232        Box::pin(async move {
233            let params: T::Params = serde_json::from_str(args_json).map_err(|e| {
234                ToolError::new(format!("Failed to deserialize tool parameters: {e}"))
235            })?;
236            self.call(params, ctx).await
237        })
238    }
239}