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