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