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 artifacts;
13mod builtin;
14mod process;
15mod program_tool;
16mod registry;
17mod selector;
18pub mod skill;
19pub mod task;
20mod types;
21
22pub use artifacts::{ArtifactStore, ArtifactStoreLimits, ToolArtifact};
23pub(crate) use builtin::register_skill;
24pub use builtin::{
25    register_generate_object, register_program, register_program_with_catalog, register_task,
26    register_task_with_mcp,
27};
28pub use program_tool::ProgramTool;
29pub use registry::ToolRegistry;
30pub use selector::{select_tools_for_messages, select_tools_for_prompt};
31pub use task::{
32    parallel_task_params_schema, task_params_schema, ParallelTaskParams, ParallelTaskTool,
33    TaskExecutor, TaskParams, TaskResult, TaskTool,
34};
35pub use types::{Tool, ToolContext, ToolEventSender, ToolOutput, ToolStreamEvent};
36
37use crate::file_history::{self, FileHistory};
38use crate::llm::ToolDefinition;
39use crate::permissions::{PermissionChecker, PermissionDecision};
40use crate::text::truncate_utf8;
41use anyhow::Result;
42use serde::{Deserialize, Serialize};
43use std::collections::HashMap;
44use std::path::PathBuf;
45use std::sync::Arc;
46
47/// Maximum output size in bytes before truncation
48pub const MAX_OUTPUT_SIZE: usize = 100 * 1024; // 100KB
49
50/// Maximum lines to read from a file
51pub const MAX_READ_LINES: usize = 2000;
52
53/// Maximum line length before truncation
54pub const MAX_LINE_LENGTH: usize = 2000;
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub(crate) struct ToolOutputArtifact {
58    pub artifact_id: String,
59    pub artifact_uri: String,
60    pub original_bytes: usize,
61    pub shown_bytes: usize,
62}
63
64#[derive(Debug, Clone)]
65pub(crate) struct TruncatedToolOutput {
66    pub content: String,
67    pub artifact: Option<ToolOutputArtifact>,
68}
69
70pub(crate) fn truncate_tool_output_with_artifact(
71    tool_name: &str,
72    output: &str,
73) -> TruncatedToolOutput {
74    if output.len() <= MAX_OUTPUT_SIZE {
75        return TruncatedToolOutput {
76            content: output.to_string(),
77            artifact: None,
78        };
79    }
80
81    let shown = truncate_utf8(output, MAX_OUTPUT_SIZE);
82    let artifact = tool_output_artifact(tool_name, output, shown.len());
83    let artifact_uri = artifact.artifact_uri.clone();
84    let content = format!(
85        "{}\n\n[tool output truncated: showing the first {} of {} bytes. Full output artifact: {}. Use narrower arguments such as offset/limit or filtering when possible.]",
86        shown,
87        shown.len(),
88        output.len(),
89        artifact_uri,
90    );
91
92    TruncatedToolOutput {
93        content,
94        artifact: Some(artifact),
95    }
96}
97
98pub(crate) fn tool_output_artifact(
99    tool_name: &str,
100    output: &str,
101    shown_bytes: usize,
102) -> ToolOutputArtifact {
103    use std::hash::{Hash, Hasher};
104
105    let mut hasher = std::collections::hash_map::DefaultHasher::new();
106    tool_name.hash(&mut hasher);
107    output.len().hash(&mut hasher);
108    output.hash(&mut hasher);
109    let digest = hasher.finish();
110    let sanitized_tool = tool_name
111        .chars()
112        .map(|ch| {
113            if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
114                ch
115            } else {
116                '_'
117            }
118        })
119        .collect::<String>();
120    let artifact_id = format!("tool-output:{sanitized_tool}:{digest:016x}");
121    let artifact_uri = format!("a3s://tool-output/{sanitized_tool}/{digest:016x}");
122
123    ToolOutputArtifact {
124        artifact_id,
125        artifact_uri,
126        original_bytes: output.len(),
127        shown_bytes,
128    }
129}
130
131pub(crate) fn merge_tool_output_artifact_metadata(
132    metadata: Option<serde_json::Value>,
133    artifact: &ToolOutputArtifact,
134) -> serde_json::Value {
135    let artifact_json = serde_json::json!({
136        "artifact_id": artifact.artifact_id,
137        "artifact_uri": artifact.artifact_uri,
138        "original_bytes": artifact.original_bytes,
139        "shown_bytes": artifact.shown_bytes,
140    });
141
142    match metadata {
143        Some(serde_json::Value::Object(mut object)) => {
144            object.insert("artifact".to_string(), artifact_json);
145            serde_json::Value::Object(object)
146        }
147        Some(value) => serde_json::json!({
148            "artifact": artifact_json,
149            "previous_metadata": value,
150        }),
151        None => serde_json::json!({
152            "artifact": artifact_json,
153        }),
154    }
155}
156
157/// Tool execution result returned by direct tool execution.
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct ToolResult {
160    pub name: String,
161    pub output: String,
162    pub exit_code: i32,
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub metadata: Option<serde_json::Value>,
165    /// Image attachments from tool execution (multi-modal output).
166    #[serde(skip)]
167    pub images: Vec<crate::llm::Attachment>,
168}
169
170impl ToolResult {
171    pub fn success(name: &str, output: String) -> Self {
172        Self {
173            name: name.to_string(),
174            output,
175            exit_code: 0,
176            metadata: None,
177            images: Vec::new(),
178        }
179    }
180
181    pub fn error(name: &str, message: String) -> Self {
182        Self {
183            name: name.to_string(),
184            output: message,
185            exit_code: 1,
186            metadata: None,
187            images: Vec::new(),
188        }
189    }
190}
191
192impl From<ToolOutput> for ToolResult {
193    fn from(output: ToolOutput) -> Self {
194        Self {
195            name: String::new(),
196            output: output.content,
197            exit_code: if output.success { 0 } else { 1 },
198            metadata: output.metadata,
199            images: output.images,
200        }
201    }
202}
203
204/// Tool executor with workspace sandboxing
205///
206/// This is the main entry point for tool execution. It wraps the ToolRegistry
207/// and captures file snapshots before write/edit/patch operations.
208///
209/// Defense-in-depth: An optional permission policy can be set to block
210/// denied tools even if the caller bypasses the agent loop's authorization.
211pub struct ToolExecutor {
212    workspace: PathBuf,
213    registry: Arc<ToolRegistry>,
214    file_history: Arc<FileHistory>,
215    guard_policy: Option<Arc<dyn PermissionChecker>>,
216    command_env: Option<Arc<HashMap<String, String>>>,
217}
218
219impl ToolExecutor {
220    pub fn new(workspace: String) -> Self {
221        Self::new_with_options(workspace, None, ArtifactStoreLimits::default())
222    }
223
224    pub fn new_with_command_env(workspace: String, command_env: HashMap<String, String>) -> Self {
225        Self::new_with_options(workspace, Some(command_env), ArtifactStoreLimits::default())
226    }
227
228    pub fn new_with_artifact_limits(
229        workspace: String,
230        artifact_limits: ArtifactStoreLimits,
231    ) -> Self {
232        Self::new_with_options(workspace, None, artifact_limits)
233    }
234
235    pub fn new_with_command_env_and_artifact_limits(
236        workspace: String,
237        command_env: HashMap<String, String>,
238        artifact_limits: ArtifactStoreLimits,
239    ) -> Self {
240        Self::new_with_options(workspace, Some(command_env), artifact_limits)
241    }
242
243    fn new_with_options(
244        workspace: String,
245        command_env: Option<HashMap<String, String>>,
246        artifact_limits: ArtifactStoreLimits,
247    ) -> Self {
248        let workspace_path = PathBuf::from(&workspace);
249        let registry = Arc::new(ToolRegistry::with_artifact_limits(
250            workspace_path.clone(),
251            artifact_limits,
252        ));
253
254        // Register native Rust built-in tools
255        builtin::register_builtins(&registry);
256        // Batch tool requires Arc<ToolRegistry>, registered separately
257        builtin::register_batch(&registry);
258        builtin::register_program(&registry);
259
260        Self {
261            workspace: workspace_path,
262            registry,
263            file_history: Arc::new(FileHistory::new(500)),
264            guard_policy: None,
265            command_env: command_env.map(Arc::new),
266        }
267    }
268
269    pub fn set_guard_policy(&mut self, policy: Arc<dyn PermissionChecker>) {
270        self.guard_policy = Some(policy);
271    }
272
273    fn check_guard(&self, name: &str, args: &serde_json::Value) -> Result<()> {
274        if let Some(checker) = &self.guard_policy {
275            if checker.check(name, args) == PermissionDecision::Deny {
276                anyhow::bail!(
277                    "Defense-in-depth: Tool '{}' is blocked by guard permission policy",
278                    name
279                );
280            }
281        }
282        Ok(())
283    }
284
285    fn check_workspace_boundary(
286        name: &str,
287        args: &serde_json::Value,
288        ctx: &ToolContext,
289    ) -> Result<()> {
290        let path_field = match name {
291            "read" | "write" | "edit" | "patch" => Some("file_path"),
292            "ls" | "grep" | "glob" => Some("path"),
293            _ => None,
294        };
295
296        if let Some(field) = path_field {
297            if let Some(path_str) = args.get(field).and_then(|v| v.as_str()) {
298                let target = if std::path::Path::new(path_str).is_absolute() {
299                    std::path::PathBuf::from(path_str)
300                } else {
301                    ctx.workspace.join(path_str)
302                };
303
304                // Canonicalize workspace first — fail closed if it can't be resolved
305                let canonical_workspace = ctx.workspace.canonicalize().map_err(|e| {
306                    anyhow::anyhow!(
307                        "Workspace boundary check failed: cannot canonicalize workspace '{}': {}",
308                        ctx.workspace.display(),
309                        e
310                    )
311                })?;
312
313                // Try to canonicalize target; fall back to parent directory for new files
314                let canonical_target = target.canonicalize().or_else(|_| {
315                    target
316                        .parent()
317                        .and_then(|p| p.canonicalize().ok())
318                        .ok_or_else(|| {
319                            std::io::Error::new(std::io::ErrorKind::NotFound, "parent not found")
320                        })
321                });
322
323                match canonical_target {
324                    Ok(canonical) => {
325                        if !canonical.starts_with(&canonical_workspace) {
326                            anyhow::bail!(
327                                "Workspace boundary violation: tool '{}' path '{}' escapes workspace '{}'",
328                                name,
329                                path_str,
330                                ctx.workspace.display()
331                            );
332                        }
333                    }
334                    Err(_) => {
335                        // Fail closed: if we can't resolve the target path, deny the operation
336                        anyhow::bail!(
337                            "Workspace boundary check failed: cannot resolve path '{}' for tool '{}'",
338                            path_str,
339                            name
340                        );
341                    }
342                }
343            }
344        }
345
346        Ok(())
347    }
348
349    pub fn workspace(&self) -> &PathBuf {
350        &self.workspace
351    }
352
353    pub fn registry(&self) -> &Arc<ToolRegistry> {
354        &self.registry
355    }
356
357    /// Get a stored tool artifact by URI.
358    pub fn get_artifact(&self, artifact_uri: &str) -> Option<ToolArtifact> {
359        self.registry.get_artifact(artifact_uri)
360    }
361
362    /// Return a clone of the executor's artifact store handle.
363    pub fn artifact_store(&self) -> ArtifactStore {
364        self.registry.artifact_store()
365    }
366
367    /// Replace the sink used for compact execution trace events.
368    pub fn set_trace_sink(&self, sink: Arc<dyn crate::trace::TraceSink>) {
369        self.registry.set_trace_sink(sink);
370    }
371
372    /// Return the currently configured execution trace sink.
373    pub fn trace_sink(&self) -> Arc<dyn crate::trace::TraceSink> {
374        self.registry.trace_sink()
375    }
376
377    pub fn command_env(&self) -> Option<Arc<HashMap<String, String>>> {
378        self.command_env.clone()
379    }
380
381    pub fn register_dynamic_tool(&self, tool: Arc<dyn Tool>) {
382        self.registry.register(tool);
383    }
384
385    pub fn unregister_dynamic_tool(&self, name: &str) {
386        self.registry.unregister(name);
387    }
388
389    /// Unregister all dynamic tools whose names start with the given prefix.
390    pub fn unregister_tools_by_prefix(&self, prefix: &str) {
391        self.registry.unregister_by_prefix(prefix);
392    }
393
394    /// Replace the model-visible `program` tool with a custom PTC catalog.
395    pub fn register_program_catalog(&self, catalog: crate::program::ProgramCatalog) {
396        builtin::register_program_with_catalog(&self.registry, catalog);
397    }
398
399    fn capture_snapshot(&self, name: &str, args: &serde_json::Value) {
400        if let Some(file_path) = file_history::extract_file_path(name, args) {
401            let resolved = self.workspace.join(&file_path);
402            let path_to_read = if resolved.exists() {
403                resolved
404            } else if std::path::Path::new(&file_path).exists() {
405                std::path::PathBuf::from(&file_path)
406            } else {
407                self.file_history.save_snapshot(&file_path, "", name);
408                return;
409            };
410
411            match std::fs::read_to_string(&path_to_read) {
412                Ok(content) => {
413                    self.file_history.save_snapshot(&file_path, &content, name);
414                    tracing::debug!(
415                        "Captured file snapshot for {} before {} (version {})",
416                        file_path,
417                        name,
418                        self.file_history.list_versions(&file_path).len() - 1,
419                    );
420                }
421                Err(e) => {
422                    tracing::warn!("Failed to capture snapshot for {}: {}", file_path, e);
423                }
424            }
425        }
426    }
427
428    pub async fn execute(&self, name: &str, args: &serde_json::Value) -> Result<ToolResult> {
429        self.check_guard(name, args)?;
430        tracing::info!("Executing tool: {} with args: {}", name, args);
431        self.capture_snapshot(name, args);
432        let mut result = self.registry.execute(name, args).await;
433        if let Ok(ref mut r) = result {
434            self.attach_diff_metadata(name, args, r);
435        }
436        match &result {
437            Ok(r) => tracing::info!("Tool {} completed with exit_code={}", name, r.exit_code),
438            Err(e) => tracing::error!("Tool {} failed: {}", name, e),
439        }
440        result
441    }
442
443    pub async fn execute_with_context(
444        &self,
445        name: &str,
446        args: &serde_json::Value,
447        ctx: &ToolContext,
448    ) -> Result<ToolResult> {
449        self.check_guard(name, args)?;
450        Self::check_workspace_boundary(name, args, ctx)?;
451        tracing::info!("Executing tool: {} with args: {}", name, args);
452        self.capture_snapshot(name, args);
453        let mut result = self.registry.execute_with_context(name, args, ctx).await;
454        if let Ok(ref mut r) = result {
455            self.attach_diff_metadata(name, args, r);
456        }
457        match &result {
458            Ok(r) => tracing::info!("Tool {} completed with exit_code={}", name, r.exit_code),
459            Err(e) => tracing::error!("Tool {} failed: {}", name, e),
460        }
461        result
462    }
463
464    fn attach_diff_metadata(&self, name: &str, args: &serde_json::Value, result: &mut ToolResult) {
465        if !file_history::is_file_modifying_tool(name) {
466            return;
467        }
468        let Some(file_path) = file_history::extract_file_path(name, args) else {
469            return;
470        };
471        // Only store file_path in metadata, let translate_event read the actual content
472        // using the session's correct workspace
473        let meta = result.metadata.get_or_insert_with(|| serde_json::json!({}));
474        meta["file_path"] = serde_json::Value::String(file_path);
475    }
476
477    pub fn definitions(&self) -> Vec<ToolDefinition> {
478        self.registry.definitions()
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use async_trait::async_trait;
486
487    struct LargeArtifactTool;
488
489    #[async_trait]
490    impl Tool for LargeArtifactTool {
491        fn name(&self) -> &str {
492            "large_artifact"
493        }
494
495        fn description(&self) -> &str {
496            "Produces large output for artifact API tests"
497        }
498
499        fn parameters(&self) -> serde_json::Value {
500            serde_json::json!({
501                "type": "object",
502                "additionalProperties": false,
503                "properties": {},
504                "required": []
505            })
506        }
507
508        async fn execute(
509            &self,
510            args: &serde_json::Value,
511            _ctx: &ToolContext,
512        ) -> Result<ToolOutput> {
513            let suffix = args
514                .get("suffix")
515                .and_then(|value| value.as_str())
516                .unwrap_or_default();
517            Ok(ToolOutput::success(format!(
518                "{}{}",
519                "z".repeat(MAX_OUTPUT_SIZE + 1),
520                suffix
521            )))
522        }
523    }
524
525    struct EchoTool;
526
527    #[async_trait]
528    impl Tool for EchoTool {
529        fn name(&self) -> &str {
530            "echo"
531        }
532
533        fn description(&self) -> &str {
534            "Echoes the message argument"
535        }
536
537        fn parameters(&self) -> serde_json::Value {
538            serde_json::json!({
539                "type": "object",
540                "additionalProperties": false,
541                "properties": {
542                    "message": { "type": "string" }
543                },
544                "required": ["message"]
545            })
546        }
547
548        async fn execute(
549            &self,
550            args: &serde_json::Value,
551            _ctx: &ToolContext,
552        ) -> Result<ToolOutput> {
553            Ok(ToolOutput::success(
554                args["message"].as_str().unwrap_or_default(),
555            ))
556        }
557    }
558
559    #[tokio::test]
560    async fn test_tool_executor_creation() {
561        let executor = ToolExecutor::new("/tmp".to_string());
562        // Baseline tools on a raw ToolExecutor: 13
563        assert_eq!(executor.registry.len(), 13);
564    }
565
566    #[tokio::test]
567    async fn test_unknown_tool() {
568        let executor = ToolExecutor::new("/tmp".to_string());
569        let result = executor
570            .execute("unknown", &serde_json::json!({}))
571            .await
572            .unwrap();
573        assert_eq!(result.exit_code, 1);
574        assert!(result.output.contains("Unknown tool"));
575    }
576
577    #[tokio::test]
578    async fn test_builtin_tools_registered() {
579        let executor = ToolExecutor::new("/tmp".to_string());
580        let definitions = executor.definitions();
581
582        assert!(definitions.iter().any(|t| t.name == "bash"));
583        assert!(definitions.iter().any(|t| t.name == "read"));
584        assert!(definitions.iter().any(|t| t.name == "write"));
585        assert!(definitions.iter().any(|t| t.name == "edit"));
586        assert!(definitions.iter().any(|t| t.name == "grep"));
587        assert!(definitions.iter().any(|t| t.name == "glob"));
588        assert!(definitions.iter().any(|t| t.name == "ls"));
589        assert!(definitions.iter().any(|t| t.name == "patch"));
590        assert!(definitions.iter().any(|t| t.name == "web_fetch"));
591        assert!(definitions.iter().any(|t| t.name == "web_search"));
592        assert!(definitions.iter().any(|t| t.name == "batch"));
593    }
594
595    #[test]
596    fn test_tool_result_success() {
597        let result = ToolResult::success("test_tool", "output text".to_string());
598        assert_eq!(result.name, "test_tool");
599        assert_eq!(result.output, "output text");
600        assert_eq!(result.exit_code, 0);
601        assert!(result.metadata.is_none());
602    }
603
604    #[test]
605    fn test_tool_result_error() {
606        let result = ToolResult::error("test_tool", "error message".to_string());
607        assert_eq!(result.name, "test_tool");
608        assert_eq!(result.output, "error message");
609        assert_eq!(result.exit_code, 1);
610        assert!(result.metadata.is_none());
611    }
612
613    #[test]
614    fn test_tool_result_from_tool_output_success() {
615        let output = ToolOutput {
616            content: "success content".to_string(),
617            success: true,
618            metadata: None,
619            images: Vec::new(),
620        };
621        let result: ToolResult = output.into();
622        assert_eq!(result.output, "success content");
623        assert_eq!(result.exit_code, 0);
624        assert!(result.metadata.is_none());
625    }
626
627    #[test]
628    fn test_tool_result_from_tool_output_failure() {
629        let output = ToolOutput {
630            content: "failure content".to_string(),
631            success: false,
632            metadata: Some(serde_json::json!({"error": "test"})),
633            images: Vec::new(),
634        };
635        let result: ToolResult = output.into();
636        assert_eq!(result.output, "failure content");
637        assert_eq!(result.exit_code, 1);
638        assert_eq!(result.metadata, Some(serde_json::json!({"error": "test"})));
639    }
640
641    #[test]
642    fn test_tool_result_metadata_propagation() {
643        let output = ToolOutput::success("content")
644            .with_metadata(serde_json::json!({"_load_skill": true, "skill_name": "test"}));
645        let result: ToolResult = output.into();
646        assert_eq!(result.exit_code, 0);
647        let meta = result.metadata.unwrap();
648        assert_eq!(meta["_load_skill"], true);
649        assert_eq!(meta["skill_name"], "test");
650    }
651
652    #[test]
653    fn test_tool_executor_workspace() {
654        let executor = ToolExecutor::new("/test/workspace".to_string());
655        assert_eq!(executor.workspace().to_str().unwrap(), "/test/workspace");
656    }
657
658    #[test]
659    fn test_tool_executor_registry() {
660        let executor = ToolExecutor::new("/tmp".to_string());
661        let registry = executor.registry();
662        // Baseline tools on a raw ToolExecutor: 13
663        assert_eq!(registry.len(), 13);
664    }
665
666    #[tokio::test]
667    async fn test_tool_executor_get_artifact() {
668        let executor = ToolExecutor::new("/tmp".to_string());
669        executor.register_dynamic_tool(Arc::new(LargeArtifactTool));
670
671        let result = executor
672            .execute("large_artifact", &serde_json::json!({}))
673            .await
674            .unwrap();
675
676        let artifact_uri = result.metadata.as_ref().unwrap()["artifact"]["artifact_uri"]
677            .as_str()
678            .unwrap();
679        let artifact = executor.get_artifact(artifact_uri).expect("artifact");
680        assert_eq!(artifact.tool_name, "large_artifact");
681        assert_eq!(artifact.content.len(), MAX_OUTPUT_SIZE + 1);
682        assert!(executor.artifact_store().get(artifact_uri).is_some());
683    }
684
685    #[tokio::test]
686    async fn test_tool_executor_respects_artifact_limits() {
687        let executor = ToolExecutor::new_with_artifact_limits(
688            "/tmp".to_string(),
689            ArtifactStoreLimits {
690                max_artifacts: 1,
691                max_bytes: usize::MAX,
692            },
693        );
694        executor.register_dynamic_tool(Arc::new(LargeArtifactTool));
695
696        let first = executor
697            .execute("large_artifact", &serde_json::json!({}))
698            .await
699            .unwrap();
700        let first_uri = first.metadata.as_ref().unwrap()["artifact"]["artifact_uri"]
701            .as_str()
702            .unwrap()
703            .to_string();
704
705        executor
706            .execute("large_artifact", &serde_json::json!({ "suffix": "again" }))
707            .await
708            .unwrap();
709
710        assert_eq!(executor.artifact_store().limits().max_artifacts, 1);
711        assert_eq!(executor.artifact_store().len(), 1);
712        assert!(executor.get_artifact(&first_uri).is_none());
713    }
714
715    #[tokio::test]
716    async fn test_tool_executor_register_program_catalog_keeps_script_only_program_tool() {
717        let executor = ToolExecutor::new("/tmp".to_string());
718        let trace_sink = crate::trace::InMemoryTraceSink::default();
719        executor.set_trace_sink(Arc::new(trace_sink.clone()));
720        executor.register_dynamic_tool(Arc::new(EchoTool));
721        let mut catalog = crate::program::ProgramCatalog::new();
722        catalog.register(
723            crate::program::ProgramTemplate::new("custom_echo", "Run a custom echo program")
724                .with_parameter(crate::program::ProgramParameter::required(
725                    "message",
726                    "Message to echo",
727                ))
728                .with_step(
729                    crate::program::ProgramStepTemplate::new(
730                        "echo",
731                        serde_json::json!({ "message": "{{message}}" }),
732                    )
733                    .with_label("echo_message"),
734                ),
735        );
736        executor.register_program_catalog(catalog);
737
738        let result = executor
739            .execute(
740                "program",
741                &serde_json::json!({
742                    "name": "custom_echo",
743                    "inputs": {
744                        "message": "hello from catalog"
745                    }
746                }),
747            )
748            .await
749            .unwrap();
750
751        assert_eq!(result.exit_code, 1);
752        assert!(result.output.contains("type parameter is required"));
753
754        let events = trace_sink.events();
755        assert!(events.iter().any(|event| {
756            event.kind == crate::trace::TraceEventKind::ToolExecution && event.name == "program"
757        }));
758        assert!(!events.iter().any(|event| {
759            event.kind == crate::trace::TraceEventKind::ToolExecution && event.name == "echo"
760        }));
761    }
762
763    #[test]
764    fn test_max_output_size_constant() {
765        assert_eq!(MAX_OUTPUT_SIZE, 100 * 1024);
766    }
767
768    #[test]
769    fn test_max_read_lines_constant() {
770        assert_eq!(MAX_READ_LINES, 2000);
771    }
772
773    #[test]
774    fn test_max_line_length_constant() {
775        assert_eq!(MAX_LINE_LENGTH, 2000);
776    }
777
778    #[test]
779    fn test_truncate_tool_output_with_artifact_reference() {
780        let output = "x".repeat(MAX_OUTPUT_SIZE + 1);
781        let truncated = truncate_tool_output_with_artifact("test/tool", &output);
782
783        let artifact = truncated.artifact.expect("artifact");
784        assert!(truncated.content.contains("Full output artifact:"));
785        assert_eq!(artifact.original_bytes, MAX_OUTPUT_SIZE + 1);
786        assert_eq!(artifact.shown_bytes, MAX_OUTPUT_SIZE);
787        assert!(artifact.artifact_id.starts_with("tool-output:test_tool:"));
788        assert!(artifact
789            .artifact_uri
790            .starts_with("a3s://tool-output/test_tool/"));
791    }
792
793    #[test]
794    fn test_tool_result_clone() {
795        let result = ToolResult::success("test", "output".to_string());
796        let cloned = result.clone();
797        assert_eq!(result.name, cloned.name);
798        assert_eq!(result.output, cloned.output);
799        assert_eq!(result.exit_code, cloned.exit_code);
800        assert_eq!(result.metadata, cloned.metadata);
801    }
802
803    #[test]
804    fn test_tool_result_debug() {
805        let result = ToolResult::success("test", "output".to_string());
806        let debug_str = format!("{:?}", result);
807        assert!(debug_str.contains("test"));
808        assert!(debug_str.contains("output"));
809    }
810
811    #[tokio::test]
812    async fn test_execute_attaches_diff_metadata() {
813        use tempfile::TempDir;
814        let dir = TempDir::new().unwrap();
815        let file = dir.path().join("hello.txt");
816        std::fs::write(&file, "before content\n").unwrap();
817
818        let executor = ToolExecutor::new(dir.path().to_str().unwrap().to_string());
819        let args = serde_json::json!({
820            "file_path": "hello.txt",
821            "content": "after content\n"
822        });
823        let result = executor.execute("write", &args).await.unwrap();
824
825        let meta = result.metadata.expect("metadata should be present");
826        assert_eq!(meta["before"], "before content\n");
827        assert_eq!(meta["after"], "after content\n");
828        assert_eq!(meta["file_path"], "hello.txt");
829    }
830
831    #[tokio::test]
832    async fn test_execute_with_context_attaches_diff_metadata() {
833        use tempfile::TempDir;
834        let dir = TempDir::new().unwrap();
835        let canonical_dir = dir.path().canonicalize().unwrap();
836        let file = canonical_dir.join("ctx.txt");
837        std::fs::write(&file, "original\n").unwrap();
838
839        let executor = ToolExecutor::new(canonical_dir.to_str().unwrap().to_string());
840        let ctx = ToolContext {
841            workspace: canonical_dir.clone(),
842            session_id: None,
843            event_tx: None,
844            agent_event_tx: None,
845            search_config: None,
846            sandbox: None,
847            command_env: None,
848        };
849        let args = serde_json::json!({
850            "file_path": "ctx.txt",
851            "content": "updated\n"
852        });
853        let result = executor
854            .execute_with_context("write", &args, &ctx)
855            .await
856            .unwrap();
857        assert_eq!(result.exit_code, 0, "write tool failed: {}", result.output);
858
859        let meta = result.metadata.expect("metadata should be present");
860        assert_eq!(meta["before"], "original\n");
861        assert_eq!(meta["after"], "updated\n");
862        assert_eq!(meta["file_path"], "ctx.txt");
863    }
864}