1use std::sync::Arc;
2
3use crate::event::AgentEvent;
4
5pub struct ToolContext {
6 pub call_id: String,
7 pub name: String,
8 pub working_dir: std::path::PathBuf,
9 pub cancel: tokio_util::sync::CancellationToken,
10 pub events: tokio::sync::mpsc::UnboundedSender<AgentEvent>, pub session_path: Option<std::path::PathBuf>,
15}
16
17#[derive(Clone, Debug, Default)]
18pub struct ToolOutput {
19 pub output: String,
21 pub is_error: bool,
22 pub diff: Option<String>,
26 pub path: Option<String>,
31}
32
33#[async_trait::async_trait]
34pub trait TypedTool: Send + Sync + 'static {
35 type Args: serde::de::DeserializeOwned + schemars::JsonSchema;
36 fn name(&self) -> &str;
37 fn description(&self) -> &str;
38
39 fn parallel_safe(&self) -> bool {
43 false
44 }
45
46 fn mutating(&self) -> bool {
50 false
51 }
52
53 async fn execute(&self, args: Self::Args, ctx: &ToolContext) -> ToolOutput;
54}
55
56#[async_trait::async_trait]
57trait ErasedToolCore: Send + Sync {
58 fn name(&self) -> &str;
59 fn description(&self) -> &str;
60 fn parameters(&self) -> serde_json::Value;
61 fn parallel_safe(&self) -> bool;
62 fn mutating(&self) -> bool;
63 async fn execute(&self, args: serde_json::Value, ctx: ToolContext) -> ToolOutput;
64}
65
66#[async_trait::async_trait]
67impl<T: TypedTool> ErasedToolCore for T {
68 fn name(&self) -> &str {
69 TypedTool::name(self)
70 }
71
72 fn description(&self) -> &str {
73 TypedTool::description(self)
74 }
75
76 fn parameters(&self) -> serde_json::Value {
77 serde_json::to_value(schemars::schema_for!(T::Args))
78 .unwrap_or_else(|_| serde_json::json!({ "type": "object" }))
79 }
80
81 fn parallel_safe(&self) -> bool {
82 TypedTool::parallel_safe(self)
83 }
84
85 fn mutating(&self) -> bool {
86 TypedTool::mutating(self)
87 }
88
89 async fn execute(&self, args: serde_json::Value, ctx: ToolContext) -> ToolOutput {
90 if ctx.cancel.is_cancelled() {
91 return ToolOutput {
92 output: "cancelled".to_string(),
93 is_error: true,
94 diff: None,
95 path: None,
96 };
97 }
98 let parsed: T::Args = match serde_json::from_value(args) {
99 Ok(args) => args,
100 Err(e) => {
101 return ToolOutput {
102 output: format!(
103 "invalid arguments for tool `{}`: {e}",
104 TypedTool::name(self)
105 ),
106 is_error: true,
107 diff: None,
108 path: None,
109 };
110 }
111 };
112 TypedTool::execute(self, parsed, &ctx).await
113 }
114}
115
116#[derive(Clone)]
117pub struct Tool(Arc<dyn ErasedToolCore>); pub fn erased<T: TypedTool>(t: T) -> Tool {
120 Tool(Arc::new(t))
121}
122
123impl Tool {
124 pub fn parallel_safe(&self) -> bool {
125 self.0.parallel_safe()
126 }
127
128 pub fn mutating(&self) -> bool {
130 self.0.mutating()
131 }
132
133 pub fn name(&self) -> &str {
134 self.0.name()
135 }
136
137 pub fn definition(&self) -> rig::completion::ToolDefinition {
138 rig::completion::ToolDefinition {
139 name: self.name().to_string(),
140 description: self.0.description().to_string(),
141 parameters: self.0.parameters(),
142 }
143 }
144
145 pub async fn execute(&self, args: serde_json::Value, ctx: ToolContext) -> ToolOutput {
146 self.0.execute(args, ctx).await
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use serde::Deserialize;
154 use tokio::sync::mpsc;
155
156 #[derive(Deserialize, schemars::JsonSchema)]
157 struct EchoArgs {
158 text: String,
159 }
160
161 #[derive(Clone)]
162 struct Echo;
163
164 #[async_trait::async_trait]
165 impl TypedTool for Echo {
166 type Args = EchoArgs;
167 fn name(&self) -> &str {
168 "echo"
169 }
170 fn description(&self) -> &str {
171 "repeats text back"
172 }
173 async fn execute(&self, args: Self::Args, _ctx: &ToolContext) -> ToolOutput {
174 ToolOutput {
175 output: format!("echo:{}", args.text),
176 is_error: false,
177 diff: None,
178 path: None,
179 }
180 }
181 }
182
183 fn test_ctx(
184 cancel: tokio_util::sync::CancellationToken,
185 ) -> (ToolContext, mpsc::UnboundedReceiver<AgentEvent>) {
186 let (tx, rx) = mpsc::unbounded_channel();
187 (
188 ToolContext {
189 call_id: "c1".to_string(),
190 name: "echo".to_string(),
191 working_dir: std::env::temp_dir(),
192 cancel,
193 events: tx,
194 session_path: None,
195 },
196 rx,
197 )
198 }
199
200 #[tokio::test]
201 async fn executes_with_typed_args() {
202 let tool = erased(Echo);
203 let (ctx, _rx) = test_ctx(tokio_util::sync::CancellationToken::new());
204 let out = tool
205 .execute(serde_json::json!({ "text": "hello" }), ctx)
206 .await;
207 assert!(!out.is_error);
208 assert_eq!(out.output, "echo:hello");
209 }
210
211 #[tokio::test]
212 async fn garbage_args_yield_error_output_without_panic() {
213 let tool = erased(Echo);
214 let (ctx, _rx) = test_ctx(tokio_util::sync::CancellationToken::new());
215 let out = tool.execute(serde_json::json!({ "wrong": 42 }), ctx).await;
216 assert!(out.is_error);
217 assert!(
218 out.output.starts_with("invalid arguments for tool `echo`"),
219 "unexpected output: {}",
220 out.output
221 );
222 }
223
224 #[tokio::test]
225 async fn cancelled_before_run_returns_cancelled_without_running() {
226 let tool = erased(Echo);
227 let cancel = tokio_util::sync::CancellationToken::new();
228 cancel.cancel();
229 let (ctx, _rx) = test_ctx(cancel);
230 let out = tool
232 .execute(serde_json::json!({ "text": "hello" }), ctx)
233 .await;
234 assert!(out.is_error);
235 assert_eq!(out.output, "cancelled");
236 }
237
238 #[test]
239 fn definition_parameters_schema_contains_property_names() {
240 let tool = erased(Echo);
241 let def = tool.definition();
242 assert_eq!(def.name, "echo");
243 assert_eq!(def.description, "repeats text back");
244 assert_eq!(def.parameters["type"], "object");
245 assert!(
246 def.parameters["properties"].get("text").is_some(),
247 "schema must expose `text` property, got: {}",
248 def.parameters
249 );
250 }
251}