1mod 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
48pub const MAX_OUTPUT_SIZE: usize = 100 * 1024; pub const MAX_READ_LINES: usize = 2000;
53
54pub 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#[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 #[serde(skip)]
168 pub images: Vec<crate::llm::Attachment>,
169 #[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
215pub 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
227fn 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
251fn 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 builtin::register_builtins(®istry, &workspace_services.capabilities());
320 builtin::register_batch(®istry);
322 builtin::register_program(®istry);
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 pub fn get_artifact(&self, artifact_uri: &str) -> Option<ToolArtifact> {
370 self.registry.get_artifact(artifact_uri)
371 }
372
373 pub fn artifact_store(&self) -> ArtifactStore {
375 self.registry.artifact_store()
376 }
377
378 pub fn set_trace_sink(&self, sink: Arc<dyn crate::trace::TraceSink>) {
380 self.registry.set_trace_sink(sink);
381 }
382
383 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 pub fn unregister_tools_by_prefix(&self, prefix: &str) {
402 self.registry.unregister_by_prefix(prefix);
403 }
404
405 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 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 assert!(summary.contains("bash"));
532 assert!(summary.contains("command"));
533 assert!(summary.contains("timeout"));
534 assert!(summary.contains("bytes"));
535 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 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 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}