1#![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
54pub 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#[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#[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
303pub 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 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}