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