Skip to main content

ralph_workflow/agents/
invoke.rs

1//! Abstract agent invocation contract.
2//!
3//! This module defines the [`AgentInvoker`] trait and supporting types that provide
4//! a domain-shaped abstraction for AI coding agent invocation. Boundary adapters
5//! (claude, codex, opencode, etc.) implement this trait, allowing domain code
6//! to depend on the abstraction rather than concrete provider implementations.
7//!
8//! # Design Principles
9//!
10//! - **Domain-shaped I/O**: Input/output types contain plain values that are
11//!   meaningful in the domain context, not raw process types.
12//! - **Object-safe**: The trait is designed for dynamic dispatch via `dyn AgentInvoker`.
13//! - **Capability injection**: Callers provide the [`AgentConfig`] at invocation time,
14//!   not construction time, enabling flexible agent selection.
15
16use crate::agents::config::AgentConfig;
17use std::path::Path;
18
19/// Input for agent invocation.
20///
21/// This struct represents all the information needed to invoke an AI coding agent.
22/// It contains plain domain values rather than process-level types.
23#[derive(Debug, Clone)]
24pub struct AgentInput<'a> {
25    /// The prompt/content to send to the agent.
26    pub prompt: &'a str,
27    /// Configuration for the agent to invoke.
28    pub agent_config: &'a AgentConfig,
29    /// Optional log file path for capturing agent output.
30    pub logfile: Option<&'a Path>,
31}
32
33/// Output from successful agent invocation.
34///
35/// This struct represents the result of a completed agent invocation,
36/// containing the agent's output streams and exit status.
37#[derive(Debug, Clone)]
38pub struct AgentOutput {
39    /// Standard output from the agent.
40    pub stdout: String,
41    /// Standard error output from the agent.
42    pub stderr: String,
43    /// Exit code returned by the agent process.
44    pub exit_code: i32,
45}
46
47/// Errors that can occur during agent invocation.
48///
49/// These errors represent failure modes that are meaningful in the domain context,
50/// such as execution failures, classification of agent errors, and invocation issues.
51#[derive(Debug, Clone)]
52pub enum AgentInvokeError {
53    /// The agent command could not be executed (not found, permission denied, etc.).
54    ExecutionFailed(String),
55    /// The agent process was killed (OOM, signal, etc.).
56    ProcessKilled(String),
57    /// Invalid input provided to the invocation.
58    InvalidInput(String),
59    /// Classification of the agent's error response.
60    AgentError(crate::agents::error::AgentErrorKind),
61    /// The agent produced no parseable output.
62    NoOutput,
63    /// The agent output was truncated.
64    TruncatedOutput,
65}
66
67impl std::fmt::Display for AgentInvokeError {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        match self {
70            Self::ExecutionFailed(msg) => write!(f, "Agent execution failed: {msg}"),
71            Self::ProcessKilled(msg) => write!(f, "Agent process killed: {msg}"),
72            Self::InvalidInput(msg) => write!(f, "Invalid invocation input: {msg}"),
73            Self::AgentError(kind) => write!(f, "Agent error: {}", kind.description()),
74            Self::NoOutput => write!(f, "Agent produced no output"),
75            Self::TruncatedOutput => write!(f, "Agent output was truncated"),
76        }
77    }
78}
79
80impl std::error::Error for AgentInvokeError {}
81
82/// Trait for invoking AI coding agents.
83///
84/// This trait abstracts the execution of an AI coding agent, enabling dependency
85/// injection and mock testing. Boundary adapters for different agents (claude,
86/// codex, opencode, gemini, etc.) implement this trait.
87///
88/// # Object Safety
89///
90/// This trait is designed to be object-safe via `dyn AgentInvoker`. Implementors
91/// must not require any type parameters or use generic methods.
92///
93/// # Example
94///
95/// ```ignore
96/// struct AgentRunner {
97///     invoker: Arc<dyn AgentInvoker>,
98/// }
99///
100/// impl AgentRunner {
101///     fn run_agent(&self, prompt: &str, config: &AgentConfig) -> Result<AgentOutput, AgentInvokeError> {
102///         let input = AgentInput {
103///             prompt,
104///             agent_config: config,
105///             logfile: None,
106///         };
107///         self.invoker.invoke(input)
108///     }
109/// }
110/// ```
111pub trait AgentInvoker: Send + Sync {
112    /// Invoke the agent with the given input.
113    ///
114    /// # Arguments
115    ///
116    /// * `input` - The invocation input containing prompt and agent configuration
117    ///
118    /// # Returns
119    ///
120    /// Returns [`AgentOutput`] on success, or [`AgentInvokeError`] on failure.
121    fn invoke(&self, input: AgentInput<'_>) -> Result<AgentOutput, AgentInvokeError>;
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn test_agent_invoker_is_object_safe() {
130        fn assert_object_safe(_: &dyn AgentInvoker) {}
131        assert_object_safe(&MockAgentInvoker);
132    }
133
134    #[test]
135    fn test_agent_input_clone() {
136        let config = AgentConfig::default();
137        let input = AgentInput {
138            prompt: "test prompt",
139            agent_config: &config,
140            logfile: None,
141        };
142        let cloned = input.clone();
143        assert_eq!(cloned.prompt, "test prompt");
144    }
145
146    #[test]
147    fn test_agent_output_clone() {
148        let output = AgentOutput {
149            stdout: "test output".to_string(),
150            stderr: "test error".to_string(),
151            exit_code: 0,
152        };
153        let cloned = output.clone();
154        assert_eq!(cloned.stdout, "test output");
155        assert_eq!(cloned.stderr, "test error");
156        assert_eq!(cloned.exit_code, 0);
157    }
158
159    #[test]
160    fn test_agent_invoke_error_display() {
161        let err = AgentInvokeError::ExecutionFailed("command not found".to_string());
162        assert!(err.to_string().contains("Agent execution failed"));
163        assert!(err.to_string().contains("command not found"));
164
165        let err = AgentInvokeError::NoOutput;
166        assert!(err.to_string().contains("no output"));
167    }
168
169    #[derive(Debug)]
170    struct MockAgentInvoker;
171
172    impl AgentInvoker for MockAgentInvoker {
173        fn invoke(&self, input: AgentInput<'_>) -> Result<AgentOutput, AgentInvokeError> {
174            Ok(AgentOutput {
175                stdout: format!("mock response to: {}", input.prompt),
176                stderr: String::new(),
177                exit_code: 0,
178            })
179        }
180    }
181
182    #[test]
183    fn test_mock_agent_invoker() {
184        let invoker = MockAgentInvoker;
185        let config = AgentConfig::default();
186        let input = AgentInput {
187            prompt: "hello",
188            agent_config: &config,
189            logfile: None,
190        };
191        let result = invoker.invoke(input).expect("should succeed");
192        assert!(result.stdout.contains("hello"));
193        assert_eq!(result.exit_code, 0);
194    }
195}