pub mod aider;
pub mod backend;
pub mod claude_code;
pub mod codex;
pub mod dry_run;
pub mod gemini;
pub mod subprocess;
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Result;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::state::TokenUsage;
pub use subprocess::{run_logged, run_logged_with_stdin, SubprocessOutcome};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
Planner,
Implementer,
Auditor,
Fixer,
}
impl Role {
pub fn as_str(self) -> &'static str {
match self {
Role::Planner => "planner",
Role::Implementer => "implementer",
Role::Auditor => "auditor",
Role::Fixer => "fixer",
}
}
}
impl std::fmt::Display for Role {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone)]
pub struct AgentRequest {
pub role: Role,
pub model: String,
pub system_prompt: String,
pub user_prompt: String,
pub workdir: PathBuf,
pub log_path: PathBuf,
pub timeout: Duration,
pub env: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub enum AgentEvent {
Stdout(String),
Stderr(String),
TokenDelta(TokenUsage),
ToolUse(String),
}
#[derive(Debug, Clone)]
pub struct AgentOutcome {
pub exit_code: i32,
pub stop_reason: StopReason,
pub tokens: TokenUsage,
pub log_path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StopReason {
Completed,
Timeout,
Cancelled,
Error(String),
}
#[async_trait]
pub trait Agent: Send + Sync {
fn name(&self) -> &str;
async fn run(
&self,
req: AgentRequest,
events: mpsc::Sender<AgentEvent>,
cancel: CancellationToken,
) -> Result<AgentOutcome>;
}
#[async_trait]
impl<A: Agent + ?Sized> Agent for Box<A> {
fn name(&self) -> &str {
(**self).name()
}
async fn run(
&self,
req: AgentRequest,
events: mpsc::Sender<AgentEvent>,
cancel: CancellationToken,
) -> Result<AgentOutcome> {
(**self).run(req, events, cancel).await
}
}
pub fn build_agent(cfg: &crate::config::Config) -> Result<Box<dyn Agent + Send + Sync>> {
let kind = match cfg.agent.backend.as_deref() {
None => backend::BackendKind::default(),
Some(s) => s.parse::<backend::BackendKind>()?,
};
match kind {
backend::BackendKind::ClaudeCode => {
let overrides = &cfg.agent.claude_code;
let mut agent = match overrides.binary.as_ref() {
Some(path) => claude_code::ClaudeCodeAgent::with_binary(path),
None => claude_code::ClaudeCodeAgent::new(),
};
if !overrides.extra_args.is_empty() {
agent = agent.with_extra_args(overrides.extra_args.clone());
}
if let Some(model) = overrides.model.as_deref() {
agent = agent.with_model_override(model);
}
if let Some(mode) = overrides.permission_mode.as_deref() {
agent = agent.with_permission_mode(mode);
}
Ok(Box::new(agent))
}
backend::BackendKind::Codex => {
let overrides = &cfg.agent.codex;
let mut agent = match overrides.binary.as_ref() {
Some(path) => codex::CodexAgent::with_binary(path),
None => codex::CodexAgent::new(),
};
if !overrides.extra_args.is_empty() {
agent = agent.with_extra_args(overrides.extra_args.clone());
}
if let Some(model) = overrides.model.as_deref() {
agent = agent.with_model_override(model);
}
Ok(Box::new(agent))
}
backend::BackendKind::Aider => {
let overrides = &cfg.agent.aider;
let mut agent = match overrides.binary.as_ref() {
Some(path) => aider::AiderAgent::with_binary(path),
None => aider::AiderAgent::new(),
};
if !overrides.extra_args.is_empty() {
agent = agent.with_extra_args(overrides.extra_args.clone());
}
if let Some(model) = overrides.model.as_deref() {
agent = agent.with_model_override(model);
}
Ok(Box::new(agent))
}
backend::BackendKind::Gemini => {
let overrides = &cfg.agent.gemini;
let mut agent = match overrides.binary.as_ref() {
Some(path) => gemini::GeminiAgent::with_binary(path),
None => gemini::GeminiAgent::new(),
};
if !overrides.extra_args.is_empty() {
agent = agent.with_extra_args(overrides.extra_args.clone());
}
if let Some(model) = overrides.model.as_deref() {
agent = agent.with_model_override(model);
}
Ok(Box::new(agent))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn role_as_str_matches_config_keys() {
assert_eq!(Role::Planner.as_str(), "planner");
assert_eq!(Role::Implementer.as_str(), "implementer");
assert_eq!(Role::Auditor.as_str(), "auditor");
assert_eq!(Role::Fixer.as_str(), "fixer");
}
#[test]
fn role_serde_round_trips_through_lowercase_string() {
let json = serde_json::to_string(&Role::Implementer).unwrap();
assert_eq!(json, "\"implementer\"");
let back: Role = serde_json::from_str(&json).unwrap();
assert_eq!(back, Role::Implementer);
}
#[test]
fn stop_reason_equality_ignores_completion_payload() {
assert_eq!(StopReason::Completed, StopReason::Completed);
assert_ne!(StopReason::Completed, StopReason::Timeout);
assert_eq!(StopReason::Error("x".into()), StopReason::Error("x".into()));
assert_ne!(StopReason::Error("x".into()), StopReason::Error("y".into()));
}
#[test]
fn build_agent_defaults_to_claude_code_when_unspecified() {
let cfg = crate::config::Config::default();
match build_agent(&cfg) {
Ok(agent) => assert_eq!(agent.name(), "claude-code"),
Err(e) => panic!("default config must build the claude_code agent: {e:#}"),
}
}
#[test]
fn build_agent_dispatches_explicit_claude_code() {
let mut cfg = crate::config::Config::default();
cfg.agent.backend = Some("claude_code".to_string());
match build_agent(&cfg) {
Ok(agent) => assert_eq!(agent.name(), "claude-code"),
Err(e) => panic!("explicit claude_code must build: {e:#}"),
}
}
#[test]
fn build_agent_has_no_pending_backends() {
for name in ["claude_code", "codex", "aider", "gemini"] {
let mut cfg = crate::config::Config::default();
cfg.agent.backend = Some(name.to_string());
assert!(
build_agent(&cfg).is_ok(),
"backend {name} must build a concrete agent"
);
}
}
#[test]
fn build_agent_dispatches_explicit_codex() {
let mut cfg = crate::config::Config::default();
cfg.agent.backend = Some("codex".to_string());
match build_agent(&cfg) {
Ok(agent) => assert_eq!(agent.name(), "codex"),
Err(e) => panic!("explicit codex must build: {e:#}"),
}
}
#[test]
fn build_agent_dispatches_explicit_aider() {
let mut cfg = crate::config::Config::default();
cfg.agent.backend = Some("aider".to_string());
match build_agent(&cfg) {
Ok(agent) => assert_eq!(agent.name(), "aider"),
Err(e) => panic!("explicit aider must build: {e:#}"),
}
}
#[test]
fn build_agent_dispatches_explicit_gemini() {
let mut cfg = crate::config::Config::default();
cfg.agent.backend = Some("gemini".to_string());
match build_agent(&cfg) {
Ok(agent) => assert_eq!(agent.name(), "gemini"),
Err(e) => panic!("explicit gemini must build: {e:#}"),
}
}
#[test]
fn build_agent_gemini_honors_overrides() {
let mut cfg = crate::config::Config::default();
cfg.agent.backend = Some("gemini".to_string());
cfg.agent.gemini.binary = Some(std::path::PathBuf::from("/tmp/fake-gemini"));
cfg.agent.gemini.extra_args = vec!["--include-directories".into(), "src".into()];
cfg.agent.gemini.model = Some("gemini-2.5-flash".into());
match build_agent(&cfg) {
Ok(agent) => assert_eq!(agent.name(), "gemini"),
Err(e) => panic!("gemini with overrides must build: {e:#}"),
}
}
#[test]
fn build_agent_aider_honors_overrides() {
let mut cfg = crate::config::Config::default();
cfg.agent.backend = Some("aider".to_string());
cfg.agent.aider.binary = Some(std::path::PathBuf::from("/tmp/fake-aider"));
cfg.agent.aider.extra_args = vec!["--no-auto-commits".into()];
cfg.agent.aider.model = Some("anthropic/sonnet-4.5".into());
match build_agent(&cfg) {
Ok(agent) => assert_eq!(agent.name(), "aider"),
Err(e) => panic!("aider with overrides must build: {e:#}"),
}
}
#[test]
fn build_agent_claude_code_honors_overrides() {
let mut cfg = crate::config::Config::default();
cfg.agent.backend = Some("claude_code".to_string());
cfg.agent.claude_code.binary = Some(std::path::PathBuf::from("/tmp/fake-claude"));
cfg.agent.claude_code.extra_args = vec!["--max-turns".into(), "50".into()];
cfg.agent.claude_code.model = Some("claude-opus-4-7".into());
match build_agent(&cfg) {
Ok(agent) => assert_eq!(agent.name(), "claude-code"),
Err(e) => panic!("claude_code with overrides must build: {e:#}"),
}
}
#[test]
fn build_agent_codex_honors_binary_override() {
let mut cfg = crate::config::Config::default();
cfg.agent.backend = Some("codex".to_string());
cfg.agent.codex.binary = Some(std::path::PathBuf::from("/tmp/fake-codex"));
cfg.agent.codex.extra_args = vec!["--quiet".into()];
cfg.agent.codex.model = Some("gpt-5-codex".into());
match build_agent(&cfg) {
Ok(agent) => assert_eq!(agent.name(), "codex"),
Err(e) => panic!("codex with overrides must build: {e:#}"),
}
}
#[test]
fn build_agent_rejects_unknown_backend() {
let mut cfg = crate::config::Config::default();
cfg.agent.backend = Some("ollama".into());
match build_agent(&cfg) {
Ok(_) => panic!("unknown backend must not build"),
Err(e) => {
let msg = format!("{e:#}");
assert!(
msg.contains("ollama"),
"expected unknown-backend error to echo the input, got: {msg}"
);
}
}
}
}