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