Skip to main content

opendev_tools_impl/
custom_tool.rs

1//! Custom tool loaded from `.opendev/tools/` directory.
2//!
3//! Users can define custom tools by placing a JSON manifest file alongside
4//! an executable script in `.opendev/tools/` (or `.opencode/tool/`).
5//!
6//! ## Manifest format (`<name>.tool.json`)
7//!
8//! ```json
9//! {
10//!   "name": "github_triage",
11//!   "description": "Assign and label GitHub issues",
12//!   "command": "./github-triage.sh",
13//!   "parameters": {
14//!     "type": "object",
15//!     "properties": {
16//!       "issue": { "type": "string", "description": "Issue number" }
17//!     },
18//!     "required": ["issue"]
19//!   },
20//!   "timeout_secs": 30
21//! }
22//! ```
23//!
24//! The tool receives arguments as JSON on stdin and should write its
25//! result to stdout. Exit code 0 = success, non-zero = failure.
26
27use std::collections::HashMap;
28use std::path::{Path, PathBuf};
29
30use async_trait::async_trait;
31use serde::Deserialize;
32use tracing::{debug, warn};
33
34use opendev_tools_core::{BaseTool, ToolContext, ToolResult};
35
36/// JSON manifest describing a custom tool.
37#[derive(Debug, Clone, Deserialize)]
38pub struct CustomToolManifest {
39    /// Tool name (used for dispatch). Must be unique.
40    pub name: String,
41    /// Human-readable description shown to the LLM.
42    pub description: String,
43    /// Command to execute (relative to the manifest directory, or absolute).
44    pub command: String,
45    /// JSON Schema for tool parameters.
46    #[serde(default = "default_params_schema")]
47    pub parameters: serde_json::Value,
48    /// Optional timeout in seconds (default: 30).
49    #[serde(default = "default_timeout")]
50    pub timeout_secs: u64,
51}
52
53fn default_params_schema() -> serde_json::Value {
54    serde_json::json!({
55        "type": "object",
56        "properties": {
57            "input": {
58                "type": "string",
59                "description": "Input to the tool"
60            }
61        }
62    })
63}
64
65fn default_timeout() -> u64 {
66    30
67}
68
69/// A tool backed by an external script/executable.
70#[derive(Debug)]
71pub struct CustomTool {
72    manifest: CustomToolManifest,
73    /// Directory containing the manifest (for resolving relative command paths).
74    base_dir: PathBuf,
75}
76
77impl CustomTool {
78    /// Create a custom tool from a manifest and its containing directory.
79    pub fn new(manifest: CustomToolManifest, base_dir: PathBuf) -> Self {
80        Self { manifest, base_dir }
81    }
82
83    /// Resolve the command path (relative to base_dir if not absolute).
84    fn resolve_command(&self) -> PathBuf {
85        let cmd = Path::new(&self.manifest.command);
86        if cmd.is_absolute() {
87            cmd.to_path_buf()
88        } else {
89            self.base_dir.join(cmd)
90        }
91    }
92}
93
94#[async_trait]
95impl BaseTool for CustomTool {
96    fn name(&self) -> &str {
97        &self.manifest.name
98    }
99
100    fn description(&self) -> &str {
101        &self.manifest.description
102    }
103
104    fn parameter_schema(&self) -> serde_json::Value {
105        self.manifest.parameters.clone()
106    }
107
108    async fn execute(
109        &self,
110        args: HashMap<String, serde_json::Value>,
111        ctx: &ToolContext,
112    ) -> ToolResult {
113        let cmd_path = self.resolve_command();
114
115        if !cmd_path.exists() {
116            return ToolResult::fail(format!(
117                "Custom tool command not found: {}",
118                cmd_path.display()
119            ));
120        }
121
122        // Serialize args as JSON for stdin.
123        let input_json = match serde_json::to_string(&args) {
124            Ok(j) => j,
125            Err(e) => return ToolResult::fail(format!("Failed to serialize args: {e}")),
126        };
127
128        // Execute the command.
129        let timeout = std::time::Duration::from_secs(self.manifest.timeout_secs);
130        let result = tokio::time::timeout(timeout, async {
131            let mut child = match tokio::process::Command::new(cmd_path.as_os_str())
132                .current_dir(&ctx.working_dir)
133                .stdin(std::process::Stdio::piped())
134                .stdout(std::process::Stdio::piped())
135                .stderr(std::process::Stdio::piped())
136                .spawn()
137            {
138                Ok(c) => c,
139                Err(e) => return Err(e),
140            };
141
142            // Write input to stdin.
143            if let Some(mut stdin) = child.stdin.take() {
144                use tokio::io::AsyncWriteExt;
145                let _ = stdin.write_all(input_json.as_bytes()).await;
146                drop(stdin);
147            }
148
149            child.wait_with_output().await
150        })
151        .await;
152
153        match result {
154            Ok(Ok(output)) => {
155                let stdout = String::from_utf8_lossy(&output.stdout).to_string();
156                let stderr = String::from_utf8_lossy(&output.stderr).to_string();
157
158                if output.status.success() {
159                    debug!(
160                        tool = self.manifest.name,
161                        exit_code = 0,
162                        "Custom tool executed successfully"
163                    );
164                    if stdout.is_empty() {
165                        ToolResult::ok("(no output)")
166                    } else {
167                        ToolResult::ok(stdout)
168                    }
169                } else {
170                    let code = output.status.code().unwrap_or(-1);
171                    let error_msg = if stderr.is_empty() {
172                        format!("Custom tool exited with code {code}")
173                    } else {
174                        format!("Exit code {code}: {stderr}")
175                    };
176                    ToolResult::fail(error_msg)
177                }
178            }
179            Ok(Err(e)) => ToolResult::fail(format!("Failed to execute custom tool: {e}")),
180            Err(_) => ToolResult::fail(format!(
181                "Custom tool timed out after {}s",
182                self.manifest.timeout_secs
183            )),
184        }
185    }
186}
187
188/// Discover custom tools from standard directories.
189///
190/// Scans these directories for `*.tool.json` manifest files:
191/// - `<working_dir>/.opendev/tools/`
192/// - `<working_dir>/.opencode/tool/`
193///
194/// Returns a list of `(manifest, base_dir)` tuples for each valid tool found.
195pub fn discover_custom_tools(working_dir: &Path) -> Vec<CustomTool> {
196    let search_dirs = [
197        working_dir.join(".opendev").join("tools"),
198        working_dir.join(".opencode").join("tool"),
199    ];
200
201    let mut tools = Vec::new();
202    let mut seen_names = std::collections::HashSet::new();
203
204    for dir in &search_dirs {
205        if !dir.is_dir() {
206            continue;
207        }
208
209        let entries = match std::fs::read_dir(dir) {
210            Ok(e) => e,
211            Err(e) => {
212                warn!(dir = %dir.display(), error = %e, "Failed to read custom tools directory");
213                continue;
214            }
215        };
216
217        for entry in entries.flatten() {
218            let path = entry.path();
219
220            // Only process *.tool.json manifests.
221            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
222            if !name.ends_with(".tool.json") {
223                continue;
224            }
225
226            match std::fs::read_to_string(&path) {
227                Ok(content) => match serde_json::from_str::<CustomToolManifest>(&content) {
228                    Ok(manifest) => {
229                        if seen_names.contains(&manifest.name) {
230                            warn!(
231                                name = manifest.name,
232                                path = %path.display(),
233                                "Duplicate custom tool name, skipping"
234                            );
235                            continue;
236                        }
237                        debug!(
238                            name = manifest.name,
239                            path = %path.display(),
240                            "Discovered custom tool"
241                        );
242                        seen_names.insert(manifest.name.clone());
243                        tools.push(CustomTool::new(manifest, dir.clone()));
244                    }
245                    Err(e) => {
246                        warn!(
247                            path = %path.display(),
248                            error = %e,
249                            "Failed to parse custom tool manifest"
250                        );
251                    }
252                },
253                Err(e) => {
254                    warn!(
255                        path = %path.display(),
256                        error = %e,
257                        "Failed to read custom tool manifest"
258                    );
259                }
260            }
261        }
262    }
263
264    tools
265}
266
267#[cfg(test)]
268#[path = "custom_tool_tests.rs"]
269mod tests;