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 /// Whether the tool output should have control tokens neutralized (`OutputSanitization::SanitizeTokens`).
82 const SANITIZE_OUTPUT: bool = false;
83
84 /// Whether the tool output should be quarantined (`OutputSanitization::Quarantine`).
85 const QUARANTINE_OUTPUT: bool = false;
86
87 /// Automatic prompt-injection sanitization policy applied to this tool's output.
88 ///
89 /// Defaults to [`OutputSanitization::Quarantine`] if [`Self::QUARANTINE_OUTPUT`] is `true`,
90 /// [`OutputSanitization::SanitizeTokens`] if [`Self::SANITIZE_OUTPUT`] is `true`, or
91 /// [`OutputSanitization::None`] otherwise.
92 const OUTPUT_SANITIZATION: OutputSanitization = if Self::QUARANTINE_OUTPUT {
93 OutputSanitization::Quarantine
94 } else if Self::SANITIZE_OUTPUT {
95 OutputSanitization::SanitizeTokens
96 } else {
97 OutputSanitization::None
98 };
99
100 /// Return the execution effect of the tool.
101 fn effect(&self) -> ToolEffect {
102 Self::EFFECT
103 }
104
105 /// Return whether the tool is idempotent (read-only).
106 fn is_idempotent(&self) -> bool {
107 matches!(self.effect(), ToolEffect::ReadOnly)
108 }
109
110 /// Return the automatic prompt-injection sanitization policy for this tool's output.
111 fn output_sanitization(&self) -> OutputSanitization {
112 Self::OUTPUT_SANITIZATION
113 }
114
115 /// Return the tool description used in [`ToolDefinition`].
116 ///
117 /// The default returns [`Self::DESCRIPTION`] (the static string from a
118 /// doc comment or template body). When using
119 /// `#[llm_tool(description_file = "...", context = ...)]`, the generated
120 /// implementation overrides this to render the template with runtime
121 /// variables on each call (parsed once via `LazyLock`), falling back to
122 /// [`Self::DESCRIPTION`] if a render ever fails.
123 fn description(&self) -> Cow<'static, str> {
124 Cow::Borrowed(Self::DESCRIPTION)
125 }
126
127 /// Execute the tool with typed parameters and an execution context.
128 ///
129 /// Async to support I/O-bound tools (HTTP, filesystem, subprocess).
130 /// Sync tools just compute and return — the async wrapper is zero-cost.
131 ///
132 /// The `ctx` parameter provides access to conversation metadata and a
133 /// shared key-value state store. Tools that don't need context can simply
134 /// ignore it with `_ctx`.
135 ///
136 /// # Errors
137 ///
138 /// Returns `Err(ToolError)` if the tool execution fails.
139 fn call(
140 &self,
141 params: Self::Params,
142 ctx: &ToolContext,
143 ) -> impl Future<Output = Result<ToolOutput, ToolError>> + Send;
144}
145
146/// Shared [`alloc::sync::Arc`]-backed wrapper for any [`RustTool`] implementation,
147/// enabling cheap cloning across registries and subagent scopes.
148#[derive(Debug)]
149pub struct SharedRustTool<T>(pub alloc::sync::Arc<T>);
150
151impl<T> Clone for SharedRustTool<T> {
152 fn clone(&self) -> Self {
153 Self(alloc::sync::Arc::clone(&self.0))
154 }
155}
156
157impl<T> SharedRustTool<T> {
158 /// Wrap `tool` in a new [`SharedRustTool`].
159 #[must_use]
160 pub fn new(tool: T) -> Self {
161 Self(alloc::sync::Arc::new(tool))
162 }
163
164 /// Borrow the underlying tool.
165 #[must_use]
166 pub fn as_inner(&self) -> &T {
167 &self.0
168 }
169}
170
171impl<T> From<alloc::sync::Arc<T>> for SharedRustTool<T> {
172 fn from(arc: alloc::sync::Arc<T>) -> Self {
173 Self(arc)
174 }
175}
176
177impl<T: RustTool> RustTool for alloc::sync::Arc<T> {
178 type Params = T::Params;
179 const NAME: &'static str = T::NAME;
180 const DESCRIPTION: &'static str = T::DESCRIPTION;
181 const EFFECT: ToolEffect = T::EFFECT;
182 const IDEMPOTENT: bool = T::IDEMPOTENT;
183 const SANITIZE_OUTPUT: bool = T::SANITIZE_OUTPUT;
184 const QUARANTINE_OUTPUT: bool = T::QUARANTINE_OUTPUT;
185 const OUTPUT_SANITIZATION: OutputSanitization = T::OUTPUT_SANITIZATION;
186
187 fn effect(&self) -> ToolEffect {
188 (**self).effect()
189 }
190
191 fn is_idempotent(&self) -> bool {
192 (**self).is_idempotent()
193 }
194
195 fn output_sanitization(&self) -> OutputSanitization {
196 (**self).output_sanitization()
197 }
198
199 fn description(&self) -> Cow<'static, str> {
200 (**self).description()
201 }
202
203 fn call(
204 &self,
205 params: Self::Params,
206 ctx: &ToolContext,
207 ) -> impl Future<Output = Result<ToolOutput, ToolError>> + Send {
208 (**self).call(params, ctx)
209 }
210}
211
212impl<T: RustTool> RustTool for SharedRustTool<T> {
213 type Params = T::Params;
214 const NAME: &'static str = T::NAME;
215 const DESCRIPTION: &'static str = T::DESCRIPTION;
216 const EFFECT: ToolEffect = T::EFFECT;
217 const IDEMPOTENT: bool = T::IDEMPOTENT;
218 const SANITIZE_OUTPUT: bool = T::SANITIZE_OUTPUT;
219 const QUARANTINE_OUTPUT: bool = T::QUARANTINE_OUTPUT;
220 const OUTPUT_SANITIZATION: OutputSanitization = T::OUTPUT_SANITIZATION;
221
222 fn effect(&self) -> ToolEffect {
223 self.0.effect()
224 }
225
226 fn is_idempotent(&self) -> bool {
227 self.0.is_idempotent()
228 }
229
230 fn output_sanitization(&self) -> OutputSanitization {
231 self.0.output_sanitization()
232 }
233
234 fn description(&self) -> Cow<'static, str> {
235 self.0.description()
236 }
237
238 fn call(
239 &self,
240 params: Self::Params,
241 ctx: &ToolContext,
242 ) -> impl Future<Output = Result<ToolOutput, ToolError>> + Send {
243 self.0.call(params, ctx)
244 }
245}
246
247/// Build a [`ToolDefinition`] from any [`RustTool`] implementor.
248///
249/// The generated schema is sanitized for broad compatibility with LLM
250/// SDKs that expect `"type"` to always be a single string
251/// (not the array form `["string", "null"]` that schemars emits for
252/// `Option<T>` fields).
253///
254/// # Errors
255///
256/// Returns `Err` if the JSON schema serialization fails.
257pub fn definition_of<T: RustTool>(tool: &T) -> Result<ToolDefinition, ToolError> {
258 let schema = schemars::schema_for!(T::Params);
259 let mut parameter_schema = serde_json::to_value(schema).map_err(|e| {
260 ToolError::new(format!(
261 "Failed to serialize schema for tool '{}': {e}",
262 T::NAME
263 ))
264 })?;
265 sanitize_schema_types(&mut parameter_schema);
266 Ok(ToolDefinition::with_effect(
267 T::NAME,
268 tool.description().into_owned(),
269 parameter_schema,
270 tool.effect(),
271 )
272 .with_output_sanitization(tool.output_sanitization()))
273}
274
275/// Recursively sanitize JSON Schema `"type"` fields for Go genai compatibility.
276///
277/// `schemars` emits `"type": ["string", "null"]` for `Option<String>` fields
278/// (the nullable type-array form). The Go genai SDK's `Schema.Type`
279/// is a single `genai.Type` enum, so it can't unmarshal an array.
280///
281/// This function walks the schema tree and replaces any array `type` with the
282/// first non-`"null"` element. For example:
283/// - `["string", "null"]` → `"string"`
284/// - `["integer", "null"]` → `"integer"`
285fn sanitize_schema_types(value: &mut serde_json::Value) {
286 match value {
287 serde_json::Value::Object(map) => {
288 // If "type" is an array (e.g. ["string", "null"]), pick the first
289 // non-"null" element and replace with it as a scalar type.
290 let replacement = match map.get("type") {
291 Some(serde_json::Value::Array(arr)) => {
292 let non_null = arr.iter().find(|v| v.as_str() != Some("null")).cloned();
293 non_null.or_else(|| arr.first().cloned())
294 }
295 _ => None,
296 };
297 if let Some(val) = replacement {
298 map.insert("type".to_string(), val);
299 }
300 for val in map.values_mut() {
301 sanitize_schema_types(val);
302 }
303 }
304 serde_json::Value::Array(arr) => {
305 for item in arr {
306 sanitize_schema_types(item);
307 }
308 }
309 _ => {}
310 }
311}
312
313/// Type-erased future returned when dispatching a boxed tool.
314///
315/// Produced by the crate-internal type-erasure layer that lets heterogeneous
316/// [`RustTool`] implementations share one registry.
317pub type BoxToolFuture<'a> =
318 Pin<Box<dyn Future<Output = Result<ToolOutput, ToolError>> + Send + 'a>>;
319
320/// Type-erased wrapper enabling heterogeneous tool storage.
321///
322/// Boxes the future from [`RustTool::call`] so we can store different tool
323/// types in the same `HashMap<String, Box<dyn ErasedTool>>`.
324pub(crate) trait ErasedTool: Send + Sync {
325 /// Deserialize `args` and call the handler, returning a boxed future.
326 fn call_erased<'a>(
327 &'a self,
328 args: serde_json::Value,
329 ctx: &'a ToolContext,
330 ) -> BoxToolFuture<'a>;
331
332 /// Deserialize `args_json` directly from a JSON string and call the handler,
333 /// returning a boxed future without intermediate DOM allocation.
334 fn call_erased_str<'a>(&'a self, args_json: &'a str, ctx: &'a ToolContext)
335 -> BoxToolFuture<'a>;
336}
337
338impl<T: RustTool> ErasedTool for T {
339 fn call_erased<'a>(
340 &'a self,
341 args: serde_json::Value,
342 ctx: &'a ToolContext,
343 ) -> BoxToolFuture<'a> {
344 Box::pin(async move {
345 let params: T::Params = serde_json::from_value(args).map_err(|e| {
346 ToolError::new(format!("Failed to deserialize tool parameters: {e}"))
347 })?;
348 let output = self.call(params, ctx).await?;
349 Ok(self.output_sanitization().apply(output))
350 })
351 }
352
353 fn call_erased_str<'a>(
354 &'a self,
355 args_json: &'a str,
356 ctx: &'a ToolContext,
357 ) -> BoxToolFuture<'a> {
358 Box::pin(async move {
359 let params: T::Params = serde_json::from_str(args_json).map_err(|e| {
360 ToolError::new(format!("Failed to deserialize tool parameters: {e}"))
361 })?;
362 let output = self.call(params, ctx).await?;
363 Ok(self.output_sanitization().apply(output))
364 })
365 }
366}