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    pub cancel: tokio_util::sync::CancellationToken,
10    pub events: tokio::sync::mpsc::UnboundedSender<AgentEvent>, // tool may send ToolExecutionUpdate
11    /// The current run's session transcript path, when it has one — `None` for
12    /// an in-memory session. `session_search` (`scope:"current"`) reads it to
13    /// recover turns a compaction replaced with a summary.
14    pub session_path: Option<std::path::PathBuf>,
15}
16
17#[derive(Clone, Debug, Default)]
18pub struct ToolOutput {
19    /// What the model reads — this becomes the `ToolResult` content.
20    pub output: String,
21    pub is_error: bool,
22    /// An optional unified diff, for *presentation only*: it rides the
23    /// `ToolExecutionEnd` event to the UI but never enters the model's context.
24    /// `None` for tools that do not touch files.
25    pub diff: Option<String>,
26    /// The file the tool changed, as the caller named it. Presentation only —
27    /// it rides `ToolExecutionEnd` so the UI can index the run's changes and
28    /// label each `⚙` line, but it never enters the model's context.
29    /// `None` for tools that do not touch files (or an uncommitted dry run).
30    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    /// Whether this tool may run concurrently with other calls in the same
40    /// batch. Default `false` — a tool opts in, so anything that mutates state
41    /// is a barrier unless it explicitly claims otherwise.
42    fn parallel_safe(&self) -> bool {
43        false
44    }
45
46    /// Whether this tool may mutate the workspace. Default `false` — a mutator
47    /// opts in, so plan mode's `MUTATING_TOOLS` denylist and this flag stay in
48    /// sync (a test in `wcode-cli` asserts it).
49    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>); // cloneable handle
118
119pub 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    /// Whether this tool may mutate the workspace (see [`TypedTool::mutating`]).
129    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        // Note: even valid args must not reach the tool body.
231        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}