pub mod model;
pub mod spec;
pub mod types;
use std::io::Read;
use std::path::Path;
use std::process::{Command, ExitStatus, Stdio};
use std::time::{Duration, Instant};
use crate::error::{Error, Result};
pub use model::{AgentModel, AgentOptions, Effort};
pub use spec::{AGENTS, AgentKind, AgentSpec, ResultFormat};
pub use types::{AgentRun, AgentVersion, DetectedAgent};
pub trait AgentClient {
fn detect(&self, kind: AgentKind) -> Result<Option<DetectedAgent>>;
fn run(
&self,
kind: AgentKind,
prompt: &str,
dir: &Path,
opts: &AgentOptions,
) -> Result<AgentRun>;
fn detect_all(&self) -> Vec<DetectedAgent> {
AgentKind::all()
.iter()
.filter_map(|&kind| self.detect(kind).ok().flatten())
.collect()
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RealAgent;
impl AgentClient for RealAgent {
fn detect(&self, kind: AgentKind) -> Result<Option<DetectedAgent>> {
detect_with(kind.spec().binary, kind, kind.spec())
}
fn run(
&self,
kind: AgentKind,
prompt: &str,
dir: &Path,
opts: &AgentOptions,
) -> Result<AgentRun> {
run_with(kind.spec().binary, kind, kind.spec(), prompt, dir, opts)
}
}
fn detect_with(binary: &str, kind: AgentKind, spec: &AgentSpec) -> Result<Option<DetectedAgent>> {
match run_agent(binary, None, &spec::version_argv(spec), None) {
Ok(stdout) => Ok(Some(DetectedAgent {
kind,
binary: binary.to_string(),
version: spec::parse_version(&stdout),
})),
Err(Error::AgentUnavailable(_)) => Ok(None),
Err(e) => Err(e),
}
}
fn run_with(
binary: &str,
kind: AgentKind,
spec: &AgentSpec,
prompt: &str,
dir: &Path,
opts: &AgentOptions,
) -> Result<AgentRun> {
let prompt = spec::apply_effort(opts.effort, prompt);
let argv = spec::prompt_argv(spec, &prompt, opts.model);
let stdout = run_agent(binary, Some(dir), &argv, opts.timeout)?;
spec::parse_result(kind, spec.result_format, &stdout)
}
fn run_agent(
binary: &str,
dir: Option<&Path>,
args: &[String],
timeout: Option<Duration>,
) -> Result<String> {
let mut cmd = Command::new(binary);
if let Some(dir) = dir {
cmd.current_dir(dir);
}
cmd.args(args);
let Some(limit) = timeout else {
return match cmd.output() {
Ok(output) => finish(binary, output.status, &output.stdout, &output.stderr),
Err(e) => Err(spawn_error(binary, &e)),
};
};
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = match cmd.spawn() {
Ok(child) => child,
Err(e) => return Err(spawn_error(binary, &e)),
};
let mut out_pipe = child.stdout.take();
let mut err_pipe = child.stderr.take();
let out_reader = std::thread::spawn(move || read_pipe(out_pipe.as_mut()));
let err_reader = std::thread::spawn(move || read_pipe(err_pipe.as_mut()));
let deadline = Instant::now() + limit;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break Some(status),
Ok(None) => {}
Err(e) => return Err(spawn_error(binary, &e)),
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
break None;
}
std::thread::sleep(POLL_INTERVAL);
};
match status {
Some(status) => {
let stdout = out_reader.join().unwrap_or_default();
let stderr = err_reader.join().unwrap_or_default();
finish(binary, status, &stdout, &stderr)
}
None => {
drop(out_reader);
drop(err_reader);
Err(Error::AgentTimeout {
binary: binary.to_string(),
seconds: limit.as_secs().max(1),
})
}
}
}
const POLL_INTERVAL: Duration = Duration::from_millis(10);
fn read_pipe(pipe: Option<&mut impl Read>) -> Vec<u8> {
let mut buf = Vec::new();
if let Some(pipe) = pipe {
let _ = pipe.read_to_end(&mut buf);
}
buf
}
fn spawn_error(binary: &str, e: &std::io::Error) -> Error {
if e.kind() == std::io::ErrorKind::NotFound {
Error::AgentUnavailable(format!("{binary} is not installed or not on PATH"))
} else {
Error::AgentUnavailable(format!("failed to run {binary}: {e}"))
}
}
fn finish(binary: &str, status: ExitStatus, stdout: &[u8], stderr: &[u8]) -> Result<String> {
if status.success() {
return Ok(String::from_utf8_lossy(stdout).into_owned());
}
Err(Error::Subprocess {
program: binary.to_string(),
stderr: String::from_utf8_lossy(stderr).trim().to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
const MISSING: &str = "wt-nonexistent-agent-binary-xyzzy";
enum Behavior {
Found,
Missing,
Failing,
}
struct Fake(Behavior);
impl AgentClient for Fake {
fn detect(&self, kind: AgentKind) -> Result<Option<DetectedAgent>> {
match self.0 {
Behavior::Found => Ok(Some(DetectedAgent {
kind,
binary: kind.as_str().to_string(),
version: AgentVersion {
version: None,
raw: String::new(),
},
})),
Behavior::Missing => Ok(None),
Behavior::Failing => Err(Error::operation("boom")),
}
}
fn run(
&self,
kind: AgentKind,
prompt: &str,
_dir: &Path,
_opts: &AgentOptions,
) -> Result<AgentRun> {
Ok(AgentRun {
kind,
is_error: false,
result: prompt.to_string(),
raw: serde_json::Value::Null,
})
}
}
#[test]
fn detect_all_keeps_found_drops_missing_and_failing() {
assert_eq!(
Fake(Behavior::Found).detect_all().len(),
AgentKind::all().len()
);
assert!(Fake(Behavior::Missing).detect_all().is_empty());
assert!(Fake(Behavior::Failing).detect_all().is_empty());
}
#[test]
fn fake_run_returns_normalized_result() {
let dir = tempfile::tempdir().unwrap();
let run = Fake(Behavior::Found)
.run(
AgentKind::Claude,
"hi",
dir.path(),
&AgentOptions::default(),
)
.unwrap();
assert_eq!(run.result, "hi");
assert!(!run.is_error);
}
#[test]
fn run_agent_maps_missing_binary_to_unavailable() {
let err = run_agent(MISSING, None, &["--version".to_string()], None).unwrap_err();
assert!(matches!(err, Error::AgentUnavailable(_)));
}
#[test]
fn detect_with_returns_none_for_missing_binary() {
let result = detect_with(MISSING, AgentKind::Claude, AgentKind::Claude.spec()).unwrap();
assert!(result.is_none());
}
#[test]
fn real_agent_detect_claude_does_not_error() {
assert!(RealAgent.detect(AgentKind::Claude).is_ok());
}
#[cfg(unix)]
mod unix {
use super::*;
const SH_VERSION: AgentSpec = AgentSpec {
kind: AgentKind::Claude,
binary: "sh",
version_args: &["-c", "echo '9.9.9 (test agent)'"],
run_args: &["-c", "printf '{\"is_error\":false,\"result\":\"ok\"}'"],
prompt_positional: true,
json_args: &[],
model_flag: "",
result_format: ResultFormat::SingleObject,
};
const SH_FAIL: AgentSpec = AgentSpec {
kind: AgentKind::Claude,
binary: "sh",
version_args: &["-c", "exit 1"],
run_args: &["-c", "true"],
prompt_positional: true,
json_args: &[],
model_flag: "",
result_format: ResultFormat::SingleObject,
};
#[test]
fn run_agent_returns_stdout_on_success() {
let out = run_agent(
"sh",
None,
&["-c".to_string(), "printf hello".to_string()],
None,
)
.unwrap();
assert_eq!(out, "hello");
}
#[test]
fn run_agent_maps_nonzero_exit_to_subprocess() {
let err =
run_agent("sh", None, &["-c".to_string(), "exit 3".to_string()], None).unwrap_err();
match err {
Error::Subprocess { program, .. } => assert_eq!(program, "sh"),
other => panic!("expected subprocess error, got {other:?}"),
}
}
#[test]
fn run_agent_kills_a_child_that_outlives_its_deadline() {
let started = Instant::now();
let err = run_agent(
"sh",
None,
&["-c".to_string(), "sleep 30 & wait".to_string()],
Some(Duration::from_millis(100)),
)
.unwrap_err();
let elapsed = started.elapsed();
match err {
Error::AgentTimeout { binary, seconds } => {
assert_eq!(binary, "sh");
assert_eq!(seconds, 1);
}
other => panic!("expected a timeout, got {other:?}"),
}
assert!(
elapsed < Duration::from_secs(10),
"returned after {elapsed:?}; the child was not killed"
);
}
#[test]
fn a_deadline_does_not_disturb_a_process_that_finishes() {
let out = run_agent(
"sh",
None,
&["-c".to_string(), "printf hello".to_string()],
Some(Duration::from_secs(30)),
)
.unwrap();
assert_eq!(out, "hello");
}
#[test]
fn a_deadline_still_maps_a_nonzero_exit_to_subprocess() {
let err = run_agent(
"sh",
None,
&["-c".to_string(), "printf oops >&2; exit 3".to_string()],
Some(Duration::from_secs(30)),
)
.unwrap_err();
match err {
Error::Subprocess { program, stderr } => {
assert_eq!(program, "sh");
assert_eq!(stderr, "oops");
}
other => panic!("expected subprocess error, got {other:?}"),
}
}
#[test]
fn detect_with_parses_version_from_real_process() {
let detected = detect_with("sh", AgentKind::Claude, &SH_VERSION)
.unwrap()
.unwrap();
assert_eq!(detected.binary, "sh");
assert_eq!(detected.version.version, Some("9.9.9".to_string()));
}
#[test]
fn detect_with_propagates_non_unavailable_errors() {
let err = detect_with("sh", AgentKind::Claude, &SH_FAIL).unwrap_err();
assert!(matches!(err, Error::Subprocess { .. }));
}
#[test]
fn run_with_invokes_and_parses_result() {
let dir = tempfile::tempdir().unwrap();
let run = run_with(
"sh",
AgentKind::Claude,
&SH_VERSION,
"my prompt",
dir.path(),
&AgentOptions::default(),
)
.unwrap();
assert!(!run.is_error);
assert_eq!(run.result, "ok");
}
}
}