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_exec;
12pub mod code_nav;
13pub mod edit;
14pub mod exec;
15pub mod extras;
16pub mod file_search;
17pub mod fs;
18pub mod git;
19pub mod knowledge_graph;
20pub mod media;
21pub mod memory;
22pub mod search_and_web;
23pub mod stt;
24pub mod subagent;
25pub mod todo;
26pub mod tts;
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 fn manifest(&self) -> ToolManifest {
115 ToolManifest::from_metadata(self.description(), self.metadata())
116 }
117 async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult>;
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
121pub struct ToolMetadata {
122 pub name: String,
123 pub toolset: String,
124 pub risk: RiskLevel,
125 pub requires_auth: bool,
126 pub mutates_files: bool,
127 pub network: bool,
128 pub exec: bool,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
132pub struct ToolManifest {
133 pub name: String,
134 pub description: String,
135 pub toolset: String,
136 pub risk: RiskLevel,
137 pub permissions: Vec<String>,
138}
139
140impl ToolManifest {
141 pub fn from_metadata(description: &str, metadata: ToolMetadata) -> Self {
142 let mut permissions = Vec::new();
143 if metadata.requires_auth {
144 permissions.push("auth".to_string());
145 }
146 if metadata.mutates_files {
147 permissions.push("files:write".to_string());
148 }
149 if metadata.network {
150 permissions.push("network".to_string());
151 }
152 if metadata.exec {
153 permissions.push("exec".to_string());
154 }
155 if permissions.is_empty() {
156 permissions.push("read".to_string());
157 }
158 Self {
159 name: metadata.name,
160 description: description.to_string(),
161 toolset: metadata.toolset,
162 risk: metadata.risk,
163 permissions,
164 }
165 }
166}
167
168pub const TOOLSETS: &[&str] = &[
169 "safe",
170 "web",
171 "file",
172 "terminal",
173 "media",
174 "debug",
175 "skills",
176 "memory",
177 "session_search",
178 "mcp",
179 "gateway",
180];
181
182pub const KNOWN_TOOLS: &[(&str, RiskLevel)] = &[
183 ("fs_read", RiskLevel::ReadOnly),
184 ("fs_list", RiskLevel::ReadOnly),
185 ("fs_write", RiskLevel::Mutating),
186 ("edit", RiskLevel::Mutating),
187 ("multi_edit", RiskLevel::Mutating),
188 ("search", RiskLevel::Network),
189 ("web_search", RiskLevel::Network),
190 ("web_fetch", RiskLevel::Network),
191 ("browser", RiskLevel::Network),
192 ("computer", RiskLevel::Exec),
193 ("git", RiskLevel::Exec),
194 ("todo", RiskLevel::ReadOnly),
195 ("exec", RiskLevel::Exec),
196 ("image_generate", RiskLevel::Network),
197 ("text_to_speech", RiskLevel::Network),
198 ("transcribe", RiskLevel::Network),
199 ("python_rpc", RiskLevel::Exec),
200 ("lsp", RiskLevel::ReadOnly),
201 ("glob", RiskLevel::ReadOnly),
202 ("symbols", RiskLevel::ReadOnly),
203 ("memory", RiskLevel::Mutating),
204 ("knowledge_graph", RiskLevel::Mutating),
205 ("subagent_spawn", RiskLevel::Exec),
206];
207
208pub fn known_tool_metadata(surface: Option<&str>) -> Vec<ToolMetadata> {
209 KNOWN_TOOLS
210 .iter()
211 .map(|(name, risk)| metadata_for(name, risk.clone()))
212 .filter(|meta| surface.map(|s| surface_allows(s, meta)).unwrap_or(true))
213 .collect()
214}
215
216pub fn metadata_for(name: &str, risk: RiskLevel) -> ToolMetadata {
217 let lower = name.to_ascii_lowercase();
218 let toolset = if matches!(lower.as_str(), "fs_read" | "fs_list" | "glob" | "symbols") {
219 "file"
220 } else if matches!(lower.as_str(), "fs_write" | "edit" | "multi_edit") {
221 "file"
222 } else if matches!(
223 lower.as_str(),
224 "search" | "web_search" | "web_fetch" | "browser"
225 ) {
226 "web"
227 } else if lower == "computer" {
228 "terminal"
229 } else if lower == "exec" || lower == "git" {
230 "terminal"
231 } else if matches!(
232 lower.as_str(),
233 "image_gen" | "image_generate" | "tts" | "text_to_speech" | "transcribe"
234 ) {
235 "media"
236 } else if lower == "memory" || lower == "knowledge_graph" {
237 "memory"
238 } else if lower.contains("session") {
239 "session_search"
240 } else if lower == "python_rpc" {
241 "terminal"
242 } else if lower == "lsp" {
243 "debug"
244 } else if lower.contains("mcp") {
245 "mcp"
246 } else if lower.contains("subagent") {
247 "skills"
248 } else if lower == "todo" {
249 "safe"
250 } else {
251 "safe"
252 };
253 ToolMetadata {
254 name: name.to_string(),
255 toolset: toolset.to_string(),
256 requires_auth: matches!(toolset, "web" | "media" | "mcp" | "gateway"),
257 mutates_files: matches!(risk, RiskLevel::Mutating | RiskLevel::Destructive)
258 || matches!(lower.as_str(), "fs_write" | "edit" | "multi_edit"),
259 network: matches!(risk, RiskLevel::Network) || matches!(toolset, "web" | "mcp" | "gateway"),
260 exec: matches!(risk, RiskLevel::Exec) || toolset == "terminal",
261 risk,
262 }
263}
264
265pub fn surface_allows(surface: &str, metadata: &ToolMetadata) -> bool {
266 match surface.trim().to_ascii_lowercase().as_str() {
267 "gateway" => {
268 !metadata.exec
269 && !metadata.mutates_files
270 && !matches!(metadata.risk, RiskLevel::Destructive)
271 && !matches!(metadata.toolset.as_str(), "terminal" | "file")
272 }
273 "subagent" => !matches!(metadata.risk, RiskLevel::Destructive),
274 "cli" | "tui" | "webview" | "" => true,
275 _ => true,
276 }
277}
278
279pub struct ToolRegistry {
282 tools: HashMap<String, Arc<dyn Tool>>,
283}
284
285impl ToolRegistry {
286 pub fn new() -> Self {
287 Self {
288 tools: HashMap::new(),
289 }
290 }
291
292 pub fn register(&mut self, tool: Arc<dyn Tool>) {
293 self.tools.insert(tool.name().to_string(), tool);
294 }
295
296 pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
297 self.tools.get(name).cloned()
298 }
299
300 pub fn all(&self) -> Vec<Arc<dyn Tool>> {
301 self.tools.values().cloned().collect()
302 }
303
304 pub fn names(&self) -> Vec<String> {
305 self.tools.keys().cloned().collect()
306 }
307
308 pub fn metadata(&self) -> Vec<ToolMetadata> {
309 self.tools.values().map(|tool| tool.metadata()).collect()
310 }
311
312 pub fn manifests(&self) -> Vec<ToolManifest> {
313 self.tools.values().map(|tool| tool.manifest()).collect()
314 }
315
316 pub fn metadata_for_surface(&self, surface: &str) -> Vec<ToolMetadata> {
317 self.metadata()
318 .into_iter()
319 .filter(|meta| surface_allows(surface, meta))
320 .collect()
321 }
322
323 pub fn to_specs_for_surface(&self, surface: &str) -> Vec<super::provider::ToolSpec> {
324 self.tools
325 .values()
326 .filter(|tool| surface_allows(surface, &tool.metadata()))
327 .map(|t| super::provider::ToolSpec {
328 name: t.name().to_string(),
329 description: t.description().to_string(),
330 input_schema: t.schema(),
331 })
332 .collect()
333 }
334
335 pub fn to_specs_for_skill(
336 &self,
337 skill: &crate::capabilities::Skill,
338 ) -> Vec<super::provider::ToolSpec> {
339 if skill.allowed_tools.is_empty() {
340 return self.to_specs();
341 }
342 let allowed: std::collections::HashSet<String> = skill
343 .allowed_tools
344 .iter()
345 .map(|tool| tool.trim().to_ascii_lowercase())
346 .filter(|tool| !tool.is_empty())
347 .collect();
348 self.tools
349 .values()
350 .filter(|tool| allowed.contains(&tool.name().to_ascii_lowercase()))
351 .map(|t| super::provider::ToolSpec {
352 name: t.name().to_string(),
353 description: t.description().to_string(),
354 input_schema: t.schema(),
355 })
356 .collect()
357 }
358
359 pub fn to_specs(&self) -> Vec<super::provider::ToolSpec> {
360 self.tools
361 .values()
362 .map(|t| super::provider::ToolSpec {
363 name: t.name().to_string(),
364 description: t.description().to_string(),
365 input_schema: t.schema(),
366 })
367 .collect()
368 }
369}
370
371impl Default for ToolRegistry {
372 fn default() -> Self {
373 Self::new()
374 }
375}