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