forge_agent/workflow/tools.rs
1//! Tool registry for external tool integration with process lifecycle management.
2//!
3//! The tools module provides a centralized registry for external tools (magellan, cargo, splice, etc.)
4//! that workflows can invoke by name. Tools are registered with their executable paths and default
5//! arguments, and can be invoked with additional arguments via ToolInvocation.
6//!
7//! # Process Guards
8//!
9//! The module implements RAII-based process lifecycle management through ProcessGuard, which
10//! automatically terminates spawned processes when dropped. This ensures proper cleanup even
11//! if errors occur during workflow execution.
12//!
13//! # Example
14//!
15//! ```ignore
16//! use forge_agent::workflow::tools::{Tool, ToolRegistry, ToolInvocation};
17//!
18//! let mut registry = ToolRegistry::new();
19//!
20//! // Register a tool
21//! let magellan = Tool::new(
22//! "magellan",
23//! "/usr/bin/magellan",
24//! vec!["--db".to_string(), ".forge/graph.db".to_string()]
25//! );
26//! registry.register(magellan)?;
27//!
28//! // Invoke the tool
29//! let invocation = ToolInvocation::new("magellan")
30//! .args(vec!["find".to_string(), "--name".to_string(), "symbol".to_string()]);
31//! let result = registry.invoke(&invocation).await?;
32//! ```
33
34use crate::workflow::rollback::ToolCompensation;
35use crate::workflow::task::{TaskContext, TaskError, TaskResult};
36use async_trait::async_trait;
37use serde::{Deserialize, Serialize};
38use std::collections::HashMap;
39use std::fmt;
40use std::path::PathBuf;
41use std::process::Command;
42use std::sync::atomic::{AtomicBool, Ordering};
43use std::sync::Arc;
44use std::time::Duration;
45
46/// A registered external tool.
47///
48/// Tools are registered with their executable path, default arguments, and description.
49/// When invoked, default arguments are combined with invocation-specific arguments.
50#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
51pub struct Tool {
52 /// Tool identifier (e.g., "magellan", "cargo", "splice")
53 pub name: String,
54 /// Path to the executable
55 pub executable: PathBuf,
56 /// Default arguments passed to every invocation
57 pub default_args: Vec<String>,
58 /// Human-readable description of the tool
59 pub description: String,
60}
61
62impl Tool {
63 /// Creates a new Tool with the given name and executable.
64 ///
65 /// # Arguments
66 ///
67 /// * `name` - Tool identifier
68 /// * `executable` - Path to the executable (can be relative or absolute)
69 ///
70 /// # Example
71 ///
72 /// ```
73 /// use forge_agent::workflow::tools::Tool;
74 /// use std::path::PathBuf;
75 ///
76 /// let tool = Tool::new("magellan", PathBuf::from("/usr/bin/magellan"));
77 /// ```
78 pub fn new(name: impl Into<String>, executable: impl Into<PathBuf>) -> Self {
79 Self {
80 name: name.into(),
81 executable: executable.into(),
82 default_args: Vec::new(),
83 description: String::new(),
84 }
85 }
86
87 /// Sets the default arguments for the tool.
88 ///
89 /// # Arguments
90 ///
91 /// * `args` - Vector of default argument strings
92 ///
93 /// # Returns
94 ///
95 /// Self for builder pattern chaining
96 ///
97 /// # Example
98 ///
99 /// ```
100 /// use forge_agent::workflow::tools::Tool;
101 ///
102 /// let tool = Tool::new("magellan", "/usr/bin/magellan")
103 /// .default_args(vec!["--db".to_string(), ".forge/graph.db".to_string()]);
104 /// ```
105 pub fn default_args(mut self, args: Vec<String>) -> Self {
106 self.default_args = args;
107 self
108 }
109
110 /// Sets the description for the tool.
111 ///
112 /// # Arguments
113 ///
114 /// * `description` - Human-readable description
115 ///
116 /// # Returns
117 ///
118 /// Self for builder pattern chaining
119 pub fn description(mut self, description: impl Into<String>) -> Self {
120 self.description = description.into();
121 self
122 }
123}
124
125/// A specific tool invocation request.
126///
127/// ToolInvocation specifies which tool to invoke, additional arguments,
128/// and optional execution context (working directory, environment variables).
129#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
130pub struct ToolInvocation {
131 /// Name of the tool to invoke
132 pub tool_name: String,
133 /// Additional arguments beyond tool defaults
134 pub args: Vec<String>,
135 /// Optional working directory for execution
136 pub working_dir: Option<PathBuf>,
137 /// Optional environment variables
138 #[serde(default)]
139 pub env: HashMap<String, String>,
140}
141
142impl ToolInvocation {
143 /// Creates a new ToolInvocation for the specified tool.
144 ///
145 /// # Arguments
146 ///
147 /// * `tool_name` - Name of the registered tool to invoke
148 ///
149 /// # Example
150 ///
151 /// ```
152 /// use forge_agent::workflow::tools::ToolInvocation;
153 ///
154 /// let invocation = ToolInvocation::new("magellan");
155 /// ```
156 pub fn new(tool_name: impl Into<String>) -> Self {
157 Self {
158 tool_name: tool_name.into(),
159 args: Vec::new(),
160 working_dir: None,
161 env: HashMap::new(),
162 }
163 }
164
165 /// Sets the arguments for this invocation.
166 ///
167 /// # Arguments
168 ///
169 /// * `args` - Vector of argument strings
170 ///
171 /// # Returns
172 ///
173 /// Self for builder pattern chaining
174 pub fn args(mut self, args: Vec<String>) -> Self {
175 self.args = args;
176 self
177 }
178
179 /// Sets the working directory for this invocation.
180 ///
181 /// # Arguments
182 ///
183 /// * `path` - Working directory path
184 ///
185 /// # Returns
186 ///
187 /// Self for builder pattern chaining
188 pub fn working_dir(mut self, path: impl Into<PathBuf>) -> Self {
189 self.working_dir = Some(path.into());
190 self
191 }
192
193 /// Adds an environment variable for this invocation.
194 ///
195 /// # Arguments
196 ///
197 /// * `key` - Environment variable name
198 /// * `value` - Environment variable value
199 ///
200 /// # Returns
201 ///
202 /// Self for builder pattern chaining
203 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
204 self.env.insert(key.into(), value.into());
205 self
206 }
207}
208
209impl fmt::Display for ToolInvocation {
210 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
211 write!(f, "{}", self.tool_name)?;
212 for arg in &self.args {
213 write!(f, " {}", arg)?;
214 }
215 Ok(())
216 }
217}
218
219/// Result of a tool invocation.
220///
221/// Contains the exit code, captured output, and success status.
222#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
223pub struct ToolResult {
224 /// Process exit code (None if process didn't terminate)
225 pub exit_code: Option<i32>,
226 /// Captured standard output
227 pub stdout: String,
228 /// Captured standard error
229 pub stderr: String,
230 /// True if exit code was 0
231 pub success: bool,
232}
233
234impl ToolResult {
235 /// Creates a new ToolResult from execution output.
236 ///
237 /// # Arguments
238 ///
239 /// * `exit_code` - Process exit code
240 /// * `stdout` - Standard output
241 /// * `stderr` - Standard error
242 pub fn new(exit_code: Option<i32>, stdout: String, stderr: String) -> Self {
243 let success = exit_code.map_or(false, |code| code == 0);
244 Self {
245 exit_code,
246 stdout,
247 stderr,
248 success,
249 }
250 }
251
252 /// Creates a successful ToolResult.
253 ///
254 /// # Arguments
255 ///
256 /// * `stdout` - Standard output
257 pub fn success(stdout: String) -> Self {
258 Self {
259 exit_code: Some(0),
260 stdout,
261 stderr: String::new(),
262 success: true,
263 }
264 }
265
266 /// Creates a failed ToolResult.
267 ///
268 /// # Arguments
269 ///
270 /// * `exit_code` - Exit code
271 /// * `stderr` - Standard error
272 pub fn failure(exit_code: i32, stderr: String) -> Self {
273 Self {
274 exit_code: Some(exit_code),
275 stdout: String::new(),
276 stderr,
277 success: false,
278 }
279 }
280}
281
282/// Errors that can occur during tool operations.
283#[derive(Clone, Debug, PartialEq, thiserror::Error)]
284pub enum ToolError {
285 /// Tool not found in registry
286 #[error("Tool not registered: {0}")]
287 ToolNotFound(String),
288
289 /// Process execution failed
290 #[error("Execution failed: {0}")]
291 ExecutionFailed(String),
292
293 /// Tool execution timed out
294 #[error("Tool timed out: {0}")]
295 Timeout(String),
296
297 /// Process termination error
298 #[error("Failed to terminate process: {0}")]
299 TerminationFailed(String),
300
301 /// Tool already registered
302 #[error("Tool already registered: {0}")]
303 AlreadyRegistered(String),
304}
305
306/// Result of a fallback handler operation.
307///
308/// Fallback handlers can retry with modified invocation, skip with a result,
309/// or fail with the original error.
310#[derive(Clone, Debug)]
311pub enum FallbackResult {
312 /// Retry the tool with the same or modified invocation
313 Retry(ToolInvocation),
314 /// Skip the tool and return a result
315 Skip(TaskResult),
316 /// Fail with the original error
317 Fail(ToolError),
318}
319
320/// Handler for tool execution failures.
321///
322/// FallbackHandler allows workflows to recover from tool failures using
323/// configurable strategies (retry, skip, custom handlers).
324#[async_trait]
325pub trait FallbackHandler: Send + Sync {
326 /// Handles a tool execution error.
327 ///
328 /// # Arguments
329 ///
330 /// * `error` - The error that occurred during tool execution
331 /// * `invocation` - The invocation that caused the error
332 ///
333 /// # Returns
334 ///
335 /// A FallbackResult indicating whether to retry, skip, or fail
336 async fn handle(&self, error: &ToolError, invocation: &ToolInvocation) -> FallbackResult;
337}
338
339/// Retry fallback handler with exponential backoff.
340///
341/// Retries tool execution on transient errors using exponential backoff.
342/// Useful for network timeouts or temporary resource issues.
343///
344/// # Example
345///
346/// ```
347/// use forge_agent::workflow::tools::RetryFallback;
348///
349/// // Retry up to 3 times with 100ms base backoff
350/// let fallback = RetryFallback::new(3, 100);
351/// ```
352#[derive(Clone)]
353pub struct RetryFallback {
354 /// Maximum number of retry attempts
355 max_attempts: u32,
356 /// Base backoff duration in milliseconds
357 backoff_ms: u64,
358}
359
360impl RetryFallback {
361 /// Creates a new RetryFallback.
362 ///
363 /// # Arguments
364 ///
365 /// * `max_attempts` - Maximum number of retry attempts (including initial attempt)
366 /// * `backoff_ms` - Base backoff duration in milliseconds (exponential: backoff_ms * 2^attempt)
367 ///
368 /// # Example
369 ///
370 /// ```
371 /// use forge_agent::workflow::tools::RetryFallback;
372 ///
373 /// let fallback = RetryFallback::new(3, 100);
374 /// ```
375 pub fn new(max_attempts: u32, backoff_ms: u64) -> Self {
376 Self {
377 max_attempts,
378 backoff_ms,
379 }
380 }
381}
382
383#[async_trait]
384impl FallbackHandler for RetryFallback {
385 async fn handle(&self, error: &ToolError, invocation: &ToolInvocation) -> FallbackResult {
386 // Extract current attempt from invocation metadata (if available)
387 // For now, we'll always retry unless it's a ToolNotFound error
388 match error {
389 ToolError::ToolNotFound(_) => {
390 // Don't retry if tool is not found
391 FallbackResult::Fail(error.clone())
392 }
393 ToolError::Timeout(_) | ToolError::ExecutionFailed(_) => {
394 // Retry transient errors
395 FallbackResult::Retry(invocation.clone())
396 }
397 ToolError::AlreadyRegistered(_) | ToolError::TerminationFailed(_) => {
398 // Don't retry registration or termination errors
399 FallbackResult::Fail(error.clone())
400 }
401 }
402 }
403}
404
405impl fmt::Debug for RetryFallback {
406 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
407 f.debug_struct("RetryFallback")
408 .field("max_attempts", &self.max_attempts)
409 .field("backoff_ms", &self.backoff_ms)
410 .finish()
411 }
412}
413
414/// Skip fallback handler that returns a fixed result.
415///
416/// Always skips tool execution and returns a pre-configured result.
417/// Useful for optional tools or graceful degradation scenarios.
418///
419/// # Example
420///
421/// ```
422/// use forge_agent::workflow::tasks::TaskResult;
423/// use forge_agent::workflow::tools::SkipFallback;
424///
425/// // Skip with success result
426/// let fallback = SkipFallback::success();
427///
428/// // Skip with custom result
429/// let fallback = SkipFallback::new(TaskResult::Skipped);
430/// ```
431#[derive(Clone)]
432pub struct SkipFallback {
433 /// Result to return when skipping
434 result: TaskResult,
435}
436
437impl SkipFallback {
438 /// Creates a new SkipFallback with the given result.
439 ///
440 /// # Arguments
441 ///
442 /// * `result` - The result to return when skipping
443 ///
444 /// # Example
445 ///
446 /// ```
447 /// use forge_agent::workflow::tasks::TaskResult;
448 /// use forge_agent::workflow::tools::SkipFallback;
449 ///
450 /// let fallback = SkipFallback::new(TaskResult::Skipped);
451 /// ```
452 pub fn new(result: TaskResult) -> Self {
453 Self { result }
454 }
455
456 /// Creates a SkipFallback that returns Success.
457 ///
458 /// # Example
459 ///
460 /// ```
461 /// use forge_agent::workflow::tools::SkipFallback;
462 ///
463 /// let fallback = SkipFallback::success();
464 /// ```
465 pub fn success() -> Self {
466 Self {
467 result: TaskResult::Success,
468 }
469 }
470
471 /// Creates a SkipFallback that returns Skipped.
472 ///
473 /// # Example
474 ///
475 /// ```
476 /// use forge_agent::workflow::tools::SkipFallback;
477 ///
478 /// let fallback = SkipFallback::skip();
479 /// ```
480 pub fn skip() -> Self {
481 Self {
482 result: TaskResult::Skipped,
483 }
484 }
485}
486
487#[async_trait]
488impl FallbackHandler for SkipFallback {
489 async fn handle(&self, _error: &ToolError, _invocation: &ToolInvocation) -> FallbackResult {
490 FallbackResult::Skip(self.result.clone())
491 }
492}
493
494impl fmt::Debug for SkipFallback {
495 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
496 f.debug_struct("SkipFallback")
497 .field("result", &self.result)
498 .finish()
499 }
500}
501
502/// Chain fallback handler that tries multiple handlers in sequence.
503///
504/// Tries each handler in order until one returns a non-Fail result.
505/// If all handlers fail, returns the last Fail result.
506///
507/// # Example
508///
509/// ```
510/// use forge_agent::workflow::tools::{ChainFallback, RetryFallback, SkipFallback};
511///
512/// let fallback = ChainFallback::new()
513/// .add(RetryFallback::new(3, 100))
514/// .add(SkipFallback::skip());
515/// ```
516#[derive(Clone)]
517pub struct ChainFallback {
518 /// Chain of handlers to try in sequence
519 handlers: Vec<Arc<dyn FallbackHandler>>,
520}
521
522impl ChainFallback {
523 /// Creates a new ChainFallback.
524 ///
525 /// # Example
526 ///
527 /// ```
528 /// use forge_agent::workflow::tools::ChainFallback;
529 ///
530 /// let fallback = ChainFallback::new();
531 /// ```
532 pub fn new() -> Self {
533 Self {
534 handlers: Vec::new(),
535 }
536 }
537
538 /// Adds a handler to the chain.
539 ///
540 /// # Arguments
541 ///
542 /// * `handler` - Handler to add to the chain
543 ///
544 /// # Returns
545 ///
546 /// Self for builder pattern chaining
547 ///
548 /// # Example
549 ///
550 /// ```
551 /// use forge_agent::workflow::tools::{ChainFallback, RetryFallback, SkipFallback};
552 ///
553 /// let fallback = ChainFallback::new()
554 /// .add(RetryFallback::new(3, 100))
555 /// .add(SkipFallback::skip());
556 /// ```
557 pub fn add(mut self, handler: impl FallbackHandler + 'static) -> Self {
558 self.handlers.push(Arc::new(handler));
559 self
560 }
561}
562
563impl Default for ChainFallback {
564 fn default() -> Self {
565 Self::new()
566 }
567}
568
569#[async_trait]
570impl FallbackHandler for ChainFallback {
571 async fn handle(&self, error: &ToolError, invocation: &ToolInvocation) -> FallbackResult {
572 let mut last_fail = None;
573
574 for handler in &self.handlers {
575 match handler.handle(error, invocation).await {
576 FallbackResult::Fail(err) => {
577 last_fail = Some(err);
578 }
579 result => return result,
580 }
581 }
582
583 // All handlers failed
584 FallbackResult::Fail(last_fail.unwrap_or_else(|| error.clone()))
585 }
586}
587
588impl fmt::Debug for ChainFallback {
589 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
590 f.debug_struct("ChainFallback")
591 .field("handlers", &self.handlers.len())
592 .finish()
593 }
594}
595
596/// RAII guard for process lifecycle management.
597///
598/// ProcessGuard automatically terminates the spawned process when dropped.
599/// This ensures proper cleanup even if errors occur during workflow execution.
600///
601/// The guard uses a shared boolean flag to track whether the process has already
602/// been terminated manually, preventing double-termination in Drop.
603#[derive(Clone, Debug)]
604pub struct ProcessGuard {
605 /// Process ID being guarded
606 pid: u32,
607 /// Name of the tool (for logging)
608 tool_name: String,
609 /// Shared flag to track termination status
610 terminated: Arc<AtomicBool>,
611}
612
613impl ProcessGuard {
614 /// Creates a new ProcessGuard for the given process.
615 ///
616 /// # Arguments
617 ///
618 /// * `pid` - Process ID to guard
619 /// * `tool_name` - Name of the tool (for logging)
620 ///
621 /// # Example
622 ///
623 /// ```
624 /// use forge_agent::workflow::tools::ProcessGuard;
625 ///
626 /// let guard = ProcessGuard::new(12345, "magellan");
627 /// ```
628 pub fn new(pid: u32, tool_name: impl Into<String>) -> Self {
629 Self {
630 pid,
631 tool_name: tool_name.into(),
632 terminated: Arc::new(AtomicBool::new(false)),
633 }
634 }
635
636 /// Manually terminates the guarded process.
637 ///
638 /// Sets the terminated flag to prevent double-termination in Drop.
639 ///
640 /// # Returns
641 ///
642 /// - `Ok(())` if termination succeeded
643 /// - `Err(ToolError)` if termination failed
644 ///
645 /// # Example
646 ///
647 /// ```
648 /// use forge_agent::workflow::tools::ProcessGuard;
649 ///
650 /// let guard = ProcessGuard::new(12345, "magellan");
651 /// guard.terminate()?;
652 /// ```
653 pub fn terminate(&self) -> Result<(), ToolError> {
654 // Check if already terminated
655 if self.terminated.load(Ordering::SeqCst) {
656 return Ok(());
657 }
658
659 // Try to kill the process gracefully
660 #[cfg(unix)]
661 {
662 use std::process::Command;
663 let result = Command::new("kill")
664 .arg("-TERM")
665 .arg(self.pid.to_string())
666 .output();
667
668 match result {
669 Ok(output) => {
670 if output.status.success() {
671 self.terminated.store(true, Ordering::SeqCst);
672 Ok(())
673 } else {
674 Err(ToolError::TerminationFailed(format!(
675 "kill command failed for process {}",
676 self.pid
677 )))
678 }
679 }
680 Err(e) => Err(ToolError::TerminationFailed(format!(
681 "Failed to execute kill command: {}",
682 e
683 ))),
684 }
685 }
686
687 #[cfg(not(unix))]
688 {
689 Err(ToolError::TerminationFailed(
690 "Process termination not supported on this platform".to_string(),
691 ))
692 }
693 }
694
695 /// Returns the process ID being guarded.
696 ///
697 /// # Example
698 ///
699 /// ```
700 /// use forge_agent::workflow::tools::ProcessGuard;
701 ///
702 /// let guard = ProcessGuard::new(12345, "magellan");
703 /// assert_eq!(guard.pid(), 12345);
704 /// ```
705 pub fn pid(&self) -> u32 {
706 self.pid
707 }
708
709 /// Returns true if the process was terminated.
710 ///
711 /// # Example
712 ///
713 /// ```
714 /// use forge_agent::workflow::tools::ProcessGuard;
715 ///
716 /// let guard = ProcessGuard::new(12345, "magellan");
717 /// assert!(!guard.is_terminated());
718 /// ```
719 pub fn is_terminated(&self) -> bool {
720 self.terminated.load(Ordering::SeqCst)
721 }
722}
723
724impl fmt::Display for ProcessGuard {
725 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
726 write!(
727 f,
728 "ProcessGuard(pid={}, tool={}, terminated={})",
729 self.pid,
730 self.tool_name,
731 self.is_terminated()
732 )
733 }
734}
735
736impl Drop for ProcessGuard {
737 fn drop(&mut self) {
738 // Only terminate if not already terminated
739 if !self.is_terminated() {
740 if let Err(e) = self.terminate() {
741 // Log the error but don't panic in Drop
742 eprintln!("ProcessGuard drop error: {}", e);
743 }
744 }
745 }
746}
747
748impl From<ProcessGuard> for ToolCompensation {
749 fn from(guard: ProcessGuard) -> Self {
750 ToolCompensation::new(
751 format!("Terminate process: {} ({})", guard.tool_name, guard.pid),
752 move |_context| {
753 // Try to terminate the process
754 if guard.terminate().is_ok() {
755 Ok(TaskResult::Success)
756 } else {
757 // Termination failed, but don't fail rollback
758 Ok(TaskResult::Skipped)
759 }
760 },
761 )
762 }
763}
764
765/// Wrapper for tool invocation results with optional process guard.
766///
767/// ToolInvocationResult contains both the result of the tool execution
768/// and an optional RAII guard for long-running processes.
769#[derive(Clone, Debug)]
770pub struct ToolInvocationResult {
771 /// Result of the tool invocation
772 pub result: ToolResult,
773 /// Optional process guard (None for simple commands that complete immediately)
774 pub guard: Option<ProcessGuard>,
775}
776
777impl ToolInvocationResult {
778 /// Creates a new ToolInvocationResult.
779 ///
780 /// # Arguments
781 ///
782 /// * `result` - Tool execution result
783 /// * `guard` - Optional process guard
784 pub fn new(result: ToolResult, guard: Option<ProcessGuard>) -> Self {
785 Self { result, guard }
786 }
787
788 /// Creates a result without a process guard (for completed commands).
789 ///
790 /// # Arguments
791 ///
792 /// * `result` - Tool execution result
793 pub fn completed(result: ToolResult) -> Self {
794 Self {
795 result,
796 guard: None,
797 }
798 }
799}
800
801/// Registry for external tools.
802///
803/// ToolRegistry stores registered tools and provides methods for invoking them
804/// with proper process lifecycle management.
805///
806/// # Example
807///
808/// ```ignore
809/// use forge_agent::workflow::tools::{Tool, ToolRegistry, ToolInvocation};
810///
811/// let mut registry = ToolRegistry::new();
812///
813/// // Register magellan
814/// let magellan = Tool::new("magellan", "/usr/bin/magellan")
815/// .default_args(vec!["--db".to_string(), ".forge/graph.db".to_string()])
816/// .description("Graph-based code indexer");
817/// registry.register(magellan)?;
818///
819/// // Check registration
820/// assert!(registry.is_registered("magellan"));
821///
822/// // List all tools
823/// let tools = registry.list_tools();
824/// assert!(tools.contains(&"magellan"));
825/// ```
826#[derive(Clone)]
827pub struct ToolRegistry {
828 /// Registered tools indexed by name
829 tools: HashMap<String, Tool>,
830}
831
832impl ToolRegistry {
833 /// Creates a new empty ToolRegistry.
834 ///
835 /// # Example
836 ///
837 /// ```
838 /// use forge_agent::workflow::tools::ToolRegistry;
839 ///
840 /// let registry = ToolRegistry::new();
841 /// assert_eq!(registry.len(), 0);
842 /// ```
843 pub fn new() -> Self {
844 Self {
845 tools: HashMap::new(),
846 }
847 }
848
849 /// Registers a tool in the registry.
850 ///
851 /// # Arguments
852 ///
853 /// * `tool` - Tool to register
854 ///
855 /// # Returns
856 ///
857 /// - `Ok(())` if registration succeeded
858 /// - `Err(ToolError::AlreadyRegistered)` if tool with same name exists
859 ///
860 /// # Example
861 ///
862 /// ```
863 /// use forge_agent::workflow::tools::{Tool, ToolRegistry};
864 ///
865 /// let mut registry = ToolRegistry::new();
866 /// let tool = Tool::new("magellan", "/usr/bin/magellan");
867 /// registry.register(tool)?;
868 /// ```
869 pub fn register(&mut self, tool: Tool) -> Result<(), ToolError> {
870 if self.tools.contains_key(&tool.name) {
871 return Err(ToolError::AlreadyRegistered(tool.name.clone()));
872 }
873 self.tools.insert(tool.name.clone(), tool);
874 Ok(())
875 }
876
877 /// Gets a tool by name.
878 ///
879 /// # Arguments
880 ///
881 /// * `name` - Tool name to look up
882 ///
883 /// # Returns
884 ///
885 /// - `Some(&Tool)` if tool exists
886 /// - `None` if tool not found
887 ///
888 /// # Example
889 ///
890 /// ```
891 /// use forge_agent::workflow::tools::{Tool, ToolRegistry};
892 ///
893 /// let mut registry = ToolRegistry::new();
894 /// registry.register(Tool::new("magellan", "/usr/bin/magellan")).unwrap();
895 ///
896 /// let tool = registry.get("magellan");
897 /// assert!(tool.is_some());
898 /// assert_eq!(tool.unwrap().name, "magellan");
899 /// ```
900 pub fn get(&self, name: &str) -> Option<&Tool> {
901 self.tools.get(name)
902 }
903
904 /// Invokes a tool with the given invocation parameters.
905 ///
906 /// # Arguments
907 ///
908 /// * `invocation` - Tool invocation request
909 ///
910 /// # Returns
911 ///
912 /// - `Ok(ToolInvocationResult)` with result and optional process guard
913 /// - `Err(ToolError)` if tool not found or execution fails
914 ///
915 /// # Example
916 ///
917 /// ```ignore
918 /// use forge_agent::workflow::tools::{ToolRegistry, ToolInvocation, Tool};
919 ///
920 /// let mut registry = ToolRegistry::new();
921 /// registry.register(Tool::new("echo", "/bin/echo")).unwrap();
922 ///
923 /// let invocation = ToolInvocation::new("echo")
924 /// .args(vec!["hello".to_string(), "world".to_string()]);
925 ///
926 /// let result = registry.invoke(&invocation).await?;
927 /// assert!(result.result.success);
928 /// ```
929 pub async fn invoke(
930 &self,
931 invocation: &ToolInvocation,
932 ) -> Result<ToolInvocationResult, ToolError> {
933 // Look up the tool
934 let tool = self
935 .get(&invocation.tool_name)
936 .ok_or_else(|| ToolError::ToolNotFound(invocation.tool_name.clone()))?;
937
938 // Build full command: executable + default_args + invocation.args
939 let mut cmd = tokio::process::Command::new(&tool.executable);
940 cmd.args(&tool.default_args);
941 cmd.args(&invocation.args);
942
943 // Apply working directory if specified
944 if let Some(ref working_dir) = invocation.working_dir {
945 cmd.current_dir(working_dir);
946 }
947
948 // Apply environment variables
949 for (key, value) in &invocation.env {
950 cmd.env(key, value);
951 }
952
953 // Ensure stdout and stderr are captured
954 cmd.stdout(std::process::Stdio::piped());
955 cmd.stderr(std::process::Stdio::piped());
956
957 // Spawn the process
958 let child = cmd
959 .spawn()
960 .map_err(|e| ToolError::ExecutionFailed(format!("Failed to spawn: {}", e)))?;
961
962 // Get the process ID
963 let pid = child.id().ok_or_else(|| {
964 ToolError::ExecutionFailed("Failed to get process ID".to_string())
965 })?;
966
967 // Create a process guard immediately
968 let guard = ProcessGuard::new(pid, &tool.name);
969
970 // Wait for the process to complete (with timeout)
971 // For now, use a default timeout of 30 seconds
972 let timeout_duration = Duration::from_secs(30);
973
974 let output = match tokio::time::timeout(timeout_duration, child.wait_with_output()).await {
975 Ok(Ok(output)) => output,
976 Ok(Err(e)) => {
977 return Err(ToolError::ExecutionFailed(format!(
978 "Failed to wait for process: {}",
979 e
980 )))
981 }
982 Err(_) => {
983 // Timeout - terminate the process
984 let _ = guard.terminate();
985 return Err(ToolError::Timeout(format!(
986 "Tool {} timed out after {:?}",
987 invocation.tool_name, timeout_duration
988 )));
989 }
990 };
991
992 // Mark the process as terminated (it completed normally)
993 guard.terminated.store(true, std::sync::atomic::Ordering::SeqCst);
994
995 // Parse the result
996 let exit_code = output.status.code();
997 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
998 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
999
1000 let result = ToolResult::new(exit_code, stdout, stderr);
1001
1002 // Always return completed result (process already terminated)
1003 Ok(ToolInvocationResult::completed(result))
1004 }
1005
1006 /// Lists all registered tool names.
1007 ///
1008 /// # Returns
1009 ///
1010 /// Vector of tool names
1011 ///
1012 /// # Example
1013 ///
1014 /// ```
1015 /// use forge_agent::workflow::tools::{Tool, ToolRegistry};
1016 ///
1017 /// let mut registry = ToolRegistry::new();
1018 /// registry.register(Tool::new("magellan", "/usr/bin/magellan")).unwrap();
1019 /// registry.register(Tool::new("cargo", "/usr/bin/cargo")).unwrap();
1020 ///
1021 /// let tools = registry.list_tools();
1022 /// assert_eq!(tools.len(), 2);
1023 /// ```
1024 pub fn list_tools(&self) -> Vec<&str> {
1025 self.tools.keys().map(|k| k.as_str()).collect()
1026 }
1027
1028 /// Checks if a tool is registered.
1029 ///
1030 /// # Arguments
1031 ///
1032 /// * `name` - Tool name to check
1033 ///
1034 /// # Returns
1035 ///
1036 /// - `true` if tool is registered
1037 /// - `false` if tool is not registered
1038 ///
1039 /// # Example
1040 ///
1041 /// ```
1042 /// use forge_agent::workflow::tools::{Tool, ToolRegistry};
1043 ///
1044 /// let mut registry = ToolRegistry::new();
1045 /// registry.register(Tool::new("magellan", "/usr/bin/magellan")).unwrap();
1046 ///
1047 /// assert!(registry.is_registered("magellan"));
1048 /// assert!(!registry.is_registered("cargo"));
1049 /// ```
1050 pub fn is_registered(&self, name: &str) -> bool {
1051 self.tools.contains_key(name)
1052 }
1053
1054 /// Returns the number of registered tools.
1055 ///
1056 /// # Example
1057 ///
1058 /// ```
1059 /// use forge_agent::workflow::tools::{Tool, ToolRegistry};
1060 ///
1061 /// let mut registry = ToolRegistry::new();
1062 /// assert_eq!(registry.len(), 0);
1063 ///
1064 /// registry.register(Tool::new("magellan", "/usr/bin/magellan")).unwrap();
1065 /// assert_eq!(registry.len(), 1);
1066 /// ```
1067 pub fn len(&self) -> usize {
1068 self.tools.len()
1069 }
1070
1071 /// Returns true if the registry is empty.
1072 ///
1073 /// # Example
1074 ///
1075 /// ```
1076 /// use forge_agent::workflow::tools::ToolRegistry;
1077 ///
1078 /// let registry = ToolRegistry::new();
1079 /// assert!(registry.is_empty());
1080 /// ```
1081 pub fn is_empty(&self) -> bool {
1082 self.tools.is_empty()
1083 }
1084
1085 /// Creates a ToolRegistry with standard tools pre-registered.
1086 ///
1087 /// This method attempts to discover and register commonly-used tools:
1088 /// - magellan (graph-based code indexer)
1089 /// - cargo (Rust package manager)
1090 /// - splice (precision code editor)
1091 ///
1092 /// Tools that are not found are logged but don't cause failure (graceful degradation).
1093 ///
1094 /// # Returns
1095 ///
1096 /// A ToolRegistry with discovered tools registered
1097 ///
1098 /// # Example
1099 ///
1100 /// ```
1101 /// use forge_agent::workflow::tools::ToolRegistry;
1102 ///
1103 /// let registry = ToolRegistry::with_standard_tools();
1104 /// // registry may have magellan, cargo, splice if they were found
1105 /// ```
1106 pub fn with_standard_tools() -> Self {
1107 let mut registry = Self::new();
1108
1109 // Helper function to find a tool in PATH
1110 let find_tool = |name: &str| -> Option<PathBuf> {
1111 match Command::new("which").arg(name).output() {
1112 Ok(output) => {
1113 if output.status.success() {
1114 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
1115 Some(PathBuf::from(path))
1116 } else {
1117 None
1118 }
1119 }
1120 Err(_) => None,
1121 }
1122 };
1123
1124 // Register magellan if found
1125 if let Some(path) = find_tool("magellan") {
1126 let tool = Tool::new("magellan", path)
1127 .description("Graph-based code indexer");
1128 if registry.register(tool).is_ok() {
1129 eprintln!("Registered standard tool: magellan");
1130 }
1131 } else {
1132 eprintln!("Warning: magellan not found in PATH");
1133 }
1134
1135 // Register cargo if found
1136 if let Some(path) = find_tool("cargo") {
1137 let tool = Tool::new("cargo", path)
1138 .description("Rust package manager");
1139 if registry.register(tool).is_ok() {
1140 eprintln!("Registered standard tool: cargo");
1141 }
1142 } else {
1143 eprintln!("Warning: cargo not found in PATH");
1144 }
1145
1146 // Register splice if found
1147 if let Some(path) = find_tool("splice") {
1148 let tool = Tool::new("splice", path)
1149 .description("Precision code editor");
1150 if registry.register(tool).is_ok() {
1151 eprintln!("Registered standard tool: splice");
1152 }
1153 } else {
1154 eprintln!("Warning: splice not found in PATH");
1155 }
1156
1157 registry
1158 }
1159}
1160
1161impl Default for ToolRegistry {
1162 fn default() -> Self {
1163 Self::with_standard_tools()
1164 }
1165}
1166
1167#[cfg(test)]
1168mod tests {
1169 use super::*;
1170
1171 // ============== FallbackHandler Tests ==============
1172
1173 #[tokio::test]
1174 async fn test_retry_fallback_retries_transient_errors() {
1175 let fallback = RetryFallback::new(3, 100);
1176 let invocation = ToolInvocation::new("test_tool").args(vec!["arg1".to_string()]);
1177
1178 // Test timeout error (should retry)
1179 let error = ToolError::Timeout("Test timeout".to_string());
1180 let result = fallback.handle(&error, &invocation).await;
1181
1182 assert!(matches!(result, FallbackResult::Retry(_)));
1183 }
1184
1185 #[tokio::test]
1186 async fn test_retry_fallback_fails_on_tool_not_found() {
1187 let fallback = RetryFallback::new(3, 100);
1188 let invocation = ToolInvocation::new("nonexistent_tool");
1189
1190 // Test tool not found (should fail)
1191 let error = ToolError::ToolNotFound("nonexistent_tool".to_string());
1192 let result = fallback.handle(&error, &invocation).await;
1193
1194 assert!(matches!(result, FallbackResult::Fail(_)));
1195 }
1196
1197 #[tokio::test]
1198 async fn test_skip_fallback_success() {
1199 let fallback = SkipFallback::success();
1200 let invocation = ToolInvocation::new("test_tool");
1201 let error = ToolError::ToolNotFound("test_tool".to_string());
1202
1203 let result = fallback.handle(&error, &invocation).await;
1204
1205 assert!(matches!(result, FallbackResult::Skip(TaskResult::Success)));
1206 }
1207
1208 #[tokio::test]
1209 async fn test_skip_fallback_skip() {
1210 let fallback = SkipFallback::skip();
1211 let invocation = ToolInvocation::new("test_tool");
1212 let error = ToolError::ToolNotFound("test_tool".to_string());
1213
1214 let result = fallback.handle(&error, &invocation).await;
1215
1216 assert!(matches!(result, FallbackResult::Skip(TaskResult::Skipped)));
1217 }
1218
1219 #[tokio::test]
1220 async fn test_skip_fallback_custom_result() {
1221 let fallback = SkipFallback::new(TaskResult::Failed("Custom failure".to_string()));
1222 let invocation = ToolInvocation::new("test_tool");
1223 let error = ToolError::ToolNotFound("test_tool".to_string());
1224
1225 let result = fallback.handle(&error, &invocation).await;
1226
1227 assert!(matches!(result, FallbackResult::Skip(TaskResult::Failed(_))));
1228 if let FallbackResult::Skip(TaskResult::Failed(msg)) = result {
1229 assert_eq!(msg, "Custom failure");
1230 }
1231 }
1232
1233 #[tokio::test]
1234 async fn test_chain_fallback_tries_handlers_in_sequence() {
1235 let invocation = ToolInvocation::new("test_tool");
1236 let error = ToolError::Timeout("Test timeout".to_string());
1237
1238 // Create chain with retry (fails) then skip (succeeds)
1239 let fallback = ChainFallback::new()
1240 .add(SkipFallback::skip())
1241 .add(SkipFallback::success());
1242
1243 let result = fallback.handle(&error, &invocation).await;
1244
1245 // First handler (skip) should be used
1246 assert!(matches!(result, FallbackResult::Skip(TaskResult::Skipped)));
1247 }
1248
1249 #[tokio::test]
1250 async fn test_chain_fallback_all_handlers_fail() {
1251 let invocation = ToolInvocation::new("test_tool");
1252 let error = ToolError::Timeout("Test timeout".to_string());
1253
1254 // Create chain with custom handler that always fails
1255 #[derive(Clone)]
1256 struct AlwaysFail;
1257 #[async_trait]
1258 impl FallbackHandler for AlwaysFail {
1259 async fn handle(&self, error: &ToolError, _invocation: &ToolInvocation) -> FallbackResult {
1260 FallbackResult::Fail(error.clone())
1261 }
1262 }
1263
1264 let fallback = ChainFallback::new()
1265 .add(AlwaysFail)
1266 .add(AlwaysFail);
1267
1268 let result = fallback.handle(&error, &invocation).await;
1269
1270 assert!(matches!(result, FallbackResult::Fail(_)));
1271 }
1272
1273 #[tokio::test]
1274 async fn test_chain_fallback_empty_chain() {
1275 let invocation = ToolInvocation::new("test_tool");
1276 let error = ToolError::Timeout("Test timeout".to_string());
1277
1278 let fallback = ChainFallback::new();
1279 let result = fallback.handle(&error, &invocation).await;
1280
1281 // Empty chain should return the original error
1282 assert!(matches!(result, FallbackResult::Fail(_)));
1283 }
1284
1285 // ============== Tool Tests ==============
1286
1287 #[test]
1288 fn test_tool_creation() {
1289 let tool = Tool::new("magellan", "/usr/bin/magellan");
1290
1291 assert_eq!(tool.name, "magellan");
1292 assert_eq!(tool.executable, PathBuf::from("/usr/bin/magellan"));
1293 assert!(tool.default_args.is_empty());
1294 assert!(tool.description.is_empty());
1295 }
1296
1297 #[test]
1298 fn test_tool_with_default_args() {
1299 let tool = Tool::new("magellan", "/usr/bin/magellan").default_args(vec![
1300 "--db".to_string(),
1301 ".forge/graph.db".to_string(),
1302 ]);
1303
1304 assert_eq!(tool.default_args.len(), 2);
1305 assert_eq!(tool.default_args[0], "--db");
1306 assert_eq!(tool.default_args[1], ".forge/graph.db");
1307 }
1308
1309 #[test]
1310 fn test_tool_with_description() {
1311 let tool = Tool::new("magellan", "/usr/bin/magellan")
1312 .description("Graph-based code indexer");
1313
1314 assert_eq!(tool.description, "Graph-based code indexer");
1315 }
1316
1317 #[test]
1318 fn test_tool_builder_pattern() {
1319 let tool = Tool::new("magellan", "/usr/bin/magellan")
1320 .default_args(vec!["--db".to_string(), ".forge/graph.db".to_string()])
1321 .description("Graph-based code indexer");
1322
1323 assert_eq!(tool.name, "magellan");
1324 assert_eq!(tool.default_args.len(), 2);
1325 assert_eq!(tool.description, "Graph-based code indexer");
1326 }
1327
1328 // ============== ToolInvocation Tests ==============
1329
1330 #[test]
1331 fn test_tool_invocation_creation() {
1332 let invocation = ToolInvocation::new("magellan");
1333
1334 assert_eq!(invocation.tool_name, "magellan");
1335 assert!(invocation.args.is_empty());
1336 assert!(invocation.working_dir.is_none());
1337 assert!(invocation.env.is_empty());
1338 }
1339
1340 #[test]
1341 fn test_tool_invocation_with_args() {
1342 let invocation = ToolInvocation::new("magellan").args(vec![
1343 "find".to_string(),
1344 "--name".to_string(),
1345 "symbol".to_string(),
1346 ]);
1347
1348 assert_eq!(invocation.args.len(), 3);
1349 assert_eq!(invocation.args[0], "find");
1350 }
1351
1352 #[test]
1353 fn test_tool_invocation_with_working_dir() {
1354 let invocation = ToolInvocation::new("magellan")
1355 .working_dir("/home/user/project");
1356
1357 assert_eq!(
1358 invocation.working_dir,
1359 Some(PathBuf::from("/home/user/project"))
1360 );
1361 }
1362
1363 #[test]
1364 fn test_tool_invocation_with_env() {
1365 let invocation = ToolInvocation::new("magellan")
1366 .env("RUST_LOG", "debug");
1367
1368 assert_eq!(invocation.env.len(), 1);
1369 assert_eq!(invocation.env.get("RUST_LOG"), Some(&"debug".to_string()));
1370 }
1371
1372 #[test]
1373 fn test_tool_invocation_display() {
1374 let invocation = ToolInvocation::new("magellan")
1375 .args(vec!["find".to_string(), "--name".to_string()]);
1376
1377 let display = format!("{}", invocation);
1378 assert!(display.contains("magellan"));
1379 assert!(display.contains("find"));
1380 }
1381
1382 // ============== ToolResult Tests ==============
1383
1384 #[test]
1385 fn test_tool_result_success() {
1386 let result = ToolResult::success("output".to_string());
1387
1388 assert_eq!(result.exit_code, Some(0));
1389 assert_eq!(result.stdout, "output");
1390 assert!(result.stderr.is_empty());
1391 assert!(result.success);
1392 }
1393
1394 #[test]
1395 fn test_tool_result_failure() {
1396 let result = ToolResult::failure(1, "error".to_string());
1397
1398 assert_eq!(result.exit_code, Some(1));
1399 assert!(result.stdout.is_empty());
1400 assert_eq!(result.stderr, "error");
1401 assert!(!result.success);
1402 }
1403
1404 #[test]
1405 fn test_tool_result_new() {
1406 let result = ToolResult::new(Some(0), "stdout".to_string(), "stderr".to_string());
1407
1408 assert_eq!(result.exit_code, Some(0));
1409 assert_eq!(result.stdout, "stdout");
1410 assert_eq!(result.stderr, "stderr");
1411 assert!(result.success);
1412 }
1413
1414 #[test]
1415 fn test_tool_result_none_exit_code() {
1416 let result = ToolResult::new(None, "stdout".to_string(), "stderr".to_string());
1417
1418 assert_eq!(result.exit_code, None);
1419 assert!(!result.success);
1420 }
1421
1422 // ============== ToolRegistry Tests ==============
1423
1424 #[test]
1425 fn test_tool_registry_new() {
1426 let registry = ToolRegistry::new();
1427
1428 assert!(registry.is_empty());
1429 assert_eq!(registry.len(), 0);
1430 }
1431
1432 #[test]
1433 fn test_register_tool() {
1434 let mut registry = ToolRegistry::new();
1435 let tool = Tool::new("magellan", "/usr/bin/magellan");
1436
1437 registry.register(tool).unwrap();
1438
1439 assert_eq!(registry.len(), 1);
1440 assert!(registry.is_registered("magellan"));
1441 }
1442
1443 #[test]
1444 fn test_register_duplicate_tool() {
1445 let mut registry = ToolRegistry::new();
1446
1447 registry
1448 .register(Tool::new("magellan", "/usr/bin/magellan"))
1449 .unwrap();
1450
1451 let result = registry.register(Tool::new("magellan", "/usr/bin/magellan"));
1452
1453 assert!(result.is_err());
1454 assert_eq!(result.unwrap_err(), ToolError::AlreadyRegistered("magellan".to_string()));
1455 }
1456
1457 #[test]
1458 fn test_get_tool() {
1459 let mut registry = ToolRegistry::new();
1460
1461 registry
1462 .register(Tool::new("magellan", "/usr/bin/magellan"))
1463 .unwrap();
1464
1465 let tool = registry.get("magellan");
1466 assert!(tool.is_some());
1467 assert_eq!(tool.unwrap().name, "magellan");
1468 }
1469
1470 #[test]
1471 fn test_get_nonexistent_tool() {
1472 let registry = ToolRegistry::new();
1473
1474 let tool = registry.get("magellan");
1475 assert!(tool.is_none());
1476 }
1477
1478 #[test]
1479 fn test_list_tools() {
1480 let mut registry = ToolRegistry::new();
1481
1482 registry
1483 .register(Tool::new("magellan", "/usr/bin/magellan"))
1484 .unwrap();
1485 registry
1486 .register(Tool::new("cargo", "/usr/bin/cargo"))
1487 .unwrap();
1488
1489 let tools = registry.list_tools();
1490 assert_eq!(tools.len(), 2);
1491 assert!(tools.contains(&"magellan"));
1492 assert!(tools.contains(&"cargo"));
1493 }
1494
1495 #[test]
1496 fn test_is_registered() {
1497 let mut registry = ToolRegistry::new();
1498
1499 registry
1500 .register(Tool::new("magellan", "/usr/bin/magellan"))
1501 .unwrap();
1502
1503 assert!(registry.is_registered("magellan"));
1504 assert!(!registry.is_registered("cargo"));
1505 }
1506
1507 #[test]
1508 fn test_tool_registry_default() {
1509 let registry = ToolRegistry::default();
1510
1511 // default() now calls with_standard_tools() which pre-registers tools
1512 // The registry should not be empty (may have magellan, cargo, splice if found)
1513 // Just verify it was created successfully
1514 assert!(registry.len() >= 0);
1515 }
1516
1517 #[tokio::test]
1518 async fn test_invoke_basic_tool() {
1519 let mut registry = ToolRegistry::new();
1520
1521 // Register echo as a test tool
1522 registry
1523 .register(Tool::new("echo", "echo"))
1524 .unwrap();
1525
1526 // Invoke echo
1527 let invocation = ToolInvocation::new("echo").args(vec!["hello".to_string()]);
1528
1529 let result = registry.invoke(&invocation).await.unwrap();
1530
1531 assert!(result.result.success);
1532 // echo adds newline, so check for "hello\n" or just "hello"
1533 let trimmed = result.result.stdout.trim();
1534 assert_eq!(trimmed, "hello", "Expected 'hello', got '{}'", trimmed);
1535 }
1536
1537 #[tokio::test]
1538 async fn test_invoke_with_default_args() {
1539 let mut registry = ToolRegistry::new();
1540
1541 // Register echo with default argument
1542 registry
1543 .register(
1544 Tool::new("echo", "/bin/echo").default_args(vec!["-n".to_string()]),
1545 )
1546 .unwrap();
1547
1548 // Invoke echo
1549 let invocation = ToolInvocation::new("echo").args(vec!["test".to_string()]);
1550
1551 let result = registry.invoke(&invocation).await.unwrap();
1552
1553 assert!(result.result.success);
1554 }
1555
1556 #[tokio::test]
1557 async fn test_invoke_nonexistent_tool() {
1558 let registry = ToolRegistry::new();
1559
1560 let invocation = ToolInvocation::new("nonexistent").args(vec!["arg".to_string()]);
1561
1562 let result = registry.invoke(&invocation).await;
1563
1564 assert!(result.is_err());
1565 assert_eq!(
1566 result.unwrap_err(),
1567 ToolError::ToolNotFound("nonexistent".to_string())
1568 );
1569 }
1570
1571 // ============== ProcessGuard Tests ==============
1572
1573 #[test]
1574 fn test_process_guard_creation() {
1575 let guard = ProcessGuard::new(12345, "test_tool");
1576
1577 assert_eq!(guard.pid(), 12345);
1578 assert!(!guard.is_terminated());
1579 }
1580
1581 #[test]
1582 fn test_process_guard_display() {
1583 let guard = ProcessGuard::new(12345, "test_tool");
1584
1585 let display = format!("{}", guard);
1586 assert!(display.contains("12345"));
1587 assert!(display.contains("test_tool"));
1588 }
1589
1590 #[test]
1591 fn test_process_guard_clone() {
1592 let guard1 = ProcessGuard::new(12345, "test_tool");
1593 let guard2 = guard1.clone();
1594
1595 assert_eq!(guard1.pid(), guard2.pid());
1596 assert_eq!(guard1.tool_name, guard2.tool_name);
1597
1598 // Both guards share the same termination flag
1599 assert_eq!(guard1.is_terminated(), guard2.is_terminated());
1600 }
1601
1602 #[test]
1603 fn test_process_guard_into_tool_compensation() {
1604 let guard = ProcessGuard::new(12345, "test_tool");
1605
1606 let compensation: ToolCompensation = guard.into();
1607
1608 assert!(compensation.description.contains("12345"));
1609 assert!(compensation.description.contains("test_tool"));
1610 }
1611
1612 #[tokio::test]
1613 async fn test_tool_invocation_result_completed() {
1614 let result = ToolResult::success("output".to_string());
1615 let invocation_result = ToolInvocationResult::completed(result);
1616
1617 assert!(invocation_result.guard.is_none());
1618 assert!(invocation_result.result.success);
1619 }
1620
1621 #[tokio::test]
1622 async fn test_tool_invocation_result_with_guard() {
1623 let result = ToolResult::failure(1, "error".to_string());
1624 let guard = ProcessGuard::new(12345, "test_tool");
1625 let invocation_result = ToolInvocationResult::new(result, Some(guard));
1626
1627 assert!(invocation_result.guard.is_some());
1628 assert!(!invocation_result.result.success);
1629 }
1630}