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