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 agent_dir_script_tool;
13mod artifacts;
14pub(crate) mod builtin;
15pub(crate) mod process;
16mod program_tool;
17mod registry;
18mod selector;
19pub mod skill;
20pub mod task;
21mod types;
22
23pub use agent_dir_script_tool::AgentDirScriptTool;
24pub use artifacts::{ArtifactStore, ArtifactStoreLimits, ToolArtifact};
25pub(crate) use builtin::register_skill;
26pub use builtin::{
27    register_generate_object, register_program, register_program_with_catalog, register_task,
28    register_task_with_mcp,
29};
30pub use program_tool::ProgramTool;
31pub use registry::ToolRegistry;
32pub use selector::{select_tools_for_messages, select_tools_for_prompt};
33pub use task::{
34    parallel_task_params_schema, task_params_schema, ParallelTaskParams, ParallelTaskTool,
35    TaskExecutor, TaskParams, TaskResult, TaskTool,
36};
37pub use types::{Tool, ToolContext, ToolErrorKind, ToolEventSender, ToolOutput, ToolStreamEvent};
38
39use crate::file_history::{self, FileHistory};
40use crate::llm::ToolDefinition;
41use crate::text::truncate_utf8;
42use anyhow::Result;
43use serde::{Deserialize, Serialize};
44use std::collections::HashMap;
45use std::path::PathBuf;
46use std::sync::Arc;
47
48/// Maximum output size in bytes before truncation
49pub const MAX_OUTPUT_SIZE: usize = 100 * 1024; // 100KB
50
51/// Maximum lines to read from a file
52pub const MAX_READ_LINES: usize = 2000;
53
54/// Maximum line length before truncation
55pub const MAX_LINE_LENGTH: usize = 2000;
56
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub(crate) struct ToolOutputArtifact {
59    pub artifact_id: String,
60    pub artifact_uri: String,
61    pub original_bytes: usize,
62    pub shown_bytes: usize,
63}
64
65#[derive(Debug, Clone)]
66pub(crate) struct TruncatedToolOutput {
67    pub content: String,
68    pub artifact: Option<ToolOutputArtifact>,
69}
70
71pub(crate) fn truncate_tool_output_with_artifact(
72    tool_name: &str,
73    output: &str,
74) -> TruncatedToolOutput {
75    if output.len() <= MAX_OUTPUT_SIZE {
76        return TruncatedToolOutput {
77            content: output.to_string(),
78            artifact: None,
79        };
80    }
81
82    let shown = truncate_utf8(output, MAX_OUTPUT_SIZE);
83    let artifact = tool_output_artifact(tool_name, output, shown.len());
84    let artifact_uri = artifact.artifact_uri.clone();
85    let content = format!(
86        "{}\n\n[tool output truncated: showing the first {} of {} bytes. Full output artifact: {}. Use narrower arguments such as offset/limit or filtering when possible.]",
87        shown,
88        shown.len(),
89        output.len(),
90        artifact_uri,
91    );
92
93    TruncatedToolOutput {
94        content,
95        artifact: Some(artifact),
96    }
97}
98
99pub(crate) fn tool_output_artifact(
100    tool_name: &str,
101    output: &str,
102    shown_bytes: usize,
103) -> ToolOutputArtifact {
104    use std::hash::{Hash, Hasher};
105
106    let mut hasher = std::collections::hash_map::DefaultHasher::new();
107    tool_name.hash(&mut hasher);
108    output.len().hash(&mut hasher);
109    output.hash(&mut hasher);
110    let digest = hasher.finish();
111    let sanitized_tool = tool_name
112        .chars()
113        .map(|ch| {
114            if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
115                ch
116            } else {
117                '_'
118            }
119        })
120        .collect::<String>();
121    let artifact_id = format!("tool-output:{sanitized_tool}:{digest:016x}");
122    let artifact_uri = format!("a3s://tool-output/{sanitized_tool}/{digest:016x}");
123
124    ToolOutputArtifact {
125        artifact_id,
126        artifact_uri,
127        original_bytes: output.len(),
128        shown_bytes,
129    }
130}
131
132pub(crate) fn merge_tool_output_artifact_metadata(
133    metadata: Option<serde_json::Value>,
134    artifact: &ToolOutputArtifact,
135) -> serde_json::Value {
136    let artifact_json = serde_json::json!({
137        "artifact_id": artifact.artifact_id,
138        "artifact_uri": artifact.artifact_uri,
139        "original_bytes": artifact.original_bytes,
140        "shown_bytes": artifact.shown_bytes,
141    });
142
143    match metadata {
144        Some(serde_json::Value::Object(mut object)) => {
145            object.insert("artifact".to_string(), artifact_json);
146            serde_json::Value::Object(object)
147        }
148        Some(value) => serde_json::json!({
149            "artifact": artifact_json,
150            "previous_metadata": value,
151        }),
152        None => serde_json::json!({
153            "artifact": artifact_json,
154        }),
155    }
156}
157
158/// Tool execution result returned by direct tool execution.
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct ToolResult {
161    pub name: String,
162    pub output: String,
163    pub exit_code: i32,
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub metadata: Option<serde_json::Value>,
166    /// Image attachments from tool execution (multi-modal output).
167    #[serde(skip)]
168    pub images: Vec<crate::llm::Attachment>,
169    /// Structured discriminant for tool failures. Populated by built-in
170    /// tools that can map their failure into a typed [`ToolErrorKind`]
171    /// (e.g. `edit`/`patch` setting `VersionConflict` on a CAS rejection
172    /// from `WorkspaceError`). Forwarded to the SDK so callers can react
173    /// programmatically without parsing `output`.
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub error_kind: Option<types::ToolErrorKind>,
176}
177
178impl ToolResult {
179    pub fn success(name: &str, output: String) -> Self {
180        Self {
181            name: name.to_string(),
182            output,
183            exit_code: 0,
184            metadata: None,
185            images: Vec::new(),
186            error_kind: None,
187        }
188    }
189
190    pub fn error(name: &str, message: String) -> Self {
191        Self {
192            name: name.to_string(),
193            output: message,
194            exit_code: 1,
195            metadata: None,
196            images: Vec::new(),
197            error_kind: None,
198        }
199    }
200}
201
202impl From<ToolOutput> for ToolResult {
203    fn from(output: ToolOutput) -> Self {
204        Self {
205            name: String::new(),
206            output: output.content,
207            exit_code: if output.success { 0 } else { 1 },
208            metadata: output.metadata,
209            images: output.images,
210            error_kind: output.error_kind,
211        }
212    }
213}
214
215/// Tool executor with workspace sandboxing
216///
217/// This is the main entry point for tool execution. It wraps the ToolRegistry
218/// and captures file snapshots before write/edit/patch operations.
219pub struct ToolExecutor {
220    workspace: PathBuf,
221    registry: Arc<ToolRegistry>,
222    file_history: Arc<FileHistory>,
223    command_env: Option<Arc<HashMap<String, String>>>,
224    workspace_services: Arc<crate::workspace::WorkspaceServices>,
225}
226
227/// Build a log line for a tool invocation that excludes argument *values*.
228///
229/// Argument values (full bash commands, file contents written by `write`/`edit`)
230/// can contain secrets, so the summary records only the tool name, the sorted
231/// argument field names, and the serialized payload size — never the values. This
232/// keeps the always-on `info!` tool trace (also exported to OTLP) compliant with
233/// the "never log secrets" boundary. Use `trace!` for full args when debugging.
234fn redacted_tool_log_summary(name: &str, args: &serde_json::Value) -> String {
235    let arg_keys: Vec<&str> = match args.as_object() {
236        Some(map) => {
237            let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
238            keys.sort_unstable();
239            keys
240        }
241        None => Vec::new(),
242    };
243    format!(
244        "Executing tool: {} (arg_keys={:?}, {} bytes)",
245        name,
246        arg_keys,
247        args.to_string().len()
248    )
249}
250
251/// Log a tool invocation without leaking argument values. See
252/// [`redacted_tool_log_summary`] for the redaction rationale.
253fn log_tool_invocation(name: &str, args: &serde_json::Value) {
254    tracing::info!("{}", redacted_tool_log_summary(name, args));
255    tracing::trace!("Tool {} full args: {}", name, args);
256}
257
258impl ToolExecutor {
259    pub fn new(workspace: String) -> Self {
260        let workspace_services =
261            crate::workspace::WorkspaceServices::local(PathBuf::from(&workspace));
262        Self::build(
263            workspace,
264            None,
265            ArtifactStoreLimits::default(),
266            workspace_services,
267        )
268    }
269
270    pub fn new_with_artifact_limits(
271        workspace: String,
272        artifact_limits: ArtifactStoreLimits,
273    ) -> Self {
274        let workspace_services =
275            crate::workspace::WorkspaceServices::local(PathBuf::from(&workspace));
276        Self::build(workspace, None, artifact_limits, workspace_services)
277    }
278
279    pub fn new_with_workspace_services(
280        workspace: String,
281        workspace_services: Arc<crate::workspace::WorkspaceServices>,
282    ) -> Self {
283        Self::build(
284            workspace,
285            None,
286            ArtifactStoreLimits::default(),
287            workspace_services,
288        )
289    }
290
291    pub fn new_with_workspace_services_and_artifact_limits(
292        workspace: String,
293        workspace_services: Arc<crate::workspace::WorkspaceServices>,
294        artifact_limits: ArtifactStoreLimits,
295    ) -> Self {
296        Self::build(workspace, None, artifact_limits, workspace_services)
297    }
298
299    fn build(
300        workspace: String,
301        command_env: Option<HashMap<String, String>>,
302        artifact_limits: ArtifactStoreLimits,
303        workspace_services: Arc<crate::workspace::WorkspaceServices>,
304    ) -> Self {
305        let workspace_path = PathBuf::from(&workspace);
306        let command_env = command_env.map(Arc::new);
307        let registry = Arc::new(ToolRegistry::with_artifact_limits_and_workspace_services(
308            workspace_path.clone(),
309            artifact_limits,
310            Arc::clone(&workspace_services),
311        ));
312        if let Some(env) = command_env.clone() {
313            registry.set_command_env(env);
314        }
315
316        // Register native Rust built-in tools — only those whose required
317        // workspace capability is available, so the model never sees a tool
318        // the backend cannot service.
319        builtin::register_builtins(&registry, &workspace_services.capabilities());
320        // Batch tool requires Arc<ToolRegistry>, registered separately
321        builtin::register_batch(&registry);
322        builtin::register_program(&registry);
323
324        Self {
325            workspace: workspace_path,
326            registry,
327            file_history: Arc::new(FileHistory::new(500)),
328            command_env,
329            workspace_services,
330        }
331    }
332
333    fn check_workspace_boundary(
334        name: &str,
335        args: &serde_json::Value,
336        ctx: &ToolContext,
337    ) -> Result<()> {
338        let path_field = match name {
339            "read" | "write" | "edit" | "patch" => Some("file_path"),
340            "ls" | "grep" | "glob" => Some("path"),
341            _ => None,
342        };
343
344        if let Some(field) = path_field {
345            if let Some(path_str) = args.get(field).and_then(|v| v.as_str()) {
346                ctx.resolve_workspace_path(path_str).map_err(|e| {
347                    anyhow::anyhow!(
348                        "Workspace boundary check failed for tool '{}' path '{}': {}",
349                        name,
350                        path_str,
351                        e
352                    )
353                })?;
354            }
355        }
356
357        Ok(())
358    }
359
360    pub fn workspace(&self) -> &PathBuf {
361        &self.workspace
362    }
363
364    pub fn registry(&self) -> &Arc<ToolRegistry> {
365        &self.registry
366    }
367
368    /// Get a stored tool artifact by URI.
369    pub fn get_artifact(&self, artifact_uri: &str) -> Option<ToolArtifact> {
370        self.registry.get_artifact(artifact_uri)
371    }
372
373    /// Return a clone of the executor's artifact store handle.
374    pub fn artifact_store(&self) -> ArtifactStore {
375        self.registry.artifact_store()
376    }
377
378    /// Replace the sink used for compact execution trace events.
379    pub fn set_trace_sink(&self, sink: Arc<dyn crate::trace::TraceSink>) {
380        self.registry.set_trace_sink(sink);
381    }
382
383    /// Return the currently configured execution trace sink.
384    pub fn trace_sink(&self) -> Arc<dyn crate::trace::TraceSink> {
385        self.registry.trace_sink()
386    }
387
388    pub fn command_env(&self) -> Option<Arc<HashMap<String, String>>> {
389        self.command_env.clone()
390    }
391
392    pub fn register_dynamic_tool(&self, tool: Arc<dyn Tool>) {
393        self.registry.register(tool);
394    }
395
396    pub fn unregister_dynamic_tool(&self, name: &str) {
397        self.registry.unregister(name);
398    }
399
400    /// Unregister all dynamic tools whose names start with the given prefix.
401    pub fn unregister_tools_by_prefix(&self, prefix: &str) {
402        self.registry.unregister_by_prefix(prefix);
403    }
404
405    /// Replace the model-visible `program` tool with a custom PTC catalog.
406    pub fn register_program_catalog(&self, catalog: crate::program::ProgramCatalog) {
407        builtin::register_program_with_catalog(&self.registry, catalog);
408    }
409
410    fn capture_snapshot(&self, name: &str, args: &serde_json::Value) {
411        let Some(local_root) = self.workspace_services.local_root() else {
412            return;
413        };
414
415        if let Some(file_path) = file_history::extract_file_path(name, args) {
416            let workspace_path = match self.workspace_services.normalize_path(&file_path) {
417                Ok(path) => path,
418                Err(e) => {
419                    tracing::warn!(
420                        "Skipping file snapshot for invalid path {}: {}",
421                        file_path,
422                        e
423                    );
424                    return;
425                }
426            };
427            let path_to_read = if workspace_path.is_root() {
428                local_root.to_path_buf()
429            } else {
430                local_root.join(workspace_path.as_str())
431            };
432
433            if !path_to_read.exists() {
434                self.file_history.save_snapshot(&file_path, "", name);
435                return;
436            }
437
438            match std::fs::read_to_string(&path_to_read) {
439                Ok(content) => {
440                    self.file_history.save_snapshot(&file_path, &content, name);
441                    tracing::debug!(
442                        "Captured file snapshot for {} before {} (version {})",
443                        file_path,
444                        name,
445                        self.file_history.list_versions(&file_path).len() - 1,
446                    );
447                }
448                Err(e) => {
449                    tracing::warn!("Failed to capture snapshot for {}: {}", file_path, e);
450                }
451            }
452        }
453    }
454
455    pub async fn execute(&self, name: &str, args: &serde_json::Value) -> Result<ToolResult> {
456        let ctx = self.registry.context();
457        if let Err(e) = Self::check_workspace_boundary(name, args, &ctx) {
458            return Ok(ToolResult::error(name, e.to_string()));
459        }
460
461        log_tool_invocation(name, args);
462        self.capture_snapshot(name, args);
463        let mut result = self.registry.execute_with_context(name, args, &ctx).await;
464        if let Ok(ref mut r) = result {
465            self.attach_diff_metadata(name, args, r);
466        }
467        match &result {
468            Ok(r) => tracing::info!("Tool {} completed with exit_code={}", name, r.exit_code),
469            Err(e) => tracing::error!("Tool {} failed: {}", name, e),
470        }
471        result
472    }
473
474    pub async fn execute_with_context(
475        &self,
476        name: &str,
477        args: &serde_json::Value,
478        ctx: &ToolContext,
479    ) -> Result<ToolResult> {
480        Self::check_workspace_boundary(name, args, ctx)?;
481        log_tool_invocation(name, args);
482        self.capture_snapshot(name, args);
483        let mut result = self.registry.execute_with_context(name, args, ctx).await;
484        if let Ok(ref mut r) = result {
485            self.attach_diff_metadata(name, args, r);
486        }
487        match &result {
488            Ok(r) => tracing::info!("Tool {} completed with exit_code={}", name, r.exit_code),
489            Err(e) => tracing::error!("Tool {} failed: {}", name, e),
490        }
491        result
492    }
493
494    fn attach_diff_metadata(&self, name: &str, args: &serde_json::Value, result: &mut ToolResult) {
495        if !file_history::is_file_modifying_tool(name) {
496            return;
497        }
498        let Some(file_path) = file_history::extract_file_path(name, args) else {
499            return;
500        };
501        // Only store file_path in metadata, let translate_event read the actual content
502        // using the session's correct workspace
503        let meta = result.metadata.get_or_insert_with(|| serde_json::json!({}));
504        meta["file_path"] = serde_json::Value::String(file_path);
505    }
506
507    pub fn definitions(&self) -> Vec<ToolDefinition> {
508        self.registry.definitions()
509    }
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515    use crate::workspace::{
516        CommandOutput, CommandRequest, WorkspaceCommandRunner, WorkspaceDirEntry, WorkspaceError,
517        WorkspaceFileSystem, WorkspaceFileType, WorkspacePath, WorkspaceRef, WorkspaceResult,
518        WorkspaceServices, WorkspaceWriteOutcome,
519    };
520    use async_trait::async_trait;
521    use std::sync::RwLock;
522
523    #[test]
524    fn test_redacted_tool_log_summary_omits_values() {
525        let args = serde_json::json!({
526            "command": "export AWS_SECRET_ACCESS_KEY=AKIAIOSFODNN7EXAMPLE && deploy",
527            "timeout": 30
528        });
529        let summary = redacted_tool_log_summary("bash", &args);
530        // Field names and size are logged...
531        assert!(summary.contains("bash"));
532        assert!(summary.contains("command"));
533        assert!(summary.contains("timeout"));
534        assert!(summary.contains("bytes"));
535        // ...but never the values (the secret must not appear).
536        assert!(!summary.contains("AKIAIOSFODNN7EXAMPLE"));
537        assert!(!summary.contains("deploy"));
538    }
539
540    #[test]
541    fn test_redacted_tool_log_summary_handles_non_object_args() {
542        let summary = redacted_tool_log_summary("noop", &serde_json::json!("raw string"));
543        assert!(summary.contains("noop"));
544        assert!(summary.contains("arg_keys=[]"));
545        assert!(!summary.contains("raw string"));
546    }
547
548    struct LargeArtifactTool;
549
550    #[async_trait]
551    impl Tool for LargeArtifactTool {
552        fn name(&self) -> &str {
553            "large_artifact"
554        }
555
556        fn description(&self) -> &str {
557            "Produces large output for artifact API tests"
558        }
559
560        fn parameters(&self) -> serde_json::Value {
561            serde_json::json!({
562                "type": "object",
563                "additionalProperties": false,
564                "properties": {},
565                "required": []
566            })
567        }
568
569        async fn execute(
570            &self,
571            args: &serde_json::Value,
572            _ctx: &ToolContext,
573        ) -> Result<ToolOutput> {
574            let suffix = args
575                .get("suffix")
576                .and_then(|value| value.as_str())
577                .unwrap_or_default();
578            Ok(ToolOutput::success(format!(
579                "{}{}",
580                "z".repeat(MAX_OUTPUT_SIZE + 1),
581                suffix
582            )))
583        }
584    }
585
586    struct EchoTool;
587
588    #[async_trait]
589    impl Tool for EchoTool {
590        fn name(&self) -> &str {
591            "echo"
592        }
593
594        fn description(&self) -> &str {
595            "Echoes the message argument"
596        }
597
598        fn parameters(&self) -> serde_json::Value {
599            serde_json::json!({
600                "type": "object",
601                "additionalProperties": false,
602                "properties": {
603                    "message": { "type": "string" }
604                },
605                "required": ["message"]
606            })
607        }
608
609        async fn execute(
610            &self,
611            args: &serde_json::Value,
612            _ctx: &ToolContext,
613        ) -> Result<ToolOutput> {
614            Ok(ToolOutput::success(
615                args["message"].as_str().unwrap_or_default(),
616            ))
617        }
618    }
619
620    #[derive(Default)]
621    struct MemoryWorkspaceFs {
622        files: RwLock<HashMap<String, String>>,
623    }
624
625    impl MemoryWorkspaceFs {
626        fn insert(&self, path: &str, content: &str) {
627            self.files
628                .write()
629                .unwrap()
630                .insert(path.to_string(), content.to_string());
631        }
632
633        fn get(&self, path: &str) -> Option<String> {
634            self.files.read().unwrap().get(path).cloned()
635        }
636    }
637
638    #[async_trait]
639    impl WorkspaceFileSystem for MemoryWorkspaceFs {
640        async fn read_text(&self, path: &WorkspacePath) -> WorkspaceResult<String> {
641            self.files
642                .read()
643                .unwrap()
644                .get(path.as_str())
645                .cloned()
646                .ok_or_else(|| WorkspaceError::NotFound {
647                    path: path.as_str().to_string(),
648                })
649        }
650
651        async fn write_text(
652            &self,
653            path: &WorkspacePath,
654            content: &str,
655        ) -> WorkspaceResult<WorkspaceWriteOutcome> {
656            self.insert(path.as_str(), content);
657            Ok(WorkspaceWriteOutcome {
658                bytes: content.len(),
659                lines: content.lines().count(),
660            })
661        }
662
663        async fn list_dir(&self, path: &WorkspacePath) -> WorkspaceResult<Vec<WorkspaceDirEntry>> {
664            let prefix = if path.is_root() {
665                String::new()
666            } else {
667                format!("{}/", path.as_str())
668            };
669            let files = self.files.read().unwrap();
670            let mut entries = Vec::new();
671            for name in files.keys() {
672                if !name.starts_with(&prefix) {
673                    continue;
674                }
675                let remaining = &name[prefix.len()..];
676                if remaining.is_empty() || remaining.contains('/') {
677                    continue;
678                }
679                entries.push(WorkspaceDirEntry {
680                    name: remaining.to_string(),
681                    kind: WorkspaceFileType::File,
682                    size: files
683                        .get(name)
684                        .map(|content| content.len() as u64)
685                        .unwrap_or(0),
686                });
687            }
688            Ok(entries)
689        }
690    }
691
692    struct MockCommandRunner;
693
694    #[async_trait]
695    impl WorkspaceCommandRunner for MockCommandRunner {
696        async fn exec(&self, request: CommandRequest) -> Result<CommandOutput> {
697            Ok(CommandOutput {
698                output: format!("remote: {}\n", request.command),
699                exit_code: 0,
700                timed_out: false,
701            })
702        }
703    }
704
705    #[tokio::test]
706    async fn test_tool_executor_creation() {
707        let executor = ToolExecutor::new("/tmp".to_string());
708        // Baseline tools on a raw ToolExecutor: 13
709        assert_eq!(executor.registry.len(), 13);
710    }
711
712    #[tokio::test]
713    async fn test_unknown_tool() {
714        let executor = ToolExecutor::new("/tmp".to_string());
715        let result = executor
716            .execute("unknown", &serde_json::json!({}))
717            .await
718            .unwrap();
719        assert_eq!(result.exit_code, 1);
720        assert!(result.output.contains("Unknown tool"));
721    }
722
723    #[tokio::test]
724    async fn test_builtin_tools_registered() {
725        let executor = ToolExecutor::new("/tmp".to_string());
726        let definitions = executor.definitions();
727
728        assert!(definitions.iter().any(|t| t.name == "bash"));
729        assert!(definitions.iter().any(|t| t.name == "read"));
730        assert!(definitions.iter().any(|t| t.name == "write"));
731        assert!(definitions.iter().any(|t| t.name == "edit"));
732        assert!(definitions.iter().any(|t| t.name == "grep"));
733        assert!(definitions.iter().any(|t| t.name == "glob"));
734        assert!(definitions.iter().any(|t| t.name == "ls"));
735        assert!(definitions.iter().any(|t| t.name == "patch"));
736        assert!(definitions.iter().any(|t| t.name == "web_fetch"));
737        assert!(definitions.iter().any(|t| t.name == "web_search"));
738        assert!(definitions.iter().any(|t| t.name == "batch"));
739    }
740
741    #[tokio::test]
742    async fn test_builtin_file_tools_use_workspace_services() {
743        let fs = Arc::new(MemoryWorkspaceFs::default());
744        fs.insert("remote.txt", "first\nsecond\n");
745        let services = WorkspaceServices::builder(
746            WorkspaceRef::new("browser-workspace", "browser://workspace"),
747            fs.clone(),
748        )
749        .build();
750        let executor = ToolExecutor::new_with_workspace_services_and_artifact_limits(
751            "/server/local-placeholder".to_string(),
752            services,
753            ArtifactStoreLimits::default(),
754        );
755        let definitions = executor.definitions();
756        assert!(definitions.iter().any(|tool| tool.name == "read"));
757        assert!(definitions.iter().any(|tool| tool.name == "write"));
758        assert!(definitions.iter().any(|tool| tool.name == "ls"));
759        assert!(!definitions.iter().any(|tool| tool.name == "bash"));
760        assert!(!definitions.iter().any(|tool| tool.name == "grep"));
761        assert!(definitions.iter().any(|tool| tool.name == "edit"));
762        assert!(definitions.iter().any(|tool| tool.name == "patch"));
763
764        let read = executor
765            .execute("read", &serde_json::json!({"file_path": "remote.txt"}))
766            .await
767            .unwrap();
768        assert_eq!(read.exit_code, 0);
769        assert!(read.output.contains("first"));
770
771        let write = executor
772            .execute(
773                "write",
774                &serde_json::json!({"file_path": "created.txt", "content": "remote write\n"}),
775            )
776            .await
777            .unwrap();
778        assert_eq!(write.exit_code, 0);
779        assert_eq!(fs.get("created.txt").unwrap(), "remote write\n");
780
781        let ls = executor
782            .execute("ls", &serde_json::json!({}))
783            .await
784            .unwrap();
785        assert_eq!(ls.exit_code, 0);
786        assert!(ls.output.contains("created.txt"));
787        assert!(ls.output.contains("remote.txt"));
788    }
789
790    #[tokio::test]
791    async fn test_bash_uses_workspace_command_runner() {
792        let fs = Arc::new(MemoryWorkspaceFs::default());
793        let fs_backend: Arc<dyn WorkspaceFileSystem> = fs;
794        let services = WorkspaceServices::builder(
795            WorkspaceRef::new("remote-workspace", "remote://workspace"),
796            fs_backend,
797        )
798        .command_runner(Arc::new(MockCommandRunner))
799        .build();
800        let executor = ToolExecutor::new_with_workspace_services_and_artifact_limits(
801            "/server/local-placeholder".to_string(),
802            services,
803            ArtifactStoreLimits::default(),
804        );
805        assert!(executor
806            .definitions()
807            .iter()
808            .any(|tool| tool.name == "bash"));
809
810        let result = executor
811            .execute("bash", &serde_json::json!({"command": "pwd"}))
812            .await
813            .unwrap();
814
815        assert_eq!(result.exit_code, 0);
816        assert_eq!(result.output, "remote: pwd\n");
817    }
818
819    #[tokio::test]
820    async fn test_command_env_is_available_on_default_context() {
821        let temp = tempfile::tempdir().unwrap();
822        let mut env = HashMap::new();
823        env.insert(
824            "A3S_COMMAND_ENV_TEST".to_string(),
825            "registry-env".to_string(),
826        );
827
828        let executor = ToolExecutor::new(temp.path().to_string_lossy().to_string());
829        executor.registry().set_command_env(Arc::new(env));
830        let context = executor.registry().context();
831        assert_eq!(
832            context
833                .command_env
834                .as_ref()
835                .and_then(|env| env.get("A3S_COMMAND_ENV_TEST"))
836                .map(String::as_str),
837            Some("registry-env")
838        );
839
840        #[cfg(windows)]
841        let command = "Write-Output $env:A3S_COMMAND_ENV_TEST";
842        #[cfg(not(windows))]
843        let command = "printf '%s' \"$A3S_COMMAND_ENV_TEST\"";
844
845        let result = executor
846            .execute("bash", &serde_json::json!({ "command": command }))
847            .await
848            .unwrap();
849
850        assert_eq!(result.exit_code, 0, "{}", result.output);
851        assert!(result.output.contains("registry-env"));
852    }
853
854    #[tokio::test]
855    async fn test_execute_applies_workspace_boundary_for_default_context() {
856        let workspace = tempfile::tempdir().unwrap();
857        let outside = tempfile::tempdir().unwrap();
858        std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
859
860        let executor = ToolExecutor::new(workspace.path().to_string_lossy().to_string());
861        let result = executor
862            .execute(
863                "grep",
864                &serde_json::json!({
865                    "pattern": "secret",
866                    "path": outside.path().to_string_lossy()
867                }),
868            )
869            .await
870            .unwrap();
871
872        assert_eq!(result.exit_code, 1);
873        assert!(result.output.contains("Workspace boundary"));
874        assert!(result.output.contains("escapes workspace"));
875    }
876
877    #[test]
878    fn test_tool_result_success() {
879        let result = ToolResult::success("test_tool", "output text".to_string());
880        assert_eq!(result.name, "test_tool");
881        assert_eq!(result.output, "output text");
882        assert_eq!(result.exit_code, 0);
883        assert!(result.metadata.is_none());
884    }
885
886    #[test]
887    fn test_tool_result_error() {
888        let result = ToolResult::error("test_tool", "error message".to_string());
889        assert_eq!(result.name, "test_tool");
890        assert_eq!(result.output, "error message");
891        assert_eq!(result.exit_code, 1);
892        assert!(result.metadata.is_none());
893    }
894
895    #[test]
896    fn test_tool_result_from_tool_output_success() {
897        let output = ToolOutput {
898            content: "success content".to_string(),
899            success: true,
900            metadata: None,
901            images: Vec::new(),
902            error_kind: None,
903        };
904        let result: ToolResult = output.into();
905        assert_eq!(result.output, "success content");
906        assert_eq!(result.exit_code, 0);
907        assert!(result.metadata.is_none());
908    }
909
910    #[test]
911    fn test_tool_result_from_tool_output_failure() {
912        let output = ToolOutput {
913            content: "failure content".to_string(),
914            success: false,
915            metadata: Some(serde_json::json!({"error": "test"})),
916            images: Vec::new(),
917            error_kind: None,
918        };
919        let result: ToolResult = output.into();
920        assert_eq!(result.output, "failure content");
921        assert_eq!(result.exit_code, 1);
922        assert_eq!(result.metadata, Some(serde_json::json!({"error": "test"})));
923    }
924
925    #[test]
926    fn test_tool_result_metadata_propagation() {
927        let output = ToolOutput::success("content")
928            .with_metadata(serde_json::json!({"_load_skill": true, "skill_name": "test"}));
929        let result: ToolResult = output.into();
930        assert_eq!(result.exit_code, 0);
931        let meta = result.metadata.unwrap();
932        assert_eq!(meta["_load_skill"], true);
933        assert_eq!(meta["skill_name"], "test");
934    }
935
936    #[test]
937    fn test_tool_executor_workspace() {
938        let executor = ToolExecutor::new("/test/workspace".to_string());
939        assert_eq!(executor.workspace().to_str().unwrap(), "/test/workspace");
940    }
941
942    #[test]
943    fn test_tool_executor_registry() {
944        let executor = ToolExecutor::new("/tmp".to_string());
945        let registry = executor.registry();
946        // Baseline tools on a raw ToolExecutor: 13
947        assert_eq!(registry.len(), 13);
948    }
949
950    #[tokio::test]
951    async fn test_tool_executor_get_artifact() {
952        let executor = ToolExecutor::new("/tmp".to_string());
953        executor.register_dynamic_tool(Arc::new(LargeArtifactTool));
954
955        let result = executor
956            .execute("large_artifact", &serde_json::json!({}))
957            .await
958            .unwrap();
959
960        let artifact_uri = result.metadata.as_ref().unwrap()["artifact"]["artifact_uri"]
961            .as_str()
962            .unwrap();
963        let artifact = executor.get_artifact(artifact_uri).expect("artifact");
964        assert_eq!(artifact.tool_name, "large_artifact");
965        assert_eq!(artifact.content.len(), MAX_OUTPUT_SIZE + 1);
966        assert!(executor.artifact_store().get(artifact_uri).is_some());
967    }
968
969    #[tokio::test]
970    async fn test_tool_executor_respects_artifact_limits() {
971        let executor = ToolExecutor::new_with_artifact_limits(
972            "/tmp".to_string(),
973            ArtifactStoreLimits {
974                max_artifacts: 1,
975                max_bytes: usize::MAX,
976            },
977        );
978        executor.register_dynamic_tool(Arc::new(LargeArtifactTool));
979
980        let first = executor
981            .execute("large_artifact", &serde_json::json!({}))
982            .await
983            .unwrap();
984        let first_uri = first.metadata.as_ref().unwrap()["artifact"]["artifact_uri"]
985            .as_str()
986            .unwrap()
987            .to_string();
988
989        executor
990            .execute("large_artifact", &serde_json::json!({ "suffix": "again" }))
991            .await
992            .unwrap();
993
994        assert_eq!(executor.artifact_store().limits().max_artifacts, 1);
995        assert_eq!(executor.artifact_store().len(), 1);
996        assert!(executor.get_artifact(&first_uri).is_none());
997    }
998
999    #[tokio::test]
1000    async fn test_tool_executor_register_program_catalog_keeps_script_only_program_tool() {
1001        let executor = ToolExecutor::new("/tmp".to_string());
1002        let trace_sink = crate::trace::InMemoryTraceSink::default();
1003        executor.set_trace_sink(Arc::new(trace_sink.clone()));
1004        executor.register_dynamic_tool(Arc::new(EchoTool));
1005        let mut catalog = crate::program::ProgramCatalog::new();
1006        catalog.register(
1007            crate::program::ProgramTemplate::new("custom_echo", "Run a custom echo program")
1008                .with_parameter(crate::program::ProgramParameter::required(
1009                    "message",
1010                    "Message to echo",
1011                ))
1012                .with_step(
1013                    crate::program::ProgramStepTemplate::new(
1014                        "echo",
1015                        serde_json::json!({ "message": "{{message}}" }),
1016                    )
1017                    .with_label("echo_message"),
1018                ),
1019        );
1020        executor.register_program_catalog(catalog);
1021
1022        let result = executor
1023            .execute(
1024                "program",
1025                &serde_json::json!({
1026                    "name": "custom_echo",
1027                    "inputs": {
1028                        "message": "hello from catalog"
1029                    }
1030                }),
1031            )
1032            .await
1033            .unwrap();
1034
1035        assert_eq!(result.exit_code, 1);
1036        assert!(result.output.contains("type parameter is required"));
1037
1038        let events = trace_sink.events();
1039        assert!(events.iter().any(|event| {
1040            event.kind == crate::trace::TraceEventKind::ToolExecution && event.name == "program"
1041        }));
1042        assert!(!events.iter().any(|event| {
1043            event.kind == crate::trace::TraceEventKind::ToolExecution && event.name == "echo"
1044        }));
1045    }
1046
1047    #[test]
1048    fn test_max_output_size_constant() {
1049        assert_eq!(MAX_OUTPUT_SIZE, 100 * 1024);
1050    }
1051
1052    #[test]
1053    fn test_max_read_lines_constant() {
1054        assert_eq!(MAX_READ_LINES, 2000);
1055    }
1056
1057    #[test]
1058    fn test_max_line_length_constant() {
1059        assert_eq!(MAX_LINE_LENGTH, 2000);
1060    }
1061
1062    #[test]
1063    fn test_truncate_tool_output_with_artifact_reference() {
1064        let output = "x".repeat(MAX_OUTPUT_SIZE + 1);
1065        let truncated = truncate_tool_output_with_artifact("test/tool", &output);
1066
1067        let artifact = truncated.artifact.expect("artifact");
1068        assert!(truncated.content.contains("Full output artifact:"));
1069        assert_eq!(artifact.original_bytes, MAX_OUTPUT_SIZE + 1);
1070        assert_eq!(artifact.shown_bytes, MAX_OUTPUT_SIZE);
1071        assert!(artifact.artifact_id.starts_with("tool-output:test_tool:"));
1072        assert!(artifact
1073            .artifact_uri
1074            .starts_with("a3s://tool-output/test_tool/"));
1075    }
1076
1077    #[test]
1078    fn test_tool_result_clone() {
1079        let result = ToolResult::success("test", "output".to_string());
1080        let cloned = result.clone();
1081        assert_eq!(result.name, cloned.name);
1082        assert_eq!(result.output, cloned.output);
1083        assert_eq!(result.exit_code, cloned.exit_code);
1084        assert_eq!(result.metadata, cloned.metadata);
1085    }
1086
1087    #[test]
1088    fn test_tool_result_debug() {
1089        let result = ToolResult::success("test", "output".to_string());
1090        let debug_str = format!("{:?}", result);
1091        assert!(debug_str.contains("test"));
1092        assert!(debug_str.contains("output"));
1093    }
1094
1095    #[tokio::test]
1096    async fn test_execute_attaches_diff_metadata() {
1097        use tempfile::TempDir;
1098        let dir = TempDir::new().unwrap();
1099        let file = dir.path().join("hello.txt");
1100        std::fs::write(&file, "before content\n").unwrap();
1101
1102        let executor = ToolExecutor::new(dir.path().to_str().unwrap().to_string());
1103        let args = serde_json::json!({
1104            "file_path": "hello.txt",
1105            "content": "after content\n"
1106        });
1107        let result = executor.execute("write", &args).await.unwrap();
1108
1109        let meta = result.metadata.expect("metadata should be present");
1110        assert_eq!(meta["before"], "before content\n");
1111        assert_eq!(meta["after"], "after content\n");
1112        assert_eq!(meta["file_path"], "hello.txt");
1113    }
1114
1115    #[tokio::test]
1116    async fn test_execute_with_context_attaches_diff_metadata() {
1117        use tempfile::TempDir;
1118        let dir = TempDir::new().unwrap();
1119        let canonical_dir = dir.path().canonicalize().unwrap();
1120        let file = canonical_dir.join("ctx.txt");
1121        std::fs::write(&file, "original\n").unwrap();
1122
1123        let executor = ToolExecutor::new(canonical_dir.to_str().unwrap().to_string());
1124        let ctx = ToolContext::new(canonical_dir.clone());
1125        let args = serde_json::json!({
1126            "file_path": "ctx.txt",
1127            "content": "updated\n"
1128        });
1129        let result = executor
1130            .execute_with_context("write", &args, &ctx)
1131            .await
1132            .unwrap();
1133        assert_eq!(result.exit_code, 0, "write tool failed: {}", result.output);
1134
1135        let meta = result.metadata.expect("metadata should be present");
1136        assert_eq!(meta["before"], "original\n");
1137        assert_eq!(meta["after"], "updated\n");
1138        assert_eq!(meta["file_path"], "ctx.txt");
1139    }
1140}