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    fn manifest(&self) -> ToolManifest {
115        ToolManifest::from_metadata(self.description(), self.metadata())
116    }
117    async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult>;
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
121pub struct ToolMetadata {
122    pub name: String,
123    pub toolset: String,
124    pub risk: RiskLevel,
125    pub requires_auth: bool,
126    pub mutates_files: bool,
127    pub network: bool,
128    pub exec: bool,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
132pub struct ToolManifest {
133    pub name: String,
134    pub description: String,
135    pub toolset: String,
136    pub risk: RiskLevel,
137    pub permissions: Vec<String>,
138}
139
140impl ToolManifest {
141    pub fn from_metadata(description: &str, metadata: ToolMetadata) -> Self {
142        let mut permissions = Vec::new();
143        if metadata.requires_auth {
144            permissions.push("auth".to_string());
145        }
146        if metadata.mutates_files {
147            permissions.push("files:write".to_string());
148        }
149        if metadata.network {
150            permissions.push("network".to_string());
151        }
152        if metadata.exec {
153            permissions.push("exec".to_string());
154        }
155        if permissions.is_empty() {
156            permissions.push("read".to_string());
157        }
158        Self {
159            name: metadata.name,
160            description: description.to_string(),
161            toolset: metadata.toolset,
162            risk: metadata.risk,
163            permissions,
164        }
165    }
166}
167
168pub const TOOLSETS: &[&str] = &[
169    "safe",
170    "web",
171    "file",
172    "terminal",
173    "media",
174    "debug",
175    "skills",
176    "memory",
177    "session_search",
178    "mcp",
179    "gateway",
180];
181
182pub const KNOWN_TOOLS: &[(&str, RiskLevel)] = &[
183    ("fs_read", RiskLevel::ReadOnly),
184    ("fs_list", RiskLevel::ReadOnly),
185    ("fs_write", RiskLevel::Mutating),
186    ("edit", RiskLevel::Mutating),
187    ("multi_edit", RiskLevel::Mutating),
188    ("search", RiskLevel::Network),
189    ("web_search", RiskLevel::Network),
190    ("web_fetch", RiskLevel::Network),
191    ("browser", RiskLevel::Network),
192    ("computer", RiskLevel::Exec),
193    ("git", RiskLevel::Exec),
194    ("todo", RiskLevel::ReadOnly),
195    ("exec", RiskLevel::Exec),
196    ("image_generate", RiskLevel::Network),
197    ("text_to_speech", RiskLevel::Network),
198    ("transcribe", RiskLevel::Network),
199    ("python_rpc", RiskLevel::Exec),
200    ("lsp", RiskLevel::ReadOnly),
201    ("glob", RiskLevel::ReadOnly),
202    ("symbols", RiskLevel::ReadOnly),
203    ("memory", RiskLevel::Mutating),
204    ("knowledge_graph", RiskLevel::Mutating),
205    ("subagent_spawn", RiskLevel::Exec),
206];
207
208pub fn known_tool_metadata(surface: Option<&str>) -> Vec<ToolMetadata> {
209    KNOWN_TOOLS
210        .iter()
211        .map(|(name, risk)| metadata_for(name, risk.clone()))
212        .filter(|meta| surface.map(|s| surface_allows(s, meta)).unwrap_or(true))
213        .collect()
214}
215
216pub fn metadata_for(name: &str, risk: RiskLevel) -> ToolMetadata {
217    let lower = name.to_ascii_lowercase();
218    let toolset = if matches!(lower.as_str(), "fs_read" | "fs_list" | "glob" | "symbols") {
219        "file"
220    } else if matches!(lower.as_str(), "fs_write" | "edit" | "multi_edit") {
221        "file"
222    } else if matches!(
223        lower.as_str(),
224        "search" | "web_search" | "web_fetch" | "browser"
225    ) {
226        "web"
227    } else if lower == "computer" {
228        "terminal"
229    } else if lower == "exec" || lower == "git" {
230        "terminal"
231    } else if matches!(
232        lower.as_str(),
233        "image_gen" | "image_generate" | "tts" | "text_to_speech" | "transcribe"
234    ) {
235        "media"
236    } else if lower == "memory" || lower == "knowledge_graph" {
237        "memory"
238    } else if lower.contains("session") {
239        "session_search"
240    } else if lower == "python_rpc" {
241        "terminal"
242    } else if lower == "lsp" {
243        "debug"
244    } else if lower.contains("mcp") {
245        "mcp"
246    } else if lower.contains("subagent") {
247        "skills"
248    } else if lower == "todo" {
249        "safe"
250    } else {
251        "safe"
252    };
253    ToolMetadata {
254        name: name.to_string(),
255        toolset: toolset.to_string(),
256        requires_auth: matches!(toolset, "web" | "media" | "mcp" | "gateway"),
257        mutates_files: matches!(risk, RiskLevel::Mutating | RiskLevel::Destructive)
258            || matches!(lower.as_str(), "fs_write" | "edit" | "multi_edit"),
259        network: matches!(risk, RiskLevel::Network) || matches!(toolset, "web" | "mcp" | "gateway"),
260        exec: matches!(risk, RiskLevel::Exec) || toolset == "terminal",
261        risk,
262    }
263}
264
265pub fn surface_allows(surface: &str, metadata: &ToolMetadata) -> bool {
266    match surface.trim().to_ascii_lowercase().as_str() {
267        "gateway" => {
268            !metadata.exec
269                && !metadata.mutates_files
270                && !matches!(metadata.risk, RiskLevel::Destructive)
271                && !matches!(metadata.toolset.as_str(), "terminal" | "file")
272        }
273        "subagent" => !matches!(metadata.risk, RiskLevel::Destructive),
274        "cli" | "tui" | "webview" | "" => true,
275        _ => true,
276    }
277}
278
279// ─── Tool registry (ToolSet) ────────────────────────────────────────────────────
280
281pub struct ToolRegistry {
282    tools: HashMap<String, Arc<dyn Tool>>,
283}
284
285impl ToolRegistry {
286    pub fn new() -> Self {
287        Self {
288            tools: HashMap::new(),
289        }
290    }
291
292    pub fn register(&mut self, tool: Arc<dyn Tool>) {
293        self.tools.insert(tool.name().to_string(), tool);
294    }
295
296    pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
297        self.tools.get(name).cloned()
298    }
299
300    pub fn all(&self) -> Vec<Arc<dyn Tool>> {
301        self.tools.values().cloned().collect()
302    }
303
304    pub fn names(&self) -> Vec<String> {
305        self.tools.keys().cloned().collect()
306    }
307
308    pub fn metadata(&self) -> Vec<ToolMetadata> {
309        self.tools.values().map(|tool| tool.metadata()).collect()
310    }
311
312    pub fn manifests(&self) -> Vec<ToolManifest> {
313        self.tools.values().map(|tool| tool.manifest()).collect()
314    }
315
316    pub fn metadata_for_surface(&self, surface: &str) -> Vec<ToolMetadata> {
317        self.metadata()
318            .into_iter()
319            .filter(|meta| surface_allows(surface, meta))
320            .collect()
321    }
322
323    pub fn to_specs_for_surface(&self, surface: &str) -> Vec<super::provider::ToolSpec> {
324        self.tools
325            .values()
326            .filter(|tool| surface_allows(surface, &tool.metadata()))
327            .map(|t| super::provider::ToolSpec {
328                name: t.name().to_string(),
329                description: t.description().to_string(),
330                input_schema: t.schema(),
331            })
332            .collect()
333    }
334
335    pub fn to_specs_for_skill(
336        &self,
337        skill: &crate::capabilities::Skill,
338    ) -> Vec<super::provider::ToolSpec> {
339        if skill.allowed_tools.is_empty() {
340            return self.to_specs();
341        }
342        let allowed: std::collections::HashSet<String> = skill
343            .allowed_tools
344            .iter()
345            .map(|tool| tool.trim().to_ascii_lowercase())
346            .filter(|tool| !tool.is_empty())
347            .collect();
348        self.tools
349            .values()
350            .filter(|tool| allowed.contains(&tool.name().to_ascii_lowercase()))
351            .map(|t| super::provider::ToolSpec {
352                name: t.name().to_string(),
353                description: t.description().to_string(),
354                input_schema: t.schema(),
355            })
356            .collect()
357    }
358
359    pub fn to_specs(&self) -> Vec<super::provider::ToolSpec> {
360        self.tools
361            .values()
362            .map(|t| super::provider::ToolSpec {
363                name: t.name().to_string(),
364                description: t.description().to_string(),
365                input_schema: t.schema(),
366            })
367            .collect()
368    }
369}
370
371impl Default for ToolRegistry {
372    fn default() -> Self {
373        Self::new()
374    }
375}