Skip to main content

wcode_harness/
tool.rs

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/// The run's cancel token. A tool that needs clean-up must watch this and
10    /// finish BEFORE returning (e.g. `bash` kills its process group); the loop
11    /// only backstops a tool that ignores it, racing the call and recording a
12    /// synthetic error `ToolResult` ("aborted: cancelled before the tool
13    /// returned") if the token fires first.
14    pub cancel: tokio_util::sync::CancellationToken,
15    pub events: tokio::sync::mpsc::UnboundedSender<AgentEvent>, // tool may send ToolExecutionUpdate
16    /// The current run's session transcript path, when it has one — `None` for
17    /// an in-memory session. `session_search` (`scope:"current"`) reads it to
18    /// recover turns a compaction replaced with a summary.
19    pub session_path: Option<std::path::PathBuf>,
20}
21
22#[derive(Clone, Debug, Default)]
23pub struct ToolOutput {
24    /// What the model reads — this becomes the `ToolResult` content.
25    pub output: String,
26    pub is_error: bool,
27    /// An optional unified diff, for *presentation only*: it rides the
28    /// `ToolExecutionEnd` event to the UI but never enters the model's context.
29    /// `None` for tools that do not touch files.
30    pub diff: Option<String>,
31    /// The file the tool changed, as the caller named it. Presentation only —
32    /// it rides `ToolExecutionEnd` so the UI can index the run's changes and
33    /// label each `⚙` line, but it never enters the model's context.
34    /// `None` for tools that do not touch files (or an uncommitted dry run).
35    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    /// Whether this tool may run concurrently with other calls in the same
45    /// batch. Default `false` — a tool opts in, so anything that mutates state
46    /// is a barrier unless it explicitly claims otherwise.
47    fn parallel_safe(&self) -> bool {
48        false
49    }
50
51    /// Whether this tool may mutate the workspace. Default `false` — a mutator
52    /// opts in, so plan mode's `MUTATING_TOOLS` denylist and this flag stay in
53    /// sync (a test in `wcode-cli` asserts it).
54    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
122/// Strip the JSON-Schema scaffolding only a *validator* reads: the `$schema`
123/// meta-schema URL, schemars' `title`, the `format` hints, and a `default` of
124/// `null`. Tool definitions ride **every** request, so this boilerplate is paid
125/// on each turn and says nothing an LLM caller acts on (the prose `description`s
126/// carry the meaning).
127///
128/// The distinction that matters: `title`/`format` are *keywords* in a **schema**
129/// position, but they are arbitrary **property names** inside a name map
130/// (`properties`, `patternProperties`, `dependentSchemas`, `$defs`,
131/// `definitions` — the set schemars itself treats as name maps). Only the former
132/// is boilerplate — `webfetch` really does take a `format` argument and `task`
133/// really does take a `title`, so filtering by key name alone would silently
134/// delete them from the wire schema (a bug caught in review, W006). Name-map
135/// keys are therefore never filtered; only their values are slimmed as schemas.
136///
137/// Purely mechanical — property names, `required`, `$ref`s, `enum`s and non-null
138/// `default`s all survive, and a nullable type (`["T", "null"]`) is left intact
139/// so nullability is still expressed.
140///
141/// Recursive, so nested `$defs` (e.g. `edits`'s `EditOp`, `todo`'s `TodoItem`)
142/// are slimmed too.
143fn 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                    // The keys here are names the tool author chose, not schema
150                    // keywords: never filter them. Their values are schemas.
151                    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
180/// Whether `key` introduces a map of user-chosen *names* → schemas, rather than
181/// being a schema keyword itself. Its keys must be preserved verbatim.
182fn 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>); // cloneable handle
191
192pub 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    /// Whether this tool may mutate the workspace (see [`TypedTool::mutating`]).
202    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        // Note: even valid args must not reach the tool body.
304        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                // Properties whose NAMES collide with schema keywords. These are
336                // real arguments (`webfetch.format`, `task.title`) and must
337                // survive; the inner `format` keyword below must not.
338                "format": { "type": "string", "format": "uint64" },
339                "title": { "type": "string", "title": "Title" },
340            },
341            "$defs": { "Inner": { "title": "Inner", "type": "string", "description": "kept" } },
342            // Another name map (schemars treats it as one): its keys are names too.
343            "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        // Regression (W006 review): a property *named* `format`/`title` is an
367        // argument, not boilerplate — filtering by key name alone deleted them
368        // from the wire schema.
369        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        // ...and the schema still does its job.
408        assert_eq!(parameters["type"], "object");
409        assert!(parameters["properties"].get("text").is_some());
410    }
411}