pub mod claude_code;
pub mod codex;
pub mod codex_protocol;
pub mod delegation;
pub mod gemini;
pub mod router;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use async_trait::async_trait;
use tracing::{debug, warn};
use crate::Result;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BackendType {
ClaudeCode,
Codex,
GeminiCli,
}
impl std::fmt::Display for BackendType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BackendType::ClaudeCode => write!(f, "claude-code"),
BackendType::Codex => write!(f, "codex"),
BackendType::GeminiCli => write!(f, "gemini-cli"),
}
}
}
impl std::str::FromStr for BackendType {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"claude-code" => Ok(BackendType::ClaudeCode),
"codex" => Ok(BackendType::Codex),
"gemini-cli" => Ok(BackendType::GeminiCli),
other => Err(format!("unknown backend type: {other}")),
}
}
}
#[derive(Debug, Clone)]
pub struct SpawnConfig {
pub name: String,
pub prompt: String,
pub model: Option<String>,
pub cwd: Option<PathBuf>,
pub max_turns: Option<i32>,
pub allowed_tools: Vec<String>,
pub permission_mode: Option<String>,
pub reasoning_effort: Option<String>,
pub env: HashMap<String, String>,
pub memory_config: Option<crate::memory::MemoryConfig>,
pub delegations: Vec<delegation::CliDelegation>,
}
impl SpawnConfig {
pub fn new(name: impl Into<String>, prompt: impl Into<String>) -> Self {
Self {
name: name.into(),
prompt: prompt.into(),
model: None,
cwd: None,
max_turns: None,
allowed_tools: Vec::new(),
permission_mode: None,
reasoning_effort: None,
env: HashMap::new(),
memory_config: None,
delegations: Vec::new(),
}
}
pub fn builder(name: impl Into<String>, prompt: impl Into<String>) -> SpawnConfigBuilder {
SpawnConfigBuilder {
name: name.into(),
prompt: prompt.into(),
model: None,
cwd: None,
max_turns: None,
allowed_tools: Vec::new(),
permission_mode: None,
reasoning_effort: None,
env: HashMap::new(),
memory_config: None,
delegations: Vec::new(),
}
}
}
#[derive(Debug)]
pub struct SpawnConfigBuilder {
name: String,
prompt: String,
model: Option<String>,
cwd: Option<PathBuf>,
max_turns: Option<i32>,
allowed_tools: Vec<String>,
permission_mode: Option<String>,
reasoning_effort: Option<String>,
env: HashMap<String, String>,
memory_config: Option<crate::memory::MemoryConfig>,
delegations: Vec<delegation::CliDelegation>,
}
impl SpawnConfigBuilder {
pub fn model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
self.cwd = Some(cwd.into());
self
}
pub fn max_turns(mut self, turns: i32) -> Self {
self.max_turns = Some(turns);
self
}
pub fn allowed_tools(mut self, tools: Vec<String>) -> Self {
self.allowed_tools = tools;
self
}
pub fn permission_mode(mut self, mode: impl Into<String>) -> Self {
self.permission_mode = Some(mode.into());
self
}
pub fn reasoning_effort(mut self, effort: impl Into<String>) -> Self {
self.reasoning_effort = Some(effort.into());
self
}
pub fn env_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.env.insert(key.into(), value.into());
self
}
pub fn env(mut self, env: HashMap<String, String>) -> Self {
self.env = env;
self
}
pub fn memory(mut self, config: crate::memory::MemoryConfig) -> Self {
self.memory_config = Some(config);
self
}
pub fn delegate(mut self, delegation: delegation::CliDelegation) -> Self {
self.delegations.push(delegation);
self
}
pub fn build(self) -> SpawnConfig {
SpawnConfig {
name: self.name,
prompt: self.prompt,
model: self.model,
cwd: self.cwd,
max_turns: self.max_turns,
allowed_tools: self.allowed_tools,
permission_mode: self.permission_mode,
reasoning_effort: self.reasoning_effort,
env: self.env,
memory_config: self.memory_config,
delegations: self.delegations,
}
}
}
#[derive(Debug, Clone)]
pub enum AgentOutput {
Message(String),
Delta(String),
TurnComplete,
Idle,
Error(String),
}
#[async_trait]
pub trait AgentBackend: Send + Sync {
fn backend_type(&self) -> BackendType;
async fn spawn(&self, config: SpawnConfig) -> Result<Box<dyn AgentSession>>;
}
#[async_trait]
pub trait AgentSession: Send + Sync {
fn name(&self) -> &str;
async fn send_input(&mut self, input: &str) -> Result<()>;
fn output_receiver(&mut self) -> Option<tokio::sync::mpsc::Receiver<AgentOutput>>;
async fn is_alive(&self) -> bool;
async fn shutdown(&mut self) -> Result<()>;
async fn force_kill(&mut self) -> Result<()>;
}
pub type AgentOutputStream = tokio_stream::wrappers::ReceiverStream<AgentOutput>;
const CONTROL_SEND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
pub(crate) async fn send_agent_output(
tx: &tokio::sync::mpsc::Sender<AgentOutput>,
output: AgentOutput,
alive: &Arc<AtomicBool>,
agent_name: &str,
) -> std::result::Result<(), ()> {
let is_control = matches!(
output,
AgentOutput::TurnComplete | AgentOutput::Error(_) | AgentOutput::Idle
);
if is_control {
match tokio::time::timeout(CONTROL_SEND_TIMEOUT, tx.send(output)).await {
Ok(Ok(())) => Ok(()),
Ok(Err(_)) => {
debug!(agent = %agent_name, "Output channel closed");
alive.store(false, Ordering::Relaxed);
Err(())
}
Err(_) => {
warn!(agent = %agent_name, "Control event delivery timed out, marking session dead");
alive.store(false, Ordering::Relaxed);
Err(())
}
}
} else {
match tx.try_send(output) {
Ok(()) => Ok(()),
Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
warn!(agent = %agent_name, "Output channel full, dropping data message");
Ok(())
}
Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
debug!(agent = %agent_name, "Output channel closed");
alive.store(false, Ordering::Relaxed);
Err(())
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backend_type_display_fromstr_round_trip() {
let variants = [BackendType::ClaudeCode, BackendType::Codex, BackendType::GeminiCli];
for variant in &variants {
let s = variant.to_string();
let parsed: BackendType = s.parse().unwrap_or_else(|e| {
panic!("FromStr failed for Display output \"{s}\": {e}");
});
assert_eq!(*variant, parsed, "Round-trip failed for {s}");
}
}
#[test]
fn backend_type_fromstr_rejects_unknown() {
assert!("unknown".parse::<BackendType>().is_err());
assert!("Claude-Code".parse::<BackendType>().is_err()); }
#[test]
fn spawn_config_builder_all_fields() {
let config = SpawnConfig::builder("test-agent", "system prompt")
.model("gpt-4.1")
.cwd("/tmp")
.max_turns(5)
.allowed_tools(vec!["Read".into(), "Write".into()])
.permission_mode("plan")
.reasoning_effort("high")
.env_var("API_KEY", "secret")
.env_var("DEBUG", "1")
.build();
assert_eq!(config.name, "test-agent");
assert_eq!(config.prompt, "system prompt");
assert_eq!(config.model.as_deref(), Some("gpt-4.1"));
assert_eq!(config.cwd.as_ref().unwrap().to_str().unwrap(), "/tmp");
assert_eq!(config.max_turns, Some(5));
assert_eq!(config.allowed_tools, vec!["Read", "Write"]);
assert_eq!(config.permission_mode.as_deref(), Some("plan"));
assert_eq!(config.reasoning_effort.as_deref(), Some("high"));
assert_eq!(config.env.len(), 2);
assert_eq!(config.env["API_KEY"], "secret");
}
#[test]
fn spawn_config_builder_minimal() {
let config = SpawnConfig::builder("agent", "prompt").build();
assert_eq!(config.name, "agent");
assert_eq!(config.prompt, "prompt");
assert!(config.model.is_none());
assert!(config.cwd.is_none());
assert!(config.max_turns.is_none());
assert!(config.allowed_tools.is_empty());
assert!(config.env.is_empty());
}
#[test]
fn spawn_config_builder_is_debug() {
let builder = SpawnConfig::builder("a", "b").model("gpt-4.1");
let debug_str = format!("{builder:?}");
assert!(debug_str.contains("SpawnConfigBuilder"));
}
}