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;
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                if let (Ok(canonical_target), Ok(canonical_workspace)) = (
159                    target.canonicalize().or_else(|_| {
160                        target
161                            .parent()
162                            .and_then(|p| p.canonicalize().ok())
163                            .ok_or_else(|| {
164                                std::io::Error::new(
165                                    std::io::ErrorKind::NotFound,
166                                    "parent not found",
167                                )
168                            })
169                    }),
170                    ctx.workspace.canonicalize(),
171                ) {
172                    if !canonical_target.starts_with(&canonical_workspace) {
173                        anyhow::bail!(
174                            "Workspace boundary violation: tool '{}' path '{}' escapes workspace '{}'",
175                            name,
176                            path_str,
177                            ctx.workspace.display()
178                        );
179                    }
180                }
181            }
182        }
183
184        Ok(())
185    }
186
187    pub fn workspace(&self) -> &PathBuf {
188        &self.workspace
189    }
190
191    pub fn registry(&self) -> &Arc<ToolRegistry> {
192        &self.registry
193    }
194
195    pub fn register_dynamic_tool(&self, tool: Arc<dyn Tool>) {
196        self.registry.register(tool);
197    }
198
199    pub fn unregister_dynamic_tool(&self, name: &str) {
200        self.registry.unregister(name);
201    }
202
203    /// Unregister all dynamic tools whose names start with the given prefix.
204    pub fn unregister_tools_by_prefix(&self, prefix: &str) {
205        self.registry.unregister_by_prefix(prefix);
206    }
207
208    pub fn file_history(&self) -> &Arc<FileHistory> {
209        &self.file_history
210    }
211
212    fn capture_snapshot(&self, name: &str, args: &serde_json::Value) {
213        if let Some(file_path) = file_history::extract_file_path(name, args) {
214            let resolved = self.workspace.join(&file_path);
215            let path_to_read = if resolved.exists() {
216                resolved
217            } else if std::path::Path::new(&file_path).exists() {
218                std::path::PathBuf::from(&file_path)
219            } else {
220                self.file_history.save_snapshot(&file_path, "", name);
221                return;
222            };
223
224            match std::fs::read_to_string(&path_to_read) {
225                Ok(content) => {
226                    self.file_history.save_snapshot(&file_path, &content, name);
227                    tracing::debug!(
228                        "Captured file snapshot for {} before {} (version {})",
229                        file_path,
230                        name,
231                        self.file_history.list_versions(&file_path).len() - 1,
232                    );
233                }
234                Err(e) => {
235                    tracing::warn!("Failed to capture snapshot for {}: {}", file_path, e);
236                }
237            }
238        }
239    }
240
241    pub async fn execute(&self, name: &str, args: &serde_json::Value) -> Result<ToolResult> {
242        self.check_guard(name, args)?;
243        tracing::info!("Executing tool: {} with args: {}", name, args);
244        self.capture_snapshot(name, args);
245        let mut result = self.registry.execute(name, args).await;
246        if let Ok(ref mut r) = result {
247            self.attach_diff_metadata(name, args, r);
248        }
249        match &result {
250            Ok(r) => tracing::info!("Tool {} completed with exit_code={}", name, r.exit_code),
251            Err(e) => tracing::error!("Tool {} failed: {}", name, e),
252        }
253        result
254    }
255
256    pub async fn execute_with_context(
257        &self,
258        name: &str,
259        args: &serde_json::Value,
260        ctx: &ToolContext,
261    ) -> Result<ToolResult> {
262        self.check_guard(name, args)?;
263        Self::check_workspace_boundary(name, args, ctx)?;
264        tracing::info!("Executing tool: {} with args: {}", name, args);
265        self.capture_snapshot(name, args);
266        let mut result = self.registry.execute_with_context(name, args, ctx).await;
267        if let Ok(ref mut r) = result {
268            self.attach_diff_metadata(name, args, r);
269        }
270        match &result {
271            Ok(r) => tracing::info!("Tool {} completed with exit_code={}", name, r.exit_code),
272            Err(e) => tracing::error!("Tool {} failed: {}", name, e),
273        }
274        result
275    }
276
277    fn attach_diff_metadata(&self, name: &str, args: &serde_json::Value, result: &mut ToolResult) {
278        if !file_history::is_file_modifying_tool(name) {
279            return;
280        }
281        let Some(file_path) = file_history::extract_file_path(name, args) else {
282            return;
283        };
284        // Only store file_path in metadata, let translate_event read the actual content
285        // using the session's correct workspace
286        let meta = result.metadata.get_or_insert_with(|| serde_json::json!({}));
287        meta["file_path"] = serde_json::Value::String(file_path);
288    }
289
290    pub fn definitions(&self) -> Vec<ToolDefinition> {
291        self.registry.definitions()
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[tokio::test]
300    async fn test_tool_executor_creation() {
301        let executor = ToolExecutor::new("/tmp".to_string());
302        // Base tools: 12 (read, write, edit, patch, bash, grep, glob, ls, web_fetch, web_search, git_worktree, batch)
303        // + 1 sandbox tool when sandbox feature is enabled
304        #[cfg(feature = "sandbox")]
305        assert_eq!(executor.registry.len(), 13);
306        #[cfg(not(feature = "sandbox"))]
307        assert_eq!(executor.registry.len(), 12);
308    }
309
310    #[tokio::test]
311    async fn test_unknown_tool() {
312        let executor = ToolExecutor::new("/tmp".to_string());
313        let result = executor
314            .execute("unknown", &serde_json::json!({}))
315            .await
316            .unwrap();
317        assert_eq!(result.exit_code, 1);
318        assert!(result.output.contains("Unknown tool"));
319    }
320
321    #[tokio::test]
322    async fn test_builtin_tools_registered() {
323        let executor = ToolExecutor::new("/tmp".to_string());
324        let definitions = executor.definitions();
325
326        assert!(definitions.iter().any(|t| t.name == "bash"));
327        assert!(definitions.iter().any(|t| t.name == "read"));
328        assert!(definitions.iter().any(|t| t.name == "write"));
329        assert!(definitions.iter().any(|t| t.name == "edit"));
330        assert!(definitions.iter().any(|t| t.name == "grep"));
331        assert!(definitions.iter().any(|t| t.name == "glob"));
332        assert!(definitions.iter().any(|t| t.name == "ls"));
333        assert!(definitions.iter().any(|t| t.name == "patch"));
334        assert!(definitions.iter().any(|t| t.name == "web_fetch"));
335        assert!(definitions.iter().any(|t| t.name == "web_search"));
336        assert!(definitions.iter().any(|t| t.name == "batch"));
337    }
338
339    #[test]
340    fn test_tool_result_success() {
341        let result = ToolResult::success("test_tool", "output text".to_string());
342        assert_eq!(result.name, "test_tool");
343        assert_eq!(result.output, "output text");
344        assert_eq!(result.exit_code, 0);
345        assert!(result.metadata.is_none());
346    }
347
348    #[test]
349    fn test_tool_result_error() {
350        let result = ToolResult::error("test_tool", "error message".to_string());
351        assert_eq!(result.name, "test_tool");
352        assert_eq!(result.output, "error message");
353        assert_eq!(result.exit_code, 1);
354        assert!(result.metadata.is_none());
355    }
356
357    #[test]
358    fn test_tool_result_from_tool_output_success() {
359        let output = ToolOutput {
360            content: "success content".to_string(),
361            success: true,
362            metadata: None,
363            images: Vec::new(),
364        };
365        let result: ToolResult = output.into();
366        assert_eq!(result.output, "success content");
367        assert_eq!(result.exit_code, 0);
368        assert!(result.metadata.is_none());
369    }
370
371    #[test]
372    fn test_tool_result_from_tool_output_failure() {
373        let output = ToolOutput {
374            content: "failure content".to_string(),
375            success: false,
376            metadata: Some(serde_json::json!({"error": "test"})),
377            images: Vec::new(),
378        };
379        let result: ToolResult = output.into();
380        assert_eq!(result.output, "failure content");
381        assert_eq!(result.exit_code, 1);
382        assert_eq!(result.metadata, Some(serde_json::json!({"error": "test"})));
383    }
384
385    #[test]
386    fn test_tool_result_metadata_propagation() {
387        let output = ToolOutput::success("content")
388            .with_metadata(serde_json::json!({"_load_skill": true, "skill_name": "test"}));
389        let result: ToolResult = output.into();
390        assert_eq!(result.exit_code, 0);
391        let meta = result.metadata.unwrap();
392        assert_eq!(meta["_load_skill"], true);
393        assert_eq!(meta["skill_name"], "test");
394    }
395
396    #[test]
397    fn test_tool_executor_workspace() {
398        let executor = ToolExecutor::new("/test/workspace".to_string());
399        assert_eq!(executor.workspace().to_str().unwrap(), "/test/workspace");
400    }
401
402    #[test]
403    fn test_tool_executor_registry() {
404        let executor = ToolExecutor::new("/tmp".to_string());
405        let registry = executor.registry();
406        // Base tools: 12 (read, write, edit, patch, bash, grep, glob, ls, web_fetch, web_search, git_worktree, batch)
407        // + 1 sandbox tool when sandbox feature is enabled
408        #[cfg(feature = "sandbox")]
409        assert_eq!(registry.len(), 13);
410        #[cfg(not(feature = "sandbox"))]
411        assert_eq!(registry.len(), 12);
412    }
413
414    #[test]
415    fn test_tool_executor_file_history() {
416        let executor = ToolExecutor::new("/tmp".to_string());
417        let history = executor.file_history();
418        assert_eq!(history.list_versions("nonexistent.txt").len(), 0);
419    }
420
421    #[test]
422    fn test_max_output_size_constant() {
423        assert_eq!(MAX_OUTPUT_SIZE, 100 * 1024);
424    }
425
426    #[test]
427    fn test_max_read_lines_constant() {
428        assert_eq!(MAX_READ_LINES, 2000);
429    }
430
431    #[test]
432    fn test_max_line_length_constant() {
433        assert_eq!(MAX_LINE_LENGTH, 2000);
434    }
435
436    #[test]
437    fn test_tool_result_clone() {
438        let result = ToolResult::success("test", "output".to_string());
439        let cloned = result.clone();
440        assert_eq!(result.name, cloned.name);
441        assert_eq!(result.output, cloned.output);
442        assert_eq!(result.exit_code, cloned.exit_code);
443        assert_eq!(result.metadata, cloned.metadata);
444    }
445
446    #[test]
447    fn test_tool_result_debug() {
448        let result = ToolResult::success("test", "output".to_string());
449        let debug_str = format!("{:?}", result);
450        assert!(debug_str.contains("test"));
451        assert!(debug_str.contains("output"));
452    }
453
454    #[tokio::test]
455    async fn test_execute_attaches_diff_metadata() {
456        use tempfile::TempDir;
457        let dir = TempDir::new().unwrap();
458        let file = dir.path().join("hello.txt");
459        std::fs::write(&file, "before content\n").unwrap();
460
461        let executor = ToolExecutor::new(dir.path().to_str().unwrap().to_string());
462        let args = serde_json::json!({
463            "file_path": "hello.txt",
464            "content": "after content\n"
465        });
466        let result = executor.execute("write", &args).await.unwrap();
467
468        let meta = result.metadata.expect("metadata should be present");
469        assert_eq!(meta["before"], "before content\n");
470        assert_eq!(meta["after"], "after content\n");
471        assert_eq!(meta["file_path"], "hello.txt");
472    }
473
474    #[tokio::test]
475    async fn test_execute_with_context_attaches_diff_metadata() {
476        use tempfile::TempDir;
477        let dir = TempDir::new().unwrap();
478        let canonical_dir = dir.path().canonicalize().unwrap();
479        let file = canonical_dir.join("ctx.txt");
480        std::fs::write(&file, "original\n").unwrap();
481
482        let executor = ToolExecutor::new(canonical_dir.to_str().unwrap().to_string());
483        let ctx = ToolContext {
484            workspace: canonical_dir.clone(),
485            session_id: None,
486            event_tx: None,
487            agent_event_tx: None,
488            search_config: None,
489            sandbox: None,
490        };
491        let args = serde_json::json!({
492            "file_path": "ctx.txt",
493            "content": "updated\n"
494        });
495        let result = executor
496            .execute_with_context("write", &args, &ctx)
497            .await
498            .unwrap();
499        assert_eq!(result.exit_code, 0, "write tool failed: {}", result.output);
500
501        let meta = result.metadata.expect("metadata should be present");
502        assert_eq!(meta["before"], "original\n");
503        assert_eq!(meta["after"], "updated\n");
504        assert_eq!(meta["file_path"], "ctx.txt");
505    }
506}