Skip to main content

sparrow_tools/
lib.rs

1// Same lint policy as the main crate (this code was extracted from it).
2#![allow(
3    clippy::collapsible_if,
4    clippy::collapsible_match,
5    clippy::derivable_impls,
6    clippy::format_in_format_args,
7    clippy::if_same_then_else,
8    clippy::iter_cloned_collect,
9    clippy::manual_clamp,
10    clippy::manual_div_ceil,
11    clippy::manual_is_multiple_of,
12    clippy::manual_pattern_char_comparison,
13    clippy::needless_borrow,
14    clippy::needless_range_loop,
15    clippy::new_without_default,
16    clippy::ptr_arg,
17    clippy::should_implement_trait,
18    clippy::single_match,
19    clippy::type_complexity,
20    clippy::unnecessary_cast,
21    clippy::let_and_return,
22    clippy::useless_conversion,
23    clippy::useless_format,
24    clippy::while_let_loop
25)]
26
27use async_trait::async_trait;
28use serde::{Deserialize, Serialize};
29use std::collections::HashMap;
30use std::path::{Path, PathBuf};
31use std::sync::Arc;
32
33use sparrow_core::event::{Block, RiskLevel};
34
35pub mod browser_sandbox;
36pub mod builder_tools;
37pub mod code_exec;
38pub mod code_nav;
39pub mod edit;
40pub mod exec;
41pub mod file_search;
42pub mod fs;
43pub mod git;
44pub mod knowledge_graph;
45pub mod media;
46pub mod memory;
47pub mod search_and_web;
48pub mod stt;
49pub mod todo;
50pub mod tts;
51pub mod voice;
52pub mod web_search;
53
54// ─── Tool context ───────────────────────────────────────────────────────────────
55
56pub struct ToolCtx {
57    pub workspace_root: std::path::PathBuf,
58    pub run_id: sparrow_core::event::RunId,
59}
60
61pub fn resolve_workspace_path(workspace_root: &Path, path: &str) -> anyhow::Result<PathBuf> {
62    let root = workspace_root
63        .canonicalize()
64        .unwrap_or_else(|_| workspace_root.to_path_buf());
65    let candidate = if Path::new(path).is_absolute() {
66        PathBuf::from(path)
67    } else {
68        root.join(path)
69    };
70
71    let check_target = if candidate.exists() {
72        candidate.canonicalize()?
73    } else {
74        let parent = candidate
75            .parent()
76            .ok_or_else(|| anyhow::anyhow!("Invalid path: {}", path))?;
77        let parent = parent
78            .canonicalize()
79            .unwrap_or_else(|_| parent.to_path_buf());
80        parent.join(
81            candidate
82                .file_name()
83                .ok_or_else(|| anyhow::anyhow!("Invalid path: {}", path))?,
84        )
85    };
86
87    if !check_target.starts_with(&root) {
88        anyhow::bail!("Path escapes workspace: {}", path);
89    }
90
91    Ok(check_target)
92}
93
94// ─── Tool result ────────────────────────────────────────────────────────────────
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct ToolResult {
98    pub content: Vec<Block>,
99    pub is_error: bool,
100}
101
102impl ToolResult {
103    pub fn ok(content: Vec<Block>) -> Self {
104        Self {
105            content,
106            is_error: false,
107        }
108    }
109
110    pub fn error(msg: impl Into<String>) -> Self {
111        Self {
112            content: vec![Block::Text(msg.into())],
113            is_error: true,
114        }
115    }
116
117    pub fn text(msg: impl Into<String>) -> Self {
118        Self {
119            content: vec![Block::Text(msg.into())],
120            is_error: false,
121        }
122    }
123}
124
125// ─── THE TOOL TRAIT ─────────────────────────────────────────────────────────────
126
127/// What an agent can do. Every tool declares a JSON schema and a risk level
128/// used by the autonomy gate.
129#[async_trait]
130pub trait Tool: Send + Sync {
131    fn name(&self) -> &str;
132    fn description(&self) -> &str;
133    fn schema(&self) -> serde_json::Value;
134    fn risk(&self) -> RiskLevel;
135    fn metadata(&self) -> ToolMetadata {
136        metadata_for(self.name(), self.risk())
137    }
138    fn manifest(&self) -> ToolManifest {
139        ToolManifest::from_metadata(self.description(), self.metadata())
140    }
141    async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult>;
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
145pub struct ToolMetadata {
146    pub name: String,
147    pub toolset: String,
148    pub risk: RiskLevel,
149    pub requires_auth: bool,
150    pub mutates_files: bool,
151    pub network: bool,
152    pub exec: bool,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
156pub struct ToolManifest {
157    pub name: String,
158    pub description: String,
159    pub toolset: String,
160    pub risk: RiskLevel,
161    pub permissions: Vec<String>,
162}
163
164impl ToolManifest {
165    pub fn from_metadata(description: &str, metadata: ToolMetadata) -> Self {
166        let mut permissions = Vec::new();
167        if metadata.requires_auth {
168            permissions.push("auth".to_string());
169        }
170        if metadata.mutates_files {
171            permissions.push("files:write".to_string());
172        }
173        if metadata.network {
174            permissions.push("network".to_string());
175        }
176        if metadata.exec {
177            permissions.push("exec".to_string());
178        }
179        if permissions.is_empty() {
180            permissions.push("read".to_string());
181        }
182        Self {
183            name: metadata.name,
184            description: description.to_string(),
185            toolset: metadata.toolset,
186            risk: metadata.risk,
187            permissions,
188        }
189    }
190}
191
192pub const TOOLSETS: &[&str] = &[
193    "safe",
194    "web",
195    "file",
196    "terminal",
197    "media",
198    "debug",
199    "skills",
200    "memory",
201    "session_search",
202    "mcp",
203    "gateway",
204];
205
206pub const KNOWN_TOOLS: &[(&str, RiskLevel)] = &[
207    ("fs_read", RiskLevel::ReadOnly),
208    ("fs_list", RiskLevel::ReadOnly),
209    ("fs_write", RiskLevel::Mutating),
210    ("edit", RiskLevel::Mutating),
211    ("multi_edit", RiskLevel::Mutating),
212    ("search", RiskLevel::Network),
213    ("web_search", RiskLevel::Network),
214    ("web_fetch", RiskLevel::Network),
215    ("browser", RiskLevel::Network),
216    ("computer", RiskLevel::Exec),
217    ("git", RiskLevel::Exec),
218    ("todo", RiskLevel::ReadOnly),
219    ("exec", RiskLevel::Exec),
220    ("image_generate", RiskLevel::Network),
221    ("text_to_speech", RiskLevel::Network),
222    ("transcribe", RiskLevel::Network),
223    ("python_rpc", RiskLevel::Exec),
224    ("lsp", RiskLevel::ReadOnly),
225    ("glob", RiskLevel::ReadOnly),
226    ("symbols", RiskLevel::ReadOnly),
227    ("memory", RiskLevel::Mutating),
228    ("knowledge_graph", RiskLevel::Mutating),
229    ("subagent_spawn", RiskLevel::Exec),
230];
231
232pub fn known_tool_metadata(surface: Option<&str>) -> Vec<ToolMetadata> {
233    KNOWN_TOOLS
234        .iter()
235        .map(|(name, risk)| metadata_for(name, risk.clone()))
236        .filter(|meta| surface.map(|s| surface_allows(s, meta)).unwrap_or(true))
237        .collect()
238}
239
240pub fn metadata_for(name: &str, risk: RiskLevel) -> ToolMetadata {
241    let lower = name.to_ascii_lowercase();
242    let toolset = if matches!(lower.as_str(), "fs_read" | "fs_list" | "glob" | "symbols") {
243        "file"
244    } else if matches!(lower.as_str(), "fs_write" | "edit" | "multi_edit") {
245        "file"
246    } else if matches!(
247        lower.as_str(),
248        "search" | "web_search" | "web_fetch" | "browser"
249    ) {
250        "web"
251    } else if lower == "computer" {
252        "terminal"
253    } else if lower == "exec" || lower == "git" {
254        "terminal"
255    } else if matches!(
256        lower.as_str(),
257        "image_gen" | "image_generate" | "tts" | "text_to_speech" | "transcribe"
258    ) {
259        "media"
260    } else if lower == "memory" || lower == "knowledge_graph" {
261        "memory"
262    } else if lower.contains("session") {
263        "session_search"
264    } else if lower == "python_rpc" {
265        "terminal"
266    } else if lower == "lsp" {
267        "debug"
268    } else if lower.contains("mcp") {
269        "mcp"
270    } else if lower.contains("subagent") {
271        "skills"
272    } else if lower == "todo" {
273        "safe"
274    } else {
275        "safe"
276    };
277    ToolMetadata {
278        name: name.to_string(),
279        toolset: toolset.to_string(),
280        requires_auth: matches!(toolset, "web" | "media" | "mcp" | "gateway"),
281        mutates_files: matches!(risk, RiskLevel::Mutating | RiskLevel::Destructive)
282            || matches!(lower.as_str(), "fs_write" | "edit" | "multi_edit"),
283        network: matches!(risk, RiskLevel::Network) || matches!(toolset, "web" | "mcp" | "gateway"),
284        exec: matches!(risk, RiskLevel::Exec) || toolset == "terminal",
285        risk,
286    }
287}
288
289pub fn surface_allows(surface: &str, metadata: &ToolMetadata) -> bool {
290    match surface.trim().to_ascii_lowercase().as_str() {
291        "gateway" => {
292            !metadata.exec
293                && !metadata.mutates_files
294                && !matches!(metadata.risk, RiskLevel::Destructive)
295                && !matches!(metadata.toolset.as_str(), "terminal" | "file")
296        }
297        "subagent" => !matches!(metadata.risk, RiskLevel::Destructive),
298        "cli" | "tui" | "webview" | "" => true,
299        _ => true,
300    }
301}
302
303// ─── Tool registry (ToolSet) ────────────────────────────────────────────────────
304
305pub struct ToolRegistry {
306    tools: HashMap<String, Arc<dyn Tool>>,
307}
308
309impl ToolRegistry {
310    pub fn new() -> Self {
311        Self {
312            tools: HashMap::new(),
313        }
314    }
315
316    pub fn register(&mut self, tool: Arc<dyn Tool>) {
317        self.tools.insert(tool.name().to_string(), tool);
318    }
319
320    pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
321        self.tools.get(name).cloned()
322    }
323
324    pub fn all(&self) -> Vec<Arc<dyn Tool>> {
325        self.tools.values().cloned().collect()
326    }
327
328    pub fn names(&self) -> Vec<String> {
329        self.tools.keys().cloned().collect()
330    }
331
332    pub fn metadata(&self) -> Vec<ToolMetadata> {
333        self.tools.values().map(|tool| tool.metadata()).collect()
334    }
335
336    pub fn manifests(&self) -> Vec<ToolManifest> {
337        self.tools.values().map(|tool| tool.manifest()).collect()
338    }
339
340    pub fn metadata_for_surface(&self, surface: &str) -> Vec<ToolMetadata> {
341        self.metadata()
342            .into_iter()
343            .filter(|meta| surface_allows(surface, meta))
344            .collect()
345    }
346
347    pub fn to_specs_for_surface(&self, surface: &str) -> Vec<sparrow_providers::ToolSpec> {
348        self.tools
349            .values()
350            .filter(|tool| surface_allows(surface, &tool.metadata()))
351            .map(|t| sparrow_providers::ToolSpec {
352                name: t.name().to_string(),
353                description: t.description().to_string(),
354                input_schema: t.schema(),
355            })
356            .collect()
357    }
358
359    /// Filter tool specs to a skill's `allowed_tools` list. Takes the slice
360    /// directly rather than `&Skill` so the tools layer does not depend on the
361    /// capabilities module (decoupling for the workspace split). Empty = all.
362    pub fn to_specs_for_skill(&self, allowed_tools: &[String]) -> Vec<sparrow_providers::ToolSpec> {
363        if allowed_tools.is_empty() {
364            return self.to_specs();
365        }
366        let allowed: std::collections::HashSet<String> = allowed_tools
367            .iter()
368            .map(|tool| tool.trim().to_ascii_lowercase())
369            .filter(|tool| !tool.is_empty())
370            .collect();
371        self.tools
372            .values()
373            .filter(|tool| allowed.contains(&tool.name().to_ascii_lowercase()))
374            .map(|t| sparrow_providers::ToolSpec {
375                name: t.name().to_string(),
376                description: t.description().to_string(),
377                input_schema: t.schema(),
378            })
379            .collect()
380    }
381
382    pub fn to_specs(&self) -> Vec<sparrow_providers::ToolSpec> {
383        self.tools
384            .values()
385            .map(|t| sparrow_providers::ToolSpec {
386                name: t.name().to_string(),
387                description: t.description().to_string(),
388                input_schema: t.schema(),
389            })
390            .collect()
391    }
392}
393
394impl Default for ToolRegistry {
395    fn default() -> Self {
396        Self::new()
397    }
398}