ic_rig/tool.rs
1//! Tool trait and type-erased tool dispatch.
2//!
3//! A [`Tool`] is a typed async function the model can invoke. Define the schema
4//! via [`ToolDefinition`] and implement [`Tool::call`] with your logic.
5//!
6//! [`ToolSet`] holds a collection of type-erased tools keyed by name. The
7//! [`Agent`](crate::agent::Agent) uses it to dispatch model-requested tool calls.
8//!
9//! # Example
10//!
11//! ```rust
12//! use irig::tool::{Tool, ToolDefinition, ToolSet};
13//! use serde::Deserialize;
14//! use serde_json::json;
15//!
16//! #[derive(Deserialize)]
17//! struct AddArgs { x: f64, y: f64 }
18//!
19//! struct Adder;
20//!
21//! impl Tool for Adder {
22//! const NAME: &'static str = "add";
23//! type Error = std::convert::Infallible;
24//! type Args = AddArgs;
25//! type Output = f64;
26//!
27//! fn definition(&self) -> ToolDefinition {
28//! ToolDefinition {
29//! name: "add".into(),
30//! description: "Add two numbers.".into(),
31//! parameters: json!({
32//! "type": "object",
33//! "properties": {
34//! "x": { "type": "number" },
35//! "y": { "type": "number" }
36//! },
37//! "required": ["x", "y"]
38//! }),
39//! }
40//! }
41//!
42//! async fn call(&self, args: AddArgs) -> Result<f64, std::convert::Infallible> {
43//! Ok(args.x + args.y)
44//! }
45//! }
46//!
47//! let mut tools = ToolSet::new();
48//! tools.add(Adder);
49//! ```
50
51use crate::wasm_compat::BoxFuture;
52use serde::{Serialize, de::DeserializeOwned};
53use std::collections::HashMap;
54use thiserror::Error;
55
56// ── ToolDefinition ────────────────────────────────────────────────────────────
57
58/// Describes a tool to the LLM.
59///
60/// The `parameters` field must be a valid JSON Schema object describing the
61/// arguments the model should provide when calling this tool.
62#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
63pub struct ToolDefinition {
64 pub name: String,
65 pub description: String,
66 /// JSON Schema for the tool's arguments.
67 pub parameters: serde_json::Value,
68}
69
70// ── Tool trait ────────────────────────────────────────────────────────────────
71
72/// A typed, async tool that an [`Agent`](crate::agent::Agent) can call.
73///
74/// Implement this trait to create tools. Register them with a [`ToolSet`].
75pub trait Tool {
76 /// Must match `ToolDefinition::name` exactly.
77 const NAME: &'static str;
78
79 /// Error type returned if the tool fails.
80 type Error: std::error::Error + 'static;
81
82 /// Deserialised argument type. Must match the `parameters` schema.
83 type Args: DeserializeOwned;
84
85 /// Serialisable output type.
86 type Output: Serialize;
87
88 /// Schema + description sent to the model before each completion call.
89 fn definition(&self) -> ToolDefinition;
90
91 /// Execute the tool with the deserialised arguments.
92 fn call(&self, args: Self::Args) -> impl std::future::Future<Output = Result<Self::Output, Self::Error>>;
93}
94
95// ── ToolError ─────────────────────────────────────────────────────────────────
96
97/// Errors that can occur when dispatching a tool call.
98#[derive(Debug, Error)]
99pub enum ToolError {
100 /// The model requested a tool that is not registered.
101 #[error("Unknown tool: {0}")]
102 NotFound(String),
103
104 /// The model-supplied arguments could not be deserialised.
105 #[error("Failed to deserialise tool arguments: {0}")]
106 ArgsParse(#[from] serde_json::Error),
107
108 /// The tool itself returned an error.
109 #[error("Tool execution failed: {0}")]
110 Execution(String),
111}
112
113// ── Type-erased tool ──────────────────────────────────────────────────────────
114
115/// Object-safe wrapper around a concrete [`Tool`].
116///
117/// You don't need to use this directly — [`ToolSet::add`] handles it.
118trait ErasedTool {
119 fn definition(&self) -> ToolDefinition;
120
121 fn call<'a>(
122 &'a self,
123 args: serde_json::Value,
124 ) -> BoxFuture<'a, Result<serde_json::Value, ToolError>>;
125}
126
127/// Concrete wrapper that bridges `Tool` to `ErasedTool`.
128struct ToolWrapper<T>(T);
129
130impl<T: Tool + 'static> ErasedTool for ToolWrapper<T> {
131 fn definition(&self) -> ToolDefinition {
132 self.0.definition()
133 }
134
135 fn call<'a>(
136 &'a self,
137 raw: serde_json::Value,
138 ) -> BoxFuture<'a, Result<serde_json::Value, ToolError>> {
139 Box::pin(async move {
140 let args: T::Args =
141 serde_json::from_value(raw).map_err(ToolError::ArgsParse)?;
142 let output = self
143 .0
144 .call(args)
145 .await
146 .map_err(|e| ToolError::Execution(e.to_string()))?;
147 serde_json::to_value(output).map_err(ToolError::ArgsParse)
148 })
149 }
150}
151
152// ── ToolSet ───────────────────────────────────────────────────────────────────
153
154/// A collection of tools keyed by name.
155///
156/// Register tools with [`add`](ToolSet::add), then pass the set to
157/// [`AgentBuilder::tool`](crate::agent::AgentBuilder::tool).
158type BoxedTool = Box<dyn ErasedTool>;
159
160pub struct ToolSet {
161 tools: HashMap<String, BoxedTool>,
162}
163
164impl ToolSet {
165 pub fn new() -> Self {
166 Self { tools: HashMap::new() }
167 }
168
169 /// Register a tool. `T::NAME` is used as the lookup key.
170 pub fn add<T: Tool + 'static>(&mut self, tool: T) -> &mut Self {
171 self.tools.insert(T::NAME.to_owned(), Box::new(ToolWrapper(tool)));
172 self
173 }
174
175 /// Schema list to include in each [`CompletionRequest`](crate::completion::CompletionRequest).
176 pub fn definitions(&self) -> Vec<ToolDefinition> {
177 self.tools.values().map(|t| t.definition()).collect()
178 }
179
180 /// Dispatch a tool call by name.
181 pub async fn call(
182 &self,
183 name: &str,
184 args: serde_json::Value,
185 ) -> Result<serde_json::Value, ToolError> {
186 let tool = self.tools.get(name).ok_or_else(|| ToolError::NotFound(name.to_owned()))?;
187 tool.call(args).await
188 }
189
190 pub fn is_empty(&self) -> bool {
191 self.tools.is_empty()
192 }
193}
194
195impl Default for ToolSet {
196 fn default() -> Self {
197 Self::new()
198 }
199}