1use std::path::PathBuf;
9use std::process::Stdio;
10use std::time::Duration;
11
12use async_trait::async_trait;
13use chrono::Utc;
14use tokio::io::AsyncWriteExt;
15use tokio::process::Command;
16use tokio::time::timeout;
17
18use crate::traits::{
19 AgentCapabilities, AgentDelegate, AgentError, AgentResult, AgentTask, AgentTaskStatus, Artifact,
20};
21
22const CAPTURE_CAP: usize = 64 * 1024;
26
27#[derive(Debug, Clone)]
29pub struct SubprocessAgentConfig {
30 pub name: String,
32 pub binary: String,
34 pub args: Vec<String>,
38 pub workdir: Option<PathBuf>,
41 pub capabilities: AgentCapabilities,
43 pub prompt_via_stdin: bool,
46 pub version_args: Vec<String>,
50}
51
52impl SubprocessAgentConfig {
53 pub fn new(name: impl Into<String>, binary: impl Into<String>) -> Self {
54 Self {
55 name: name.into(),
56 binary: binary.into(),
57 args: Vec::new(),
58 workdir: None,
59 capabilities: AgentCapabilities::default(),
60 prompt_via_stdin: true,
61 version_args: vec!["--version".to_string()],
62 }
63 }
64
65 pub fn with_args<I, S>(mut self, args: I) -> Self
66 where
67 I: IntoIterator<Item = S>,
68 S: Into<String>,
69 {
70 self.args = args.into_iter().map(Into::into).collect();
71 self
72 }
73
74 pub fn with_workdir(mut self, workdir: impl Into<PathBuf>) -> Self {
75 self.workdir = Some(workdir.into());
76 self
77 }
78
79 pub fn with_capabilities(mut self, caps: AgentCapabilities) -> Self {
80 self.capabilities = caps;
81 self
82 }
83
84 pub fn with_prompt_via_stdin(mut self, via_stdin: bool) -> Self {
85 self.prompt_via_stdin = via_stdin;
86 self
87 }
88
89 pub fn with_version_args<I, S>(mut self, args: I) -> Self
90 where
91 I: IntoIterator<Item = S>,
92 S: Into<String>,
93 {
94 self.version_args = args.into_iter().map(Into::into).collect();
95 self
96 }
97}
98
99pub struct SubprocessAgentDelegate {
100 config: SubprocessAgentConfig,
101}
102
103impl SubprocessAgentDelegate {
104 pub fn new(config: SubprocessAgentConfig) -> Self {
105 Self { config }
106 }
107
108 pub fn binary(&self) -> &str {
110 &self.config.binary
111 }
112
113 pub fn render_prompt(task: &AgentTask) -> String {
116 let mut out = String::new();
117 out.push_str("# Task\n");
118 out.push_str(task.description.trim());
119 out.push_str("\n\n");
120 let ctx = task.context.render();
121 if !ctx.is_empty() {
122 out.push_str("# Context\n");
123 out.push_str(&ctx);
124 out.push_str("\n\n");
125 }
126 out.push_str("# Workdir\n");
127 match &task.workdir {
128 Some(p) => out.push_str(&p.display().to_string()),
129 None => out.push_str("<inherit>"),
130 }
131 out.push('\n');
132 out
133 }
134
135 fn build_args(&self, prompt: &str, task: &AgentTask) -> Vec<String> {
136 self.config
137 .args
138 .iter()
139 .map(|a| a.replace("{prompt}", prompt).replace("{task_id}", &task.id))
140 .collect()
141 }
142}
143
144#[async_trait]
145impl AgentDelegate for SubprocessAgentDelegate {
146 fn name(&self) -> &str {
147 &self.config.name
148 }
149
150 fn capabilities(&self) -> AgentCapabilities {
151 self.config.capabilities.clone()
152 }
153
154 async fn delegate(&self, task: AgentTask) -> Result<AgentResult, AgentError> {
155 let started_at = Utc::now();
156 let prompt = Self::render_prompt(&task);
157 let args = self.build_args(&prompt, &task);
158
159 let workdir = self
160 .config
161 .workdir
162 .clone()
163 .or_else(|| task.workdir.clone())
164 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
165
166 let mut cmd = Command::new(&self.config.binary);
167 cmd.args(&args)
168 .current_dir(&workdir)
169 .stdin(if self.config.prompt_via_stdin {
170 Stdio::piped()
171 } else {
172 Stdio::null()
173 })
174 .stdout(Stdio::piped())
175 .stderr(Stdio::piped())
176 .kill_on_drop(true);
177
178 tracing::debug!(
179 agent = %self.config.name,
180 task_id = %task.id,
181 binary = %self.config.binary,
182 workdir = %workdir.display(),
183 "Spawning subprocess delegate"
184 );
185
186 let mut child = cmd
187 .spawn()
188 .map_err(|e| AgentError::Launch(format!("spawn {}: {e}", self.config.binary)))?;
189
190 if self.config.prompt_via_stdin {
191 if let Some(stdin) = child.stdin.as_mut() {
192 match stdin.write_all(prompt.as_bytes()).await {
193 Ok(()) => {}
194 Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {
199 tracing::debug!(
200 agent = %self.config.name,
201 task_id = %task.id,
202 "child closed stdin before prompt was fully written"
203 );
204 }
205 Err(e) => return Err(AgentError::Io(format!("writing stdin: {e}"))),
206 }
207 }
208 drop(child.stdin.take());
210 }
211
212 let wait = async {
213 child
214 .wait_with_output()
215 .await
216 .map_err(|e| AgentError::Io(format!("waiting on {}: {e}", self.config.name)))
217 };
218
219 let output = match timeout(Duration::from_secs(task.timeout_secs), wait).await {
220 Ok(res) => res?,
221 Err(_) => {
222 tracing::warn!(
223 agent = %self.config.name,
224 task_id = %task.id,
225 secs = task.timeout_secs,
226 "Delegate timed out"
227 );
228 return Err(AgentError::Timeout {
229 task_id: task.id.clone(),
230 secs: task.timeout_secs,
231 });
232 }
233 };
234
235 let completed_at = Utc::now();
236 let stdout = truncate(String::from_utf8_lossy(&output.stdout).into_owned());
237 let stderr = truncate(String::from_utf8_lossy(&output.stderr).into_owned());
238 let code = output.status.code().unwrap_or(-1);
239
240 let status = if output.status.success() {
241 AgentTaskStatus::Succeeded
242 } else {
243 AgentTaskStatus::Failed
244 };
245
246 let summary = if status == AgentTaskStatus::Succeeded {
247 first_line(&stdout, &task.description)
248 } else {
249 format!("{} failed (exit {})", self.config.name, code)
250 };
251
252 let artifacts = extract_artifacts(&stdout, &stderr);
253
254 Ok(AgentResult {
255 task_id: task.id,
256 status,
257 summary,
258 artifacts,
259 stdout,
260 stderr,
261 exit_code: Some(code),
262 started_at,
263 completed_at,
264 })
265 }
266
267 async fn health_check(&self) -> bool {
272 if self.config.version_args.is_empty() {
273 return true;
274 }
275 let probe = Command::new(&self.config.binary)
276 .args(&self.config.version_args)
277 .stdin(Stdio::null())
278 .stdout(Stdio::piped())
279 .stderr(Stdio::piped())
280 .kill_on_drop(true)
281 .output();
282 matches!(
283 timeout(Duration::from_secs(5), probe).await,
284 Ok(Ok(o)) if o.status.success()
285 )
286 }
287}
288
289fn truncate(mut s: String) -> String {
290 if s.len() <= CAPTURE_CAP {
291 return s;
292 }
293 s.truncate(CAPTURE_CAP);
294 s.push_str("\n…[truncated]");
295 s
296}
297
298fn first_line(stdout: &str, fallback: &str) -> String {
299 stdout
300 .lines()
301 .map(str::trim)
302 .find(|l| !l.is_empty())
303 .map(|l| l.to_string())
304 .unwrap_or_else(|| format!("completed: {fallback}"))
305}
306
307fn extract_artifacts(stdout: &str, stderr: &str) -> Vec<Artifact> {
316 let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
317 let mut out = Vec::new();
318 for source in [stdout, stderr] {
319 extract_urls_into(source, &mut seen, &mut out);
320 }
321 out
322}
323
324fn extract_urls_into(
325 haystack: &str,
326 seen: &mut std::collections::BTreeSet<String>,
327 out: &mut Vec<Artifact>,
328) {
329 let bytes = haystack.as_bytes();
334 let needles = ["http://", "https://"];
335 for needle in needles {
336 let mut start = 0;
337 while let Some(found) = haystack[start..].find(needle) {
338 let begin = start + found;
339 let mut end = begin + needle.len();
340 while end < bytes.len() && is_url_byte(bytes[end]) {
341 end += 1;
342 }
343 while end > begin + needle.len() {
346 let last = bytes[end - 1];
347 if matches!(last, b'.' | b',' | b';' | b':' | b')' | b']' | b'}' | b'>') {
348 end -= 1;
349 } else {
350 break;
351 }
352 }
353 if end > begin + needle.len() {
354 let url = haystack[begin..end].to_string();
355 if seen.insert(url.clone()) {
356 out.push(Artifact {
357 kind: "url".to_string(),
358 reference: url,
359 summary: None,
360 });
361 }
362 }
363 start = end.max(begin + needle.len());
364 }
365 }
366}
367
368fn is_url_byte(b: u8) -> bool {
369 matches!(b,
372 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
373 | b'-' | b'.' | b'_' | b'~'
374 | b':' | b'/' | b'?' | b'#' | b'[' | b']' | b'@'
375 | b'!' | b'$' | b'&' | b'\'' | b'(' | b')'
376 | b'*' | b'+' | b',' | b';' | b'=' | b'%'
377 )
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383 use crate::traits::AgentContext;
384
385 #[test]
386 fn render_prompt_includes_description_and_context() {
387 let task = AgentTask::new("add a test").with_context(
388 AgentContext::new()
389 .push("memory", "project uses clap 4")
390 .push("conventions", "tabs, trailing newline"),
391 );
392 let rendered = SubprocessAgentDelegate::render_prompt(&task);
393 assert!(rendered.starts_with("# Task"));
394 assert!(rendered.contains("add a test"));
395 assert!(rendered.contains("### memory"));
396 assert!(rendered.contains("### conventions"));
397 assert!(rendered.contains("# Workdir"));
398 }
399
400 #[test]
401 fn truncate_respects_cap() {
402 let s = "a".repeat(CAPTURE_CAP + 500);
403 let out = truncate(s);
404 assert!(out.ends_with("…[truncated]"));
405 assert!(out.len() <= CAPTURE_CAP + "\n…[truncated]".len());
406 }
407
408 #[test]
409 fn extract_artifacts_picks_distinct_urls_from_stdout_and_stderr() {
410 let stdout = "PR opened: https://github.com/foo/bar/pull/42.\n\
411 Already covered: HTTPS://EXAMPLE.COM ignored (case mismatch on scheme).\n\
412 Dup: https://github.com/foo/bar/pull/42 again.";
413 let stderr = "warning: deploy failed at http://staging.local:8080/health,\n\
414 see (https://docs.example.com/runbooks/deploy)";
415 let arts = extract_artifacts(stdout, stderr);
416 let refs: Vec<&str> = arts.iter().map(|a| a.reference.as_str()).collect();
417 assert_eq!(arts.len(), 3, "got {refs:?}");
418 assert!(refs.contains(&"https://github.com/foo/bar/pull/42"));
419 assert!(refs.contains(&"http://staging.local:8080/health"));
420 assert!(refs.contains(&"https://docs.example.com/runbooks/deploy"));
421 for a in &arts {
422 assert_eq!(a.kind, "url");
423 assert!(a.summary.is_none());
424 }
425 }
426
427 #[test]
428 fn extract_artifacts_empty_when_no_urls() {
429 let arts = extract_artifacts("just text\nno links here", "stderr too");
430 assert!(arts.is_empty());
431 }
432
433 #[test]
434 fn extract_artifacts_strips_trailing_punctuation() {
435 let arts = extract_artifacts("see https://example.com/x.", "");
436 assert_eq!(arts.len(), 1);
437 assert_eq!(arts[0].reference, "https://example.com/x");
438 }
439
440 #[test]
441 fn build_args_substitutes_placeholders() {
442 let cfg = SubprocessAgentConfig::new("mock", "echo")
443 .with_args(["--task", "{task_id}", "--prompt", "{prompt}"])
444 .with_prompt_via_stdin(false);
445 let delegate = SubprocessAgentDelegate::new(cfg);
446 let task = AgentTask::new("hi");
447 let args = delegate.build_args("PROMPT", &task);
448 assert_eq!(args[0], "--task");
449 assert_eq!(args[1], task.id);
450 assert_eq!(args[2], "--prompt");
451 assert_eq!(args[3], "PROMPT");
452 }
453
454 #[cfg(unix)]
456 #[tokio::test]
457 async fn echoes_prompt_via_stdin() {
458 let cfg = SubprocessAgentConfig::new("cat-agent", "/bin/cat");
459 let delegate = SubprocessAgentDelegate::new(cfg);
460 let task = AgentTask::new("say hello").with_timeout_secs(5);
461 let result = delegate.delegate(task).await.unwrap();
462 assert_eq!(result.status, AgentTaskStatus::Succeeded);
463 assert!(result.stdout.contains("# Task"));
464 assert!(result.stdout.contains("say hello"));
465 }
466
467 #[cfg(unix)]
468 #[tokio::test]
469 async fn times_out_when_child_hangs() {
470 let cfg = SubprocessAgentConfig::new("sleep-agent", "/bin/sleep").with_args(["30"]);
471 let delegate = SubprocessAgentDelegate::new(cfg);
472 let task = AgentTask::new("noop").with_timeout_secs(1);
473 let err = delegate.delegate(task).await.unwrap_err();
474 matches!(err, AgentError::Timeout { .. });
475 }
476
477 #[cfg(unix)]
478 #[tokio::test]
479 async fn nonzero_exit_surfaces_as_failed_status() {
480 let cfg = SubprocessAgentConfig::new("false-agent", "/usr/bin/false");
481 let delegate = SubprocessAgentDelegate::new(cfg);
482 let task = AgentTask::new("noop").with_timeout_secs(5);
483 let result = delegate.delegate(task).await.unwrap();
484 assert_eq!(result.status, AgentTaskStatus::Failed);
485 assert_ne!(result.exit_code, Some(0));
486 }
487}