Skip to main content

sparrow/tools/
mod.rs

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
30// ─── Tool context ───────────────────────────────────────────────────────────────
31
32pub 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// ─── Tool result ────────────────────────────────────────────────────────────────
71
72#[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// ─── THE TOOL TRAIT ─────────────────────────────────────────────────────────────
102
103/// What an agent can do. Every tool declares a JSON schema and a risk level
104/// used by the autonomy gate.
105#[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
239// ─── Tool registry (ToolSet) ────────────────────────────────────────────────────
240
241pub 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}