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