1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use crate::event::{Block, RiskLevel};
8
9pub mod browser_sandbox;
10pub mod builder_tools;
11pub mod code_nav;
12pub mod edit;
13pub mod exec;
14pub mod extras;
15pub mod file_search;
16pub mod fs;
17pub mod git;
18pub mod knowledge_graph;
19pub mod media;
20pub mod memory;
21pub mod search_and_web;
22pub mod stt;
23pub mod subagent;
24pub mod todo;
25pub mod tts;
26pub mod code_exec;
27pub mod voice;
28pub mod web_search;
29
30pub struct ToolCtx {
33 pub workspace_root: std::path::PathBuf,
34 pub run_id: crate::event::RunId,
35}
36
37pub fn resolve_workspace_path(workspace_root: &Path, path: &str) -> anyhow::Result<PathBuf> {
38 let root = workspace_root
39 .canonicalize()
40 .unwrap_or_else(|_| workspace_root.to_path_buf());
41 let candidate = if Path::new(path).is_absolute() {
42 PathBuf::from(path)
43 } else {
44 root.join(path)
45 };
46
47 let check_target = if candidate.exists() {
48 candidate.canonicalize()?
49 } else {
50 let parent = candidate
51 .parent()
52 .ok_or_else(|| anyhow::anyhow!("Invalid path: {}", path))?;
53 let parent = parent
54 .canonicalize()
55 .unwrap_or_else(|_| parent.to_path_buf());
56 parent.join(
57 candidate
58 .file_name()
59 .ok_or_else(|| anyhow::anyhow!("Invalid path: {}", path))?,
60 )
61 };
62
63 if !check_target.starts_with(&root) {
64 anyhow::bail!("Path escapes workspace: {}", path);
65 }
66
67 Ok(check_target)
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ToolResult {
74 pub content: Vec<Block>,
75 pub is_error: bool,
76}
77
78impl ToolResult {
79 pub fn ok(content: Vec<Block>) -> Self {
80 Self {
81 content,
82 is_error: false,
83 }
84 }
85
86 pub fn error(msg: impl Into<String>) -> Self {
87 Self {
88 content: vec![Block::Text(msg.into())],
89 is_error: true,
90 }
91 }
92
93 pub fn text(msg: impl Into<String>) -> Self {
94 Self {
95 content: vec![Block::Text(msg.into())],
96 is_error: false,
97 }
98 }
99}
100
101#[async_trait]
106pub trait Tool: Send + Sync {
107 fn name(&self) -> &str;
108 fn description(&self) -> &str;
109 fn schema(&self) -> serde_json::Value;
110 fn risk(&self) -> RiskLevel;
111 fn metadata(&self) -> ToolMetadata {
112 metadata_for(self.name(), self.risk())
113 }
114 async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult>;
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
118pub struct ToolMetadata {
119 pub name: String,
120 pub toolset: String,
121 pub risk: RiskLevel,
122 pub requires_auth: bool,
123 pub mutates_files: bool,
124 pub network: bool,
125 pub exec: bool,
126}
127
128pub const TOOLSETS: &[&str] = &[
129 "safe",
130 "web",
131 "file",
132 "terminal",
133 "media",
134 "debug",
135 "skills",
136 "memory",
137 "session_search",
138 "mcp",
139 "gateway",
140];
141
142pub const KNOWN_TOOLS: &[(&str, RiskLevel)] = &[
143 ("fs_read", RiskLevel::ReadOnly),
144 ("fs_list", RiskLevel::ReadOnly),
145 ("fs_write", RiskLevel::Mutating),
146 ("edit", RiskLevel::Mutating),
147 ("multi_edit", RiskLevel::Mutating),
148 ("search", RiskLevel::Network),
149 ("web_search", RiskLevel::Network),
150 ("web_fetch", RiskLevel::Network),
151 ("browser", RiskLevel::Network),
152 ("computer", RiskLevel::Exec),
153 ("git", RiskLevel::Exec),
154 ("todo", RiskLevel::ReadOnly),
155 ("exec", RiskLevel::Exec),
156 ("image_generate", RiskLevel::Network),
157 ("text_to_speech", RiskLevel::Network),
158 ("transcribe", RiskLevel::Network),
159 ("python_rpc", RiskLevel::Exec),
160 ("lsp", RiskLevel::ReadOnly),
161 ("glob", RiskLevel::ReadOnly),
162 ("symbols", RiskLevel::ReadOnly),
163 ("memory", RiskLevel::Mutating),
164 ("knowledge_graph", RiskLevel::Mutating),
165 ("subagent_spawn", RiskLevel::Exec),
166];
167
168pub fn known_tool_metadata(surface: Option<&str>) -> Vec<ToolMetadata> {
169 KNOWN_TOOLS
170 .iter()
171 .map(|(name, risk)| metadata_for(name, risk.clone()))
172 .filter(|meta| surface.map(|s| surface_allows(s, meta)).unwrap_or(true))
173 .collect()
174}
175
176pub fn metadata_for(name: &str, risk: RiskLevel) -> ToolMetadata {
177 let lower = name.to_ascii_lowercase();
178 let toolset = if matches!(lower.as_str(), "fs_read" | "fs_list" | "glob" | "symbols") {
179 "file"
180 } else if matches!(lower.as_str(), "fs_write" | "edit" | "multi_edit") {
181 "file"
182 } else if matches!(
183 lower.as_str(),
184 "search" | "web_search" | "web_fetch" | "browser"
185 ) {
186 "web"
187 } else if lower == "computer" {
188 "terminal"
189 } else if lower == "exec" || lower == "git" {
190 "terminal"
191 } else if matches!(
192 lower.as_str(),
193 "image_gen" | "image_generate" | "tts" | "text_to_speech" | "transcribe"
194 ) {
195 "media"
196 } else if lower == "memory" || lower == "knowledge_graph" {
197 "memory"
198 } else if lower.contains("session") {
199 "session_search"
200 } else if lower == "python_rpc" {
201 "terminal"
202 } else if lower == "lsp" {
203 "debug"
204 } else if lower.contains("mcp") {
205 "mcp"
206 } else if lower.contains("subagent") {
207 "skills"
208 } else if lower == "todo" {
209 "safe"
210 } else {
211 "safe"
212 };
213 ToolMetadata {
214 name: name.to_string(),
215 toolset: toolset.to_string(),
216 requires_auth: matches!(toolset, "web" | "media" | "mcp" | "gateway"),
217 mutates_files: matches!(risk, RiskLevel::Mutating | RiskLevel::Destructive)
218 || matches!(lower.as_str(), "fs_write" | "edit" | "multi_edit"),
219 network: matches!(risk, RiskLevel::Network) || matches!(toolset, "web" | "mcp" | "gateway"),
220 exec: matches!(risk, RiskLevel::Exec) || toolset == "terminal",
221 risk,
222 }
223}
224
225pub fn surface_allows(surface: &str, metadata: &ToolMetadata) -> bool {
226 match surface.trim().to_ascii_lowercase().as_str() {
227 "gateway" => {
228 !metadata.exec
229 && !metadata.mutates_files
230 && !matches!(metadata.risk, RiskLevel::Destructive)
231 && !matches!(metadata.toolset.as_str(), "terminal" | "file")
232 }
233 "subagent" => !matches!(metadata.risk, RiskLevel::Destructive),
234 "cli" | "tui" | "webview" | "" => true,
235 _ => true,
236 }
237}
238
239pub struct ToolRegistry {
242 tools: HashMap<String, Arc<dyn Tool>>,
243}
244
245impl ToolRegistry {
246 pub fn new() -> Self {
247 Self {
248 tools: HashMap::new(),
249 }
250 }
251
252 pub fn register(&mut self, tool: Arc<dyn Tool>) {
253 self.tools.insert(tool.name().to_string(), tool);
254 }
255
256 pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
257 self.tools.get(name).cloned()
258 }
259
260 pub fn all(&self) -> Vec<Arc<dyn Tool>> {
261 self.tools.values().cloned().collect()
262 }
263
264 pub fn names(&self) -> Vec<String> {
265 self.tools.keys().cloned().collect()
266 }
267
268 pub fn metadata(&self) -> Vec<ToolMetadata> {
269 self.tools.values().map(|tool| tool.metadata()).collect()
270 }
271
272 pub fn metadata_for_surface(&self, surface: &str) -> Vec<ToolMetadata> {
273 self.metadata()
274 .into_iter()
275 .filter(|meta| surface_allows(surface, meta))
276 .collect()
277 }
278
279 pub fn to_specs_for_surface(&self, surface: &str) -> Vec<super::provider::ToolSpec> {
280 self.tools
281 .values()
282 .filter(|tool| surface_allows(surface, &tool.metadata()))
283 .map(|t| super::provider::ToolSpec {
284 name: t.name().to_string(),
285 description: t.description().to_string(),
286 input_schema: t.schema(),
287 })
288 .collect()
289 }
290
291 pub fn to_specs(&self) -> Vec<super::provider::ToolSpec> {
292 self.tools
293 .values()
294 .map(|t| super::provider::ToolSpec {
295 name: t.name().to_string(),
296 description: t.description().to_string(),
297 input_schema: t.schema(),
298 })
299 .collect()
300 }
301}
302
303impl Default for ToolRegistry {
304 fn default() -> Self {
305 Self::new()
306 }
307}