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 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
24// ─── Tool context ───────────────────────────────────────────────────────────────
25
26pub 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// ─── Tool result ────────────────────────────────────────────────────────────────
65
66#[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// ─── THE TOOL TRAIT ─────────────────────────────────────────────────────────────
96
97/// What an agent can do. Every tool declares a JSON schema and a risk level
98/// used by the autonomy gate.
99#[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
233// ─── Tool registry (ToolSet) ────────────────────────────────────────────────────
234
235pub 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}