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_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
28// ─── Tool context ───────────────────────────────────────────────────────────────
29
30pub 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// ─── Tool result ────────────────────────────────────────────────────────────────
69
70#[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// ─── THE TOOL TRAIT ─────────────────────────────────────────────────────────────
100
101/// What an agent can do. Every tool declares a JSON schema and a risk level
102/// used by the autonomy gate.
103#[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
237// ─── Tool registry (ToolSet) ────────────────────────────────────────────────────
238
239pub 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}