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 .map(|schema| slim_schema(&schema))
84 .unwrap_or_else(|_| serde_json::json!({ "type": "object" }))
85 }
86
87 fn parallel_safe(&self) -> bool {
88 TypedTool::parallel_safe(self)
89 }
90
91 fn mutating(&self) -> bool {
92 TypedTool::mutating(self)
93 }
94
95 async fn execute(&self, args: serde_json::Value, ctx: ToolContext) -> ToolOutput {
96 if ctx.cancel.is_cancelled() {
97 return ToolOutput {
98 output: "cancelled".to_string(),
99 is_error: true,
100 diff: None,
101 path: None,
102 };
103 }
104 let parsed: T::Args = match serde_json::from_value(args) {
105 Ok(args) => args,
106 Err(e) => {
107 return ToolOutput {
108 output: format!(
109 "invalid arguments for tool `{}`: {e}",
110 TypedTool::name(self)
111 ),
112 is_error: true,
113 diff: None,
114 path: None,
115 };
116 }
117 };
118 TypedTool::execute(self, parsed, &ctx).await
119 }
120}
121
122fn slim_schema(v: &serde_json::Value) -> serde_json::Value {
144 match v {
145 serde_json::Value::Object(map) => {
146 let mut out = serde_json::Map::with_capacity(map.len());
147 for (k, val) in map {
148 if is_name_map(k) {
149 let inner = match val {
152 serde_json::Value::Object(names) => serde_json::Value::Object(
153 names
154 .iter()
155 .map(|(name, schema)| (name.clone(), slim_schema(schema)))
156 .collect(),
157 ),
158 other => slim_schema(other),
159 };
160 out.insert(k.clone(), inner);
161 continue;
162 }
163 let boilerplate = k == "$schema"
164 || k == "title"
165 || k == "format"
166 || (k == "default" && val.is_null());
167 if !boilerplate {
168 out.insert(k.clone(), slim_schema(val));
169 }
170 }
171 serde_json::Value::Object(out)
172 }
173 serde_json::Value::Array(items) => {
174 serde_json::Value::Array(items.iter().map(slim_schema).collect())
175 }
176 other => other.clone(),
177 }
178}
179
180fn is_name_map(key: &str) -> bool {
183 matches!(
184 key,
185 "properties" | "patternProperties" | "dependentSchemas" | "$defs" | "definitions"
186 )
187}
188
189#[derive(Clone)]
190pub struct Tool(Arc<dyn ErasedToolCore>); pub fn erased<T: TypedTool>(t: T) -> Tool {
193 Tool(Arc::new(t))
194}
195
196impl Tool {
197 pub fn parallel_safe(&self) -> bool {
198 self.0.parallel_safe()
199 }
200
201 pub fn mutating(&self) -> bool {
203 self.0.mutating()
204 }
205
206 pub fn name(&self) -> &str {
207 self.0.name()
208 }
209
210 pub fn definition(&self) -> rig::completion::ToolDefinition {
211 rig::completion::ToolDefinition {
212 name: self.name().to_string(),
213 description: self.0.description().to_string(),
214 parameters: self.0.parameters(),
215 }
216 }
217
218 pub async fn execute(&self, args: serde_json::Value, ctx: ToolContext) -> ToolOutput {
219 self.0.execute(args, ctx).await
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use serde::Deserialize;
227 use tokio::sync::mpsc;
228
229 #[derive(Deserialize, schemars::JsonSchema)]
230 struct EchoArgs {
231 text: String,
232 }
233
234 #[derive(Clone)]
235 struct Echo;
236
237 #[async_trait::async_trait]
238 impl TypedTool for Echo {
239 type Args = EchoArgs;
240 fn name(&self) -> &str {
241 "echo"
242 }
243 fn description(&self) -> &str {
244 "repeats text back"
245 }
246 async fn execute(&self, args: Self::Args, _ctx: &ToolContext) -> ToolOutput {
247 ToolOutput {
248 output: format!("echo:{}", args.text),
249 is_error: false,
250 diff: None,
251 path: None,
252 }
253 }
254 }
255
256 fn test_ctx(
257 cancel: tokio_util::sync::CancellationToken,
258 ) -> (ToolContext, mpsc::UnboundedReceiver<AgentEvent>) {
259 let (tx, rx) = mpsc::unbounded_channel();
260 (
261 ToolContext {
262 call_id: "c1".to_string(),
263 name: "echo".to_string(),
264 working_dir: std::env::temp_dir(),
265 cancel,
266 events: tx,
267 session_path: None,
268 },
269 rx,
270 )
271 }
272
273 #[tokio::test]
274 async fn executes_with_typed_args() {
275 let tool = erased(Echo);
276 let (ctx, _rx) = test_ctx(tokio_util::sync::CancellationToken::new());
277 let out = tool
278 .execute(serde_json::json!({ "text": "hello" }), ctx)
279 .await;
280 assert!(!out.is_error);
281 assert_eq!(out.output, "echo:hello");
282 }
283
284 #[tokio::test]
285 async fn garbage_args_yield_error_output_without_panic() {
286 let tool = erased(Echo);
287 let (ctx, _rx) = test_ctx(tokio_util::sync::CancellationToken::new());
288 let out = tool.execute(serde_json::json!({ "wrong": 42 }), ctx).await;
289 assert!(out.is_error);
290 assert!(
291 out.output.starts_with("invalid arguments for tool `echo`"),
292 "unexpected output: {}",
293 out.output
294 );
295 }
296
297 #[tokio::test]
298 async fn cancelled_before_run_returns_cancelled_without_running() {
299 let tool = erased(Echo);
300 let cancel = tokio_util::sync::CancellationToken::new();
301 cancel.cancel();
302 let (ctx, _rx) = test_ctx(cancel);
303 let out = tool
305 .execute(serde_json::json!({ "text": "hello" }), ctx)
306 .await;
307 assert!(out.is_error);
308 assert_eq!(out.output, "cancelled");
309 }
310
311 #[test]
312 fn definition_parameters_schema_contains_property_names() {
313 let tool = erased(Echo);
314 let def = tool.definition();
315 assert_eq!(def.name, "echo");
316 assert_eq!(def.description, "repeats text back");
317 assert_eq!(def.parameters["type"], "object");
318 assert!(
319 def.parameters["properties"].get("text").is_some(),
320 "schema must expose `text` property, got: {}",
321 def.parameters
322 );
323 }
324
325 #[test]
326 fn slim_schema_drops_boilerplate_but_keeps_semantics() {
327 let raw = serde_json::json!({
328 "$schema": "https://json-schema.org/draft/2020-12/schema",
329 "title": "XArgs",
330 "type": "object",
331 "properties": {
332 "n": { "type": ["integer", "null"], "format": "uint64", "minimum": 0, "default": null },
333 "flag": { "type": "boolean", "default": false },
334 "nested": { "$ref": "#/$defs/Inner" },
335 "format": { "type": "string", "format": "uint64" },
339 "title": { "type": "string", "title": "Title" },
340 },
341 "$defs": { "Inner": { "title": "Inner", "type": "string", "description": "kept" } },
342 "dependentSchemas": { "title": { "type": "object" } },
344 "required": ["n"],
345 });
346 let slim = slim_schema(&raw);
347
348 assert!(slim.get("$schema").is_none(), "$schema is validator-only");
349 assert!(slim.get("title").is_none(), "title is noise");
350 assert_eq!(
351 slim["properties"]["n"]["type"],
352 serde_json::json!(["integer", "null"]),
353 "nullability must survive (Phase 1 does not collapse it)"
354 );
355 assert!(slim["properties"]["n"].get("format").is_none());
356 assert!(
357 slim["properties"]["n"].get("default").is_none(),
358 "a null default is dropped"
359 );
360 assert_eq!(
361 slim["properties"]["flag"]["default"],
362 serde_json::json!(false),
363 "a non-null default is meaningful and must survive"
364 );
365 assert_eq!(slim["properties"]["nested"]["$ref"], "#/$defs/Inner");
366 assert_eq!(
370 slim["properties"]["format"]["type"], "string",
371 "a property named `format` must survive"
372 );
373 assert!(
374 slim["properties"]["format"].get("format").is_none(),
375 "...while the `format` KEYWORD inside it is still dropped"
376 );
377 assert_eq!(
378 slim["properties"]["title"]["type"], "string",
379 "a property named `title` must survive"
380 );
381 assert!(
382 slim["properties"]["title"].get("title").is_none(),
383 "...while the `title` KEYWORD inside it is still dropped"
384 );
385 assert_eq!(
386 slim["dependentSchemas"]["title"]["type"], "object",
387 "every name map in `is_name_map` keeps its keys — including dependentSchemas"
388 );
389 assert_eq!(slim["required"], serde_json::json!(["n"]));
390 assert_eq!(
391 slim["$defs"]["Inner"]["description"], "kept",
392 "recursion must preserve prose inside $defs"
393 );
394 assert!(
395 slim["$defs"]["Inner"].get("title").is_none(),
396 "recursion must reach $defs"
397 );
398 }
399
400 #[test]
401 fn real_tool_definitions_are_slim() {
402 let parameters = erased(Echo).definition().parameters;
403 let text = parameters.to_string();
404 for gone in ["$schema", "\"title\"", "\"format\""] {
405 assert!(!text.contains(gone), "`{gone}` leaked: {text}");
406 }
407 assert_eq!(parameters["type"], "object");
409 assert!(parameters["properties"].get("text").is_some());
410 }
411}