Skip to main content

a3s_code_core/tools/
mod.rs

1//! Extensible Tool System
2//!
3//! Provides a trait-based abstraction for tools.
4//!
5//! ## Architecture
6//!
7//! ```text
8//! ToolRegistry
9//!   └── builtin tools (bash, read, write, edit, grep, glob, ls, patch, web_fetch, web_search)
10//! ```
11
12mod builtin;
13pub mod document_parser;
14mod process;
15mod registry;
16pub mod skill;
17pub mod task;
18mod types;
19
20pub(crate) use builtin::agentic_parse::AgenticParseTool;
21pub(crate) use builtin::agentic_search::AgenticSearchTool;
22pub use builtin::{register_agentic_tools, register_skill, register_task, register_task_with_mcp};
23pub use registry::ToolRegistry;
24pub use task::{
25    parallel_task_params_schema, task_params_schema, ParallelTaskParams, ParallelTaskTool,
26    TaskExecutor, TaskParams, TaskResult, TaskTool,
27};
28pub use types::{Tool, ToolContext, ToolEventSender, ToolOutput, ToolStreamEvent};
29
30use crate::file_history::{self, FileHistory};
31use crate::llm::ToolDefinition;
32use crate::permissions::{PermissionChecker, PermissionDecision};
33use anyhow::Result;
34use serde::{Deserialize, Serialize};
35use std::path::PathBuf;
36use std::sync::Arc;
37
38/// Maximum output size in bytes before truncation
39pub const MAX_OUTPUT_SIZE: usize = 100 * 1024; // 100KB
40
41/// Maximum lines to read from a file
42pub const MAX_READ_LINES: usize = 2000;
43
44/// Maximum line length before truncation
45pub const MAX_LINE_LENGTH: usize = 2000;
46
47/// Tool execution result (legacy format for backward compatibility)
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ToolResult {
50    pub name: String,
51    pub output: String,
52    pub exit_code: i32,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub metadata: Option<serde_json::Value>,
55    /// Image attachments from tool execution (multi-modal output).
56    #[serde(skip)]
57    pub images: Vec<crate::llm::Attachment>,
58}
59
60impl ToolResult {
61    pub fn success(name: &str, output: String) -> Self {
62        Self {
63            name: name.to_string(),
64            output,
65            exit_code: 0,
66            metadata: None,
67            images: Vec::new(),
68        }
69    }
70
71    pub fn error(name: &str, message: String) -> Self {
72        Self {
73            name: name.to_string(),
74            output: message,
75            exit_code: 1,
76            metadata: None,
77            images: Vec::new(),
78        }
79    }
80}
81
82impl From<ToolOutput> for ToolResult {
83    fn from(output: ToolOutput) -> Self {
84        Self {
85            name: String::new(),
86            output: output.content,
87            exit_code: if output.success { 0 } else { 1 },
88            metadata: output.metadata,
89            images: output.images,
90        }
91    }
92}
93
94/// Tool executor with workspace sandboxing
95///
96/// This is the main entry point for tool execution. It wraps the ToolRegistry
97/// and provides backward-compatible API. Includes file version history tracking
98/// for write/edit/patch operations.
99///
100/// Defense-in-depth: An optional permission policy can be set to block
101/// denied tools even if the caller bypasses the agent loop's authorization.
102pub struct ToolExecutor {
103    workspace: PathBuf,
104    registry: Arc<ToolRegistry>,
105    file_history: Arc<FileHistory>,
106    guard_policy: Option<Arc<dyn PermissionChecker>>,
107}
108
109impl ToolExecutor {
110    pub fn new(workspace: String) -> Self {
111        let workspace_path = PathBuf::from(&workspace);
112        let registry = Arc::new(ToolRegistry::new(workspace_path.clone()));
113
114        // Register native Rust built-in tools
115        builtin::register_builtins(&registry);
116        // Batch tool requires Arc<ToolRegistry>, registered separately
117        builtin::register_batch(&registry);
118
119        Self {
120            workspace: workspace_path,
121            registry,
122            file_history: Arc::new(FileHistory::new(500)),
123            guard_policy: None,
124        }
125    }
126
127    pub fn set_guard_policy(&mut self, policy: Arc<dyn PermissionChecker>) {
128        self.guard_policy = Some(policy);
129    }
130
131    fn check_guard(&self, name: &str, args: &serde_json::Value) -> Result<()> {
132        if let Some(checker) = &self.guard_policy {
133            if checker.check(name, args) == PermissionDecision::Deny {
134                anyhow::bail!(
135                    "Defense-in-depth: Tool '{}' is blocked by guard permission policy",
136                    name
137                );
138            }
139        }
140        Ok(())
141    }
142
143    fn check_workspace_boundary(
144        name: &str,
145        args: &serde_json::Value,
146        ctx: &ToolContext,
147    ) -> Result<()> {
148        let path_field = match name {
149            "read" | "write" | "edit" | "patch" => Some("file_path"),
150            "ls" | "grep" | "glob" => Some("path"),
151            _ => None,
152        };
153
154        if let Some(field) = path_field {
155            if let Some(path_str) = args.get(field).and_then(|v| v.as_str()) {
156                let target = if std::path::Path::new(path_str).is_absolute() {
157                    std::path::PathBuf::from(path_str)
158                } else {
159                    ctx.workspace.join(path_str)
160                };
161
162                // Canonicalize workspace first — fail closed if it can't be resolved
163                let canonical_workspace = ctx.workspace.canonicalize().map_err(|e| {
164                    anyhow::anyhow!(
165                        "Workspace boundary check failed: cannot canonicalize workspace '{}': {}",
166                        ctx.workspace.display(),
167                        e
168                    )
169                })?;
170
171                // Try to canonicalize target; fall back to parent directory for new files
172                let canonical_target = target.canonicalize().or_else(|_| {
173                    target
174                        .parent()
175                        .and_then(|p| p.canonicalize().ok())
176                        .ok_or_else(|| {
177                            std::io::Error::new(std::io::ErrorKind::NotFound, "parent not found")
178                        })
179                });
180
181                match canonical_target {
182                    Ok(canonical) => {
183                        if !canonical.starts_with(&canonical_workspace) {
184                            anyhow::bail!(
185                                "Workspace boundary violation: tool '{}' path '{}' escapes workspace '{}'",
186                                name,
187                                path_str,
188                                ctx.workspace.display()
189                            );
190                        }
191                    }
192                    Err(_) => {
193                        // Fail closed: if we can't resolve the target path, deny the operation
194                        anyhow::bail!(
195                            "Workspace boundary check failed: cannot resolve path '{}' for tool '{}'",
196                            path_str,
197                            name
198                        );
199                    }
200                }
201            }
202        }
203
204        Ok(())
205    }
206
207    pub fn workspace(&self) -> &PathBuf {
208        &self.workspace
209    }
210
211    pub fn registry(&self) -> &Arc<ToolRegistry> {
212        &self.registry
213    }
214
215    pub fn register_dynamic_tool(&self, tool: Arc<dyn Tool>) {
216        self.registry.register(tool);
217    }
218
219    pub fn unregister_dynamic_tool(&self, name: &str) {
220        self.registry.unregister(name);
221    }
222
223    /// Unregister all dynamic tools whose names start with the given prefix.
224    pub fn unregister_tools_by_prefix(&self, prefix: &str) {
225        self.registry.unregister_by_prefix(prefix);
226    }
227
228    pub fn file_history(&self) -> &Arc<FileHistory> {
229        &self.file_history
230    }
231
232    fn capture_snapshot(&self, name: &str, args: &serde_json::Value) {
233        if let Some(file_path) = file_history::extract_file_path(name, args) {
234            let resolved = self.workspace.join(&file_path);
235            let path_to_read = if resolved.exists() {
236                resolved
237            } else if std::path::Path::new(&file_path).exists() {
238                std::path::PathBuf::from(&file_path)
239            } else {
240                self.file_history.save_snapshot(&file_path, "", name);
241                return;
242            };
243
244            match std::fs::read_to_string(&path_to_read) {
245                Ok(content) => {
246                    self.file_history.save_snapshot(&file_path, &content, name);
247                    tracing::debug!(
248                        "Captured file snapshot for {} before {} (version {})",
249                        file_path,
250                        name,
251                        self.file_history.list_versions(&file_path).len() - 1,
252                    );
253                }
254                Err(e) => {
255                    tracing::warn!("Failed to capture snapshot for {}: {}", file_path, e);
256                }
257            }
258        }
259    }
260
261    pub async fn execute(&self, name: &str, args: &serde_json::Value) -> Result<ToolResult> {
262        self.check_guard(name, args)?;
263        tracing::info!("Executing tool: {} with args: {}", name, args);
264        self.capture_snapshot(name, args);
265        let mut result = self.registry.execute(name, args).await;
266        if let Ok(ref mut r) = result {
267            self.attach_diff_metadata(name, args, r);
268        }
269        match &result {
270            Ok(r) => tracing::info!("Tool {} completed with exit_code={}", name, r.exit_code),
271            Err(e) => tracing::error!("Tool {} failed: {}", name, e),
272        }
273        result
274    }
275
276    pub async fn execute_with_context(
277        &self,
278        name: &str,
279        args: &serde_json::Value,
280        ctx: &ToolContext,
281    ) -> Result<ToolResult> {
282        self.check_guard(name, args)?;
283        Self::check_workspace_boundary(name, args, ctx)?;
284        tracing::info!("Executing tool: {} with args: {}", name, args);
285        self.capture_snapshot(name, args);
286        let mut result = self.registry.execute_with_context(name, args, ctx).await;
287        if let Ok(ref mut r) = result {
288            self.attach_diff_metadata(name, args, r);
289        }
290        match &result {
291            Ok(r) => tracing::info!("Tool {} completed with exit_code={}", name, r.exit_code),
292            Err(e) => tracing::error!("Tool {} failed: {}", name, e),
293        }
294        result
295    }
296
297    fn attach_diff_metadata(&self, name: &str, args: &serde_json::Value, result: &mut ToolResult) {
298        if !file_history::is_file_modifying_tool(name) {
299            return;
300        }
301        let Some(file_path) = file_history::extract_file_path(name, args) else {
302            return;
303        };
304        // Only store file_path in metadata, let translate_event read the actual content
305        // using the session's correct workspace
306        let meta = result.metadata.get_or_insert_with(|| serde_json::json!({}));
307        meta["file_path"] = serde_json::Value::String(file_path);
308    }
309
310    pub fn definitions(&self) -> Vec<ToolDefinition> {
311        self.registry.definitions()
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    #[tokio::test]
320    async fn test_tool_executor_creation() {
321        let executor = ToolExecutor::new("/tmp".to_string());
322        // Base tools: 12 (read, write, edit, patch, bash, grep, glob, ls, web_fetch, web_search, git_worktree, batch)
323        // agentic_search and agentic_parse are opt-in plugins, not registered by default.
324        assert_eq!(executor.registry.len(), 12);
325    }
326
327    #[tokio::test]
328    async fn test_unknown_tool() {
329        let executor = ToolExecutor::new("/tmp".to_string());
330        let result = executor
331            .execute("unknown", &serde_json::json!({}))
332            .await
333            .unwrap();
334        assert_eq!(result.exit_code, 1);
335        assert!(result.output.contains("Unknown tool"));
336    }
337
338    #[tokio::test]
339    async fn test_builtin_tools_registered() {
340        let executor = ToolExecutor::new("/tmp".to_string());
341        let definitions = executor.definitions();
342
343        assert!(definitions.iter().any(|t| t.name == "bash"));
344        assert!(definitions.iter().any(|t| t.name == "read"));
345        assert!(definitions.iter().any(|t| t.name == "write"));
346        assert!(definitions.iter().any(|t| t.name == "edit"));
347        assert!(definitions.iter().any(|t| t.name == "grep"));
348        assert!(definitions.iter().any(|t| t.name == "glob"));
349        assert!(definitions.iter().any(|t| t.name == "ls"));
350        assert!(definitions.iter().any(|t| t.name == "patch"));
351        assert!(definitions.iter().any(|t| t.name == "web_fetch"));
352        assert!(definitions.iter().any(|t| t.name == "web_search"));
353        assert!(definitions.iter().any(|t| t.name == "batch"));
354    }
355
356    #[test]
357    fn test_tool_result_success() {
358        let result = ToolResult::success("test_tool", "output text".to_string());
359        assert_eq!(result.name, "test_tool");
360        assert_eq!(result.output, "output text");
361        assert_eq!(result.exit_code, 0);
362        assert!(result.metadata.is_none());
363    }
364
365    #[test]
366    fn test_tool_result_error() {
367        let result = ToolResult::error("test_tool", "error message".to_string());
368        assert_eq!(result.name, "test_tool");
369        assert_eq!(result.output, "error message");
370        assert_eq!(result.exit_code, 1);
371        assert!(result.metadata.is_none());
372    }
373
374    #[test]
375    fn test_tool_result_from_tool_output_success() {
376        let output = ToolOutput {
377            content: "success content".to_string(),
378            success: true,
379            metadata: None,
380            images: Vec::new(),
381        };
382        let result: ToolResult = output.into();
383        assert_eq!(result.output, "success content");
384        assert_eq!(result.exit_code, 0);
385        assert!(result.metadata.is_none());
386    }
387
388    #[test]
389    fn test_tool_result_from_tool_output_failure() {
390        let output = ToolOutput {
391            content: "failure content".to_string(),
392            success: false,
393            metadata: Some(serde_json::json!({"error": "test"})),
394            images: Vec::new(),
395        };
396        let result: ToolResult = output.into();
397        assert_eq!(result.output, "failure content");
398        assert_eq!(result.exit_code, 1);
399        assert_eq!(result.metadata, Some(serde_json::json!({"error": "test"})));
400    }
401
402    #[test]
403    fn test_tool_result_metadata_propagation() {
404        let output = ToolOutput::success("content")
405            .with_metadata(serde_json::json!({"_load_skill": true, "skill_name": "test"}));
406        let result: ToolResult = output.into();
407        assert_eq!(result.exit_code, 0);
408        let meta = result.metadata.unwrap();
409        assert_eq!(meta["_load_skill"], true);
410        assert_eq!(meta["skill_name"], "test");
411    }
412
413    #[test]
414    fn test_tool_executor_workspace() {
415        let executor = ToolExecutor::new("/test/workspace".to_string());
416        assert_eq!(executor.workspace().to_str().unwrap(), "/test/workspace");
417    }
418
419    #[test]
420    fn test_tool_executor_registry() {
421        let executor = ToolExecutor::new("/tmp".to_string());
422        let registry = executor.registry();
423        // Base tools: 12 (read, write, edit, patch, bash, grep, glob, ls, web_fetch, web_search, git_worktree, batch)
424        // agentic_search and agentic_parse are opt-in plugins, not registered by default.
425        assert_eq!(registry.len(), 12);
426    }
427
428    #[test]
429    fn test_tool_executor_file_history() {
430        let executor = ToolExecutor::new("/tmp".to_string());
431        let history = executor.file_history();
432        assert_eq!(history.list_versions("nonexistent.txt").len(), 0);
433    }
434
435    #[test]
436    fn test_max_output_size_constant() {
437        assert_eq!(MAX_OUTPUT_SIZE, 100 * 1024);
438    }
439
440    #[test]
441    fn test_max_read_lines_constant() {
442        assert_eq!(MAX_READ_LINES, 2000);
443    }
444
445    #[test]
446    fn test_max_line_length_constant() {
447        assert_eq!(MAX_LINE_LENGTH, 2000);
448    }
449
450    #[test]
451    fn test_tool_result_clone() {
452        let result = ToolResult::success("test", "output".to_string());
453        let cloned = result.clone();
454        assert_eq!(result.name, cloned.name);
455        assert_eq!(result.output, cloned.output);
456        assert_eq!(result.exit_code, cloned.exit_code);
457        assert_eq!(result.metadata, cloned.metadata);
458    }
459
460    #[test]
461    fn test_tool_result_debug() {
462        let result = ToolResult::success("test", "output".to_string());
463        let debug_str = format!("{:?}", result);
464        assert!(debug_str.contains("test"));
465        assert!(debug_str.contains("output"));
466    }
467
468    #[tokio::test]
469    async fn test_execute_attaches_diff_metadata() {
470        use tempfile::TempDir;
471        let dir = TempDir::new().unwrap();
472        let file = dir.path().join("hello.txt");
473        std::fs::write(&file, "before content\n").unwrap();
474
475        let executor = ToolExecutor::new(dir.path().to_str().unwrap().to_string());
476        let args = serde_json::json!({
477            "file_path": "hello.txt",
478            "content": "after content\n"
479        });
480        let result = executor.execute("write", &args).await.unwrap();
481
482        let meta = result.metadata.expect("metadata should be present");
483        assert_eq!(meta["before"], "before content\n");
484        assert_eq!(meta["after"], "after content\n");
485        assert_eq!(meta["file_path"], "hello.txt");
486    }
487
488    #[tokio::test]
489    async fn test_execute_with_context_attaches_diff_metadata() {
490        use tempfile::TempDir;
491        let dir = TempDir::new().unwrap();
492        let canonical_dir = dir.path().canonicalize().unwrap();
493        let file = canonical_dir.join("ctx.txt");
494        std::fs::write(&file, "original\n").unwrap();
495
496        let executor = ToolExecutor::new(canonical_dir.to_str().unwrap().to_string());
497        let ctx = ToolContext {
498            workspace: canonical_dir.clone(),
499            session_id: None,
500            event_tx: None,
501            agent_event_tx: None,
502            search_config: None,
503            sandbox: None,
504            document_parsers: None,
505        };
506        let args = serde_json::json!({
507            "file_path": "ctx.txt",
508            "content": "updated\n"
509        });
510        let result = executor
511            .execute_with_context("write", &args, &ctx)
512            .await
513            .unwrap();
514        assert_eq!(result.exit_code, 0, "write tool failed: {}", result.output);
515
516        let meta = result.metadata.expect("metadata should be present");
517        assert_eq!(meta["before"], "original\n");
518        assert_eq!(meta["after"], "updated\n");
519        assert_eq!(meta["file_path"], "ctx.txt");
520    }
521}