Skip to main content

a3s_code_core/tools/
mod.rs

1//! Extensible Tool System
2//!
3//! Provides a trait-based abstraction for tools that can be:
4//! - Built-in (Rust implementations)
5//! - Binary (external executables)
6//! - HTTP (API calls)
7//! - Script (interpreted scripts)
8//!
9//! ## Architecture
10//!
11//! ```text
12//! ToolRegistry
13//!   ├── builtin tools (bash, read, write, edit, grep, glob, ls)
14//!   ├── native tools (search_skills, install_skill, load_skill)
15//!   └── dynamic tools (loaded from skills)
16//!         ├── BinaryTool
17//!         ├── HttpTool
18//!         └── ScriptTool
19//! ```
20
21mod builtin;
22mod dynamic;
23mod registry;
24pub mod skill;
25mod skill_catalog;
26mod skill_discovery;
27mod skill_loader;
28pub mod task;
29mod types;
30
31pub use dynamic::create_tool;
32pub use registry::ToolRegistry;
33pub use skill::{builtin_skills, load_skills, Skill, SkillKind, ToolPermission};
34pub use skill_catalog::{build_skills_injection, DEFAULT_CATALOG_THRESHOLD};
35pub use task::{
36    parallel_task_params_schema, task_params_schema, ParallelTaskParams, ParallelTaskTool,
37    TaskExecutor, TaskParams, TaskResult,
38};
39pub use types::{Tool, ToolBackend, ToolContext, ToolEventSender, ToolOutput, ToolStreamEvent};
40
41pub(crate) use skill_loader::parse_skill_tools;
42
43use crate::file_history::{self, FileHistory};
44use crate::llm::ToolDefinition;
45use crate::permissions::{PermissionDecision, PermissionPolicy};
46use anyhow::Result;
47use serde::{Deserialize, Serialize};
48use std::path::PathBuf;
49use std::sync::Arc;
50use tokio::sync::RwLock;
51
52/// Maximum output size in bytes before truncation
53pub const MAX_OUTPUT_SIZE: usize = 100 * 1024; // 100KB
54
55/// Maximum lines to read from a file
56pub const MAX_READ_LINES: usize = 2000;
57
58/// Maximum line length before truncation
59pub const MAX_LINE_LENGTH: usize = 2000;
60
61/// Tool execution result (legacy format for backward compatibility)
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ToolResult {
64    pub name: String,
65    pub output: String,
66    pub exit_code: i32,
67    /// Optional metadata propagated from tool execution (e.g., skill auto-load signals)
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub metadata: Option<serde_json::Value>,
70}
71
72impl ToolResult {
73    pub fn success(name: &str, output: String) -> Self {
74        Self {
75            name: name.to_string(),
76            output,
77            exit_code: 0,
78            metadata: None,
79        }
80    }
81
82    pub fn error(name: &str, message: String) -> Self {
83        Self {
84            name: name.to_string(),
85            output: message,
86            exit_code: 1,
87            metadata: None,
88        }
89    }
90}
91
92impl From<ToolOutput> for ToolResult {
93    fn from(output: ToolOutput) -> Self {
94        Self {
95            name: String::new(), // Will be set by executor
96            output: output.content,
97            exit_code: if output.success { 0 } else { 1 },
98            metadata: output.metadata,
99        }
100    }
101}
102
103/// Tool executor with workspace sandboxing
104///
105/// This is the main entry point for tool execution. It wraps the ToolRegistry
106/// and provides backward-compatible API. Includes file version history tracking
107/// for write/edit/patch operations.
108///
109/// Defense-in-depth: An optional permission policy can be set to block
110/// denied tools even if the caller bypasses the agent loop's authorization.
111pub struct ToolExecutor {
112    workspace: PathBuf,
113    registry: ToolRegistry,
114    file_history: Arc<FileHistory>,
115    /// Defense-in-depth: optional permission policy checked before every execution
116    guard_policy: Option<Arc<RwLock<PermissionPolicy>>>,
117}
118
119impl ToolExecutor {
120    pub fn new(workspace: String) -> Self {
121        let workspace_path = PathBuf::from(&workspace);
122
123        let registry = ToolRegistry::new(workspace_path.clone());
124
125        // Register native Rust built-in tools (replaces a3s-tools binary)
126        builtin::register_builtins(&registry);
127
128        // Register native Rust tools (skill discovery + on-demand loading)
129        registry.register_builtin(Arc::new(skill_discovery::SearchSkillsTool::new()));
130        registry.register_builtin(Arc::new(skill_discovery::InstallSkillTool::new()));
131        registry.register_builtin(Arc::new(skill_discovery::LoadSkillTool::new()));
132
133        Self {
134            workspace: workspace_path,
135            registry,
136            file_history: Arc::new(FileHistory::new(500)),
137            guard_policy: None,
138        }
139    }
140
141    /// Set a defense-in-depth permission policy.
142    ///
143    /// When set, every tool execution is checked against this policy.
144    /// Tools that match a `Deny` rule are blocked before execution.
145    /// This is a safety net for code paths that bypass the agent loop.
146    pub fn set_guard_policy(&mut self, policy: Arc<RwLock<PermissionPolicy>>) {
147        self.guard_policy = Some(policy);
148    }
149
150    /// Check defense-in-depth guard policy. Returns Err if tool is denied.
151    async fn check_guard(&self, name: &str, args: &serde_json::Value) -> Result<()> {
152        if let Some(policy_lock) = &self.guard_policy {
153            let policy = policy_lock.read().await;
154            if policy.check(name, args) == PermissionDecision::Deny {
155                anyhow::bail!(
156                    "Defense-in-depth: Tool '{}' is blocked by guard permission policy",
157                    name
158                );
159            }
160        }
161        Ok(())
162    }
163
164    /// Workspace boundary enforcement for file-accessing tools.
165    /// Validates that file paths resolve within the workspace directory.
166    fn check_workspace_boundary(
167        name: &str,
168        args: &serde_json::Value,
169        ctx: &ToolContext,
170    ) -> Result<()> {
171        // Only check file-accessing tools
172        let path_field = match name {
173            "read" | "write" | "edit" | "patch" => Some("file_path"),
174            "ls" | "grep" | "glob" => Some("path"),
175            _ => None,
176        };
177
178        if let Some(field) = path_field {
179            if let Some(path_str) = args.get(field).and_then(|v| v.as_str()) {
180                // Resolve the path relative to workspace
181                let target = if std::path::Path::new(path_str).is_absolute() {
182                    std::path::PathBuf::from(path_str)
183                } else {
184                    ctx.workspace.join(path_str)
185                };
186
187                // Canonicalize to resolve symlinks and ..
188                // Use the workspace canonical path for comparison
189                if let (Ok(canonical_target), Ok(canonical_workspace)) = (
190                    target.canonicalize().or_else(|_| {
191                        // File may not exist yet (write); check parent
192                        target
193                            .parent()
194                            .and_then(|p| p.canonicalize().ok())
195                            .ok_or_else(|| {
196                                std::io::Error::new(
197                                    std::io::ErrorKind::NotFound,
198                                    "parent not found",
199                                )
200                            })
201                    }),
202                    ctx.workspace.canonicalize(),
203                ) {
204                    if !canonical_target.starts_with(&canonical_workspace) {
205                        anyhow::bail!(
206                            "Workspace boundary violation: tool '{}' path '{}' escapes workspace '{}'",
207                            name,
208                            path_str,
209                            ctx.workspace.display()
210                        );
211                    }
212                }
213            }
214        }
215
216        Ok(())
217    }
218
219    /// Get the workspace path
220    pub fn workspace(&self) -> &PathBuf {
221        &self.workspace
222    }
223
224    /// Get the tool registry for dynamic tool registration
225    pub fn registry(&self) -> &ToolRegistry {
226        &self.registry
227    }
228
229    /// Register a dynamic tool at runtime (e.g., MCP tools, LSP tools, task tool).
230    /// The tool becomes immediately available in all future tool executions.
231    pub fn register_dynamic_tool(&self, tool: Arc<dyn Tool>) {
232        self.registry.register(tool);
233    }
234
235    /// Unregister a dynamic tool by name
236    pub fn unregister_dynamic_tool(&self, name: &str) {
237        self.registry.unregister(name);
238    }
239
240    /// Get the file version history tracker
241    pub fn file_history(&self) -> &Arc<FileHistory> {
242        &self.file_history
243    }
244
245    /// Capture a file snapshot before a modifying tool executes
246    fn capture_snapshot(&self, name: &str, args: &serde_json::Value) {
247        if let Some(file_path) = file_history::extract_file_path(name, args) {
248            let resolved = self.workspace.join(&file_path);
249            // Also try the raw path if it's absolute
250            let path_to_read = if resolved.exists() {
251                resolved
252            } else if std::path::Path::new(&file_path).exists() {
253                std::path::PathBuf::from(&file_path)
254            } else {
255                // New file, save empty snapshot
256                self.file_history.save_snapshot(&file_path, "", name);
257                return;
258            };
259
260            match std::fs::read_to_string(&path_to_read) {
261                Ok(content) => {
262                    self.file_history.save_snapshot(&file_path, &content, name);
263                    tracing::debug!(
264                        "Captured file snapshot for {} before {} (version {})",
265                        file_path,
266                        name,
267                        self.file_history.list_versions(&file_path).len() - 1,
268                    );
269                }
270                Err(e) => {
271                    tracing::warn!("Failed to capture snapshot for {}: {}", file_path, e);
272                }
273            }
274        }
275    }
276
277    /// Execute a tool by name using the server-level default context
278    pub async fn execute(&self, name: &str, args: &serde_json::Value) -> Result<ToolResult> {
279        // Defense-in-depth: check guard policy before execution
280        self.check_guard(name, args).await?;
281
282        tracing::info!("Executing tool: {} with args: {}", name, args);
283
284        // Capture file snapshot before modification
285        self.capture_snapshot(name, args);
286
287        let result = self.registry.execute(name, args).await;
288
289        match &result {
290            Ok(r) => tracing::info!("Tool {} completed with exit_code={}", name, r.exit_code),
291            Err(e) => tracing::error!("Tool {} failed: {}", name, e),
292        }
293
294        result
295    }
296
297    /// Execute a tool by name with a per-session context
298    pub async fn execute_with_context(
299        &self,
300        name: &str,
301        args: &serde_json::Value,
302        ctx: &ToolContext,
303    ) -> Result<ToolResult> {
304        // Defense-in-depth: check guard policy before execution
305        self.check_guard(name, args).await?;
306
307        // Workspace boundary enforcement for file-accessing tools
308        Self::check_workspace_boundary(name, args, ctx)?;
309
310        tracing::info!("Executing tool: {} with args: {}", name, args);
311
312        // Capture file snapshot before modification
313        self.capture_snapshot(name, args);
314
315        let result = self.registry.execute_with_context(name, args, ctx).await;
316
317        match &result {
318            Ok(r) => tracing::info!("Tool {} completed with exit_code={}", name, r.exit_code),
319            Err(e) => tracing::error!("Tool {} failed: {}", name, e),
320        }
321
322        result
323    }
324
325    /// Get all tool definitions for LLM
326    pub fn definitions(&self) -> Vec<ToolDefinition> {
327        self.registry.definitions()
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn test_builtin_skill_parsing() {
337        let builtin_skill = include_str!("../../skills/builtin-tools.md");
338        let tools = parse_skill_tools(builtin_skill);
339        // 11 builtin tools (backend: builtin) — actual implementations are
340        // registered via register_builtins(), these are metadata-only
341        assert_eq!(tools.len(), 11);
342    }
343
344    #[tokio::test]
345    async fn test_tool_executor_creation() {
346        let executor = ToolExecutor::new("/tmp".to_string());
347        assert_eq!(executor.registry.len(), 14); // 11 native builtins + 3 skill tools (search_skills, install_skill, load_skill)
348    }
349
350    #[tokio::test]
351    async fn test_unknown_tool() {
352        let executor = ToolExecutor::new("/tmp".to_string());
353        let result = executor
354            .execute("unknown", &serde_json::json!({}))
355            .await
356            .unwrap();
357        assert_eq!(result.exit_code, 1);
358        assert!(result.output.contains("Unknown tool"));
359    }
360
361    #[tokio::test]
362    async fn test_builtin_tools_registered() {
363        let executor = ToolExecutor::new("/tmp".to_string());
364        let definitions = executor.definitions();
365
366        // Should have all 11 native builtin tools + 3 skill tools
367        assert!(definitions.iter().any(|t| t.name == "bash"));
368        assert!(definitions.iter().any(|t| t.name == "read"));
369        assert!(definitions.iter().any(|t| t.name == "write"));
370        assert!(definitions.iter().any(|t| t.name == "edit"));
371        assert!(definitions.iter().any(|t| t.name == "grep"));
372        assert!(definitions.iter().any(|t| t.name == "glob"));
373        assert!(definitions.iter().any(|t| t.name == "ls"));
374        assert!(definitions.iter().any(|t| t.name == "patch"));
375        assert!(definitions.iter().any(|t| t.name == "web_fetch"));
376        assert!(definitions.iter().any(|t| t.name == "web_search"));
377        assert!(definitions.iter().any(|t| t.name == "cron"));
378        assert!(definitions.iter().any(|t| t.name == "search_skills"));
379        assert!(definitions.iter().any(|t| t.name == "install_skill"));
380        assert!(definitions.iter().any(|t| t.name == "load_skill"));
381    }
382
383    #[test]
384    fn test_tool_result_success() {
385        let result = ToolResult::success("test_tool", "output text".to_string());
386        assert_eq!(result.name, "test_tool");
387        assert_eq!(result.output, "output text");
388        assert_eq!(result.exit_code, 0);
389        assert!(result.metadata.is_none());
390    }
391
392    #[test]
393    fn test_tool_result_error() {
394        let result = ToolResult::error("test_tool", "error message".to_string());
395        assert_eq!(result.name, "test_tool");
396        assert_eq!(result.output, "error message");
397        assert_eq!(result.exit_code, 1);
398        assert!(result.metadata.is_none());
399    }
400
401    #[test]
402    fn test_tool_result_from_tool_output_success() {
403        let output = ToolOutput {
404            content: "success content".to_string(),
405            success: true,
406            metadata: None,
407        };
408        let result: ToolResult = output.into();
409        assert_eq!(result.output, "success content");
410        assert_eq!(result.exit_code, 0);
411        assert!(result.metadata.is_none());
412    }
413
414    #[test]
415    fn test_tool_result_from_tool_output_failure() {
416        let output = ToolOutput {
417            content: "failure content".to_string(),
418            success: false,
419            metadata: Some(serde_json::json!({"error": "test"})),
420        };
421        let result: ToolResult = output.into();
422        assert_eq!(result.output, "failure content");
423        assert_eq!(result.exit_code, 1);
424        assert_eq!(result.metadata, Some(serde_json::json!({"error": "test"})));
425    }
426
427    #[test]
428    fn test_tool_result_metadata_propagation() {
429        let output = ToolOutput::success("content")
430            .with_metadata(serde_json::json!({"_load_skill": true, "skill_name": "test"}));
431        let result: ToolResult = output.into();
432        assert_eq!(result.exit_code, 0);
433        let meta = result.metadata.unwrap();
434        assert_eq!(meta["_load_skill"], true);
435        assert_eq!(meta["skill_name"], "test");
436    }
437
438    #[test]
439    fn test_tool_executor_workspace() {
440        let executor = ToolExecutor::new("/test/workspace".to_string());
441        assert_eq!(executor.workspace().to_str().unwrap(), "/test/workspace");
442    }
443
444    #[test]
445    fn test_tool_executor_registry() {
446        let executor = ToolExecutor::new("/tmp".to_string());
447        let registry = executor.registry();
448        assert_eq!(registry.len(), 14); // 11 native builtins + 3 skill tools
449    }
450
451    #[test]
452    fn test_tool_executor_file_history() {
453        let executor = ToolExecutor::new("/tmp".to_string());
454        let history = executor.file_history();
455        assert_eq!(history.list_versions("nonexistent.txt").len(), 0);
456    }
457
458    #[test]
459    fn test_max_output_size_constant() {
460        assert_eq!(MAX_OUTPUT_SIZE, 100 * 1024);
461    }
462
463    #[test]
464    fn test_max_read_lines_constant() {
465        assert_eq!(MAX_READ_LINES, 2000);
466    }
467
468    #[test]
469    fn test_max_line_length_constant() {
470        assert_eq!(MAX_LINE_LENGTH, 2000);
471    }
472
473    #[test]
474    fn test_tool_result_clone() {
475        let result = ToolResult::success("test", "output".to_string());
476        let cloned = result.clone();
477        assert_eq!(result.name, cloned.name);
478        assert_eq!(result.output, cloned.output);
479        assert_eq!(result.exit_code, cloned.exit_code);
480        assert_eq!(result.metadata, cloned.metadata);
481    }
482
483    #[test]
484    fn test_tool_result_debug() {
485        let result = ToolResult::success("test", "output".to_string());
486        let debug_str = format!("{:?}", result);
487        assert!(debug_str.contains("test"));
488        assert!(debug_str.contains("output"));
489    }
490}