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            .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>); // cloneable handle
123
124pub 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    /// Whether this tool may mutate the workspace (see [`TypedTool::mutating`]).
134    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        // Note: even valid args must not reach the tool body.
236        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}