1mod 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
47pub const MAX_OUTPUT_SIZE: usize = 100 * 1024; pub const MAX_READ_LINES: usize = 2000;
52
53pub 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#[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 #[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
204pub 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 builtin::register_builtins(®istry);
256 builtin::register_batch(®istry);
258 builtin::register_program(®istry);
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 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 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 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 pub fn get_artifact(&self, artifact_uri: &str) -> Option<ToolArtifact> {
359 self.registry.get_artifact(artifact_uri)
360 }
361
362 pub fn artifact_store(&self) -> ArtifactStore {
364 self.registry.artifact_store()
365 }
366
367 pub fn set_trace_sink(&self, sink: Arc<dyn crate::trace::TraceSink>) {
369 self.registry.set_trace_sink(sink);
370 }
371
372 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 pub fn unregister_tools_by_prefix(&self, prefix: &str) {
391 self.registry.unregister_by_prefix(prefix);
392 }
393
394 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 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 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 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}