1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
use async_trait::async_trait;
mod claude;
mod codex;
mod integ;
pub mod log_line;
mod log_processor;
mod no_op;
mod no_op_log_processor;
mod provider;
pub mod task_logger;
mod task_result;
pub use self::log_processor::LogProcessor;
pub use self::task_result::TaskResult;
pub use claude::ClaudeAgent;
pub use claude::{OAuthTokenStatus, check_oauth_token_validity};
pub use codex::CodexAgent;
pub use integ::IntegAgent;
pub use no_op::NoOpAgent;
pub use provider::AgentProvider;
/// Trait defining the interface for AI agents that can execute tasks
#[async_trait]
pub trait Agent: Send + Sync {
/// Returns the command to execute the agent
///
/// # Arguments
/// * `instruction_path` - Path to the instruction file
/// * `is_interactive` - Whether to build command for interactive debugging mode
///
/// When `is_interactive` is true, the command should:
/// 1. Echo the task instructions and normal command that would run non-interactively
/// 2. Provide an interactive shell or interface for debugging
fn build_command(&self, instruction_path: &str, is_interactive: bool) -> Vec<String>;
/// Returns the volumes to mount for this agent
/// Format: Vec<(host_path, container_path, options)> where options is like ":ro" for read-only
fn volumes(&self) -> Vec<(String, String, String)>;
/// Returns environment variables for this agent
fn environment(&self) -> Vec<(String, String)>;
/// Creates a log processor for this agent's output
fn create_log_processor(&self, task: Option<&crate::task::Task>) -> Box<dyn LogProcessor>;
/// Returns the agent's unique identifier
fn name(&self) -> &str;
/// Validates that this agent is properly configured
async fn validate(&self) -> Result<(), String> {
Ok(())
}
/// Performs any necessary warmup steps before launching the Docker container
///
/// This method is called after validation but before container creation.
/// It can be used to execute host-side setup commands, refresh credentials,
/// or perform any other preparatory work needed by the agent.
///
/// The default implementation does nothing, allowing backward compatibility.
async fn warmup(&self) -> Result<(), String> {
Ok(())
}
/// Returns the version string for this agent
///
/// This version is used to determine when Docker images need to be rebuilt.
/// When an agent's version changes, Docker will rebuild the image from the agent
/// layer onwards, ensuring users always have the latest agent version.
///
/// The default implementation returns "unknown" for backward compatibility.
/// Agents should override this to return their actual version string.
fn version(&self) -> String {
"unknown".to_string()
}
/// Returns files to copy into the container before starting
///
/// Each tuple contains:
/// - A tar archive with the files to copy
/// - The destination directory path in the container
///
/// The tar archive will be extracted to the destination path using
/// Docker's upload_to_container API (equivalent to `docker cp`).
///
/// The default implementation returns an empty vec.
fn files_to_copy(&self) -> Vec<(Vec<u8>, String)> {
vec![]
}
}
#[cfg(test)]
mod tests {
use super::log_line::LogLine;
use super::*;
/// Test agent for testing purposes
struct TestAgent {
name: String,
}
impl TestAgent {
fn new(name: &str) -> Self {
Self {
name: name.to_string(),
}
}
}
#[async_trait]
impl Agent for TestAgent {
fn build_command(&self, instruction_path: &str, is_interactive: bool) -> Vec<String> {
if is_interactive {
vec![
"sh".to_string(),
"-c".to_string(),
format!(
"sleep 0.5; echo '=== Agent Command ==='; echo 'test {}'; echo '=== Starting Interactive Session ==='; exec /bin/bash",
instruction_path
),
]
} else {
vec!["test".to_string(), instruction_path.to_string()]
}
}
fn volumes(&self) -> Vec<(String, String, String)> {
vec![("/test".to_string(), "/test".to_string(), ":ro".to_string())]
}
fn environment(&self) -> Vec<(String, String)> {
vec![("TEST_VAR".to_string(), "test_value".to_string())]
}
fn create_log_processor(&self, _task: Option<&crate::task::Task>) -> Box<dyn LogProcessor> {
struct TestLogProcessor;
#[async_trait]
impl LogProcessor for TestLogProcessor {
fn process_line(&mut self, line: &str) -> Option<LogLine> {
Some(LogLine::message(vec![], None, line.to_string()))
}
fn get_final_result(&self) -> Option<&TaskResult> {
None
}
}
Box::new(TestLogProcessor)
}
fn name(&self) -> &str {
&self.name
}
fn version(&self) -> String {
"test-1.0.0".to_string()
}
}
#[test]
fn test_agent_trait_is_object_safe() {
// This test ensures that the Agent trait can be used as a trait object
fn _assert_object_safe(_: &dyn Agent) {}
let agent = TestAgent::new("test");
_assert_object_safe(&agent);
}
#[tokio::test]
async fn test_no_op_agent() {
let agent = NoOpAgent;
// Test agent name
assert_eq!(agent.name(), "no-op");
// Test build_command in non-interactive mode
let command = agent.build_command("/instructions/test.md", false);
assert_eq!(command.len(), 3);
assert_eq!(command[0], "sh");
assert_eq!(command[1], "-c");
assert!(command[2].contains("cat '/instructions/test.md'"));
// Test build_command in interactive mode
let interactive_command = agent.build_command("/instructions/test.md", true);
assert_eq!(interactive_command.len(), 3);
assert_eq!(interactive_command[0], "sh");
assert_eq!(interactive_command[1], "-c");
assert!(interactive_command[2].starts_with("sleep 0.5;"));
assert!(interactive_command[2].contains("=== Task Instructions ==="));
assert!(interactive_command[2].contains("=== Starting Interactive Session ==="));
// Test volumes
let volumes = agent.volumes();
assert!(volumes.is_empty());
// Test environment
let env = agent.environment();
assert!(env.is_empty());
// Test validation (should always succeed)
assert!(agent.validate().await.is_ok());
// Test warmup (should always succeed)
assert!(agent.warmup().await.is_ok());
// Test version (should return "1.0.0" for no-op agent)
assert_eq!(agent.version(), "1.0.0");
}
#[test]
fn test_no_op_log_processor() {
use super::no_op_log_processor::NoOpLogProcessor;
let mut processor = NoOpLogProcessor::new();
// Test process_line passes through as LogLine::Message
let line = "test output line";
let result = processor.process_line(line).unwrap();
if let LogLine::Message { message, .. } = &result {
assert_eq!(message, line);
} else {
panic!("Expected Message variant");
}
// Test get_final_result returns None
assert!(processor.get_final_result().is_none());
}
#[test]
fn test_codex_log_processor() {
use super::codex::codex_log_processor::CodexLogProcessor;
let mut processor = CodexLogProcessor::new();
// Test that thread.started is filtered out
let thread_started = r#"{"type":"thread.started","thread_id":"test-123"}"#;
assert_eq!(processor.process_line(thread_started), None);
// Test command execution
let cmd_started = r#"{"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"ls","status":"in_progress"}}"#;
let output = processor.process_line(cmd_started);
assert!(output.is_some());
assert!(output.unwrap().to_string().contains("Running: ls"));
// Test get_final_result returns None before turn.completed
assert!(processor.get_final_result().is_none());
// Test turn.completed creates final result
let turn_completed =
r#"{"type":"turn.completed","usage":{"input_tokens":1000,"output_tokens":500}}"#;
processor.process_line(turn_completed);
let result = processor.get_final_result();
assert!(result.is_some());
assert!(result.unwrap().success);
}
}