Adding New Agents
This guide walks through adding support for a new coding agent to codeloops.
Overview
To add a new agent, you need to:
- Implement the
Agenttrait - Add the agent type to the
AgentTypeenum - Update the agent factory function
- Add CLI support
- Update documentation and tests
Step 1: Implement the Agent Trait
Create a new file in crates/codeloops-agent/src/agents/:
#![allow(unused)] fn main() { // crates/codeloops-agent/src/agents/aider.rs use crate::{Agent, AgentConfig, AgentError, AgentOutput, AgentType, OutputCallback}; use async_trait::async_trait; use std::path::{Path, PathBuf}; use std::process::Stdio; use tokio::process::Command; use tokio::io::{AsyncBufReadExt, BufReader}; /// Aider coding agent implementation. pub struct AiderAgent { binary_path: PathBuf, } impl AiderAgent { pub fn new() -> Self { Self { binary_path: PathBuf::from("aider"), } } } #[async_trait] impl Agent for AiderAgent { fn name(&self) -> &str { "Aider" } fn agent_type(&self) -> AgentType { AgentType::Aider } // Note: execute() has a default implementation that calls execute_with_callback(), // so you only need to implement execute_with_callback(). async fn execute_with_callback( &self, prompt: &str, config: &AgentConfig, on_output: Option<OutputCallback>, ) -> Result<AgentOutput, AgentError> { let start = std::time::Instant::now(); // Build command let mut cmd = Command::new(&self.binary_path); cmd.current_dir(&config.working_dir); // Add agent-specific arguments cmd.arg("--message").arg(prompt); cmd.arg("--yes"); // Auto-confirm changes cmd.arg("--no-git"); // Let codeloops handle git // Add model if specified if let Some(model) = &config.model { cmd.arg("--model").arg(model); } // Set up I/O cmd.stdin(Stdio::null()); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); // Spawn process let mut child = cmd.spawn().map_err(|e| { AgentError::SpawnError(format!("Failed to spawn aider: {}", e)) })?; // Capture output let stdout = child.stdout.take().unwrap(); let stderr = child.stderr.take().unwrap(); let mut stdout_reader = BufReader::new(stdout).lines(); let mut stderr_reader = BufReader::new(stderr).lines(); let mut stdout_output = String::new(); let mut stderr_output = String::new(); // Read output streams loop { tokio::select! { line = stdout_reader.next_line() => { match line { Ok(Some(line)) => { if let Some(ref callback) = on_output { callback(&line); } stdout_output.push_str(&line); stdout_output.push('\n'); } Ok(None) => break, Err(e) => { stderr_output.push_str(&format!("Read error: {}\n", e)); break; } } } line = stderr_reader.next_line() => { if let Ok(Some(line)) = line { stderr_output.push_str(&line); stderr_output.push('\n'); } } } } // Wait for process let status = child.wait().await.map_err(|e| { AgentError::ExecutionError(format!("Failed to wait for aider: {}", e)) })?; let duration = start.elapsed(); Ok(AgentOutput { stdout: stdout_output, stderr: stderr_output, exit_code: status.code().unwrap_or(-1), duration, }) } async fn is_available(&self) -> bool { Command::new(&self.binary_path) .arg("--version") .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .await .map(|s| s.success()) .unwrap_or(false) } fn binary_path(&self) -> &Path { &self.binary_path } } impl Default for AiderAgent { fn default() -> Self { Self::new() } } }
Step 2: Add the Agent Type
Edit crates/codeloops-agent/src/lib.rs:
#![allow(unused)] fn main() { /// Supported agent types. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AgentType { ClaudeCode, OpenCode, Cursor, Aider, // Add new variant } impl AgentType { pub fn display_name(&self) -> &'static str { match self { AgentType::ClaudeCode => "Claude Code", AgentType::OpenCode => "OpenCode", AgentType::Cursor => "Cursor", AgentType::Aider => "Aider", // Add display name } } } }
Step 3: Update the Factory Function
Edit crates/codeloops-agent/src/lib.rs:
#![allow(unused)] fn main() { mod agents; pub use agents::aider::AiderAgent; // Export new agent pub use agents::claude::ClaudeCodeAgent; pub use agents::cursor::CursorAgent; pub use agents::opencode::OpenCodeAgent; /// Create an agent instance from the agent type. pub fn create_agent(agent_type: AgentType) -> Box<dyn Agent> { match agent_type { AgentType::ClaudeCode => Box::new(ClaudeCodeAgent::new()), AgentType::OpenCode => Box::new(OpenCodeAgent::new()), AgentType::Cursor => Box::new(CursorAgent::new()), AgentType::Aider => Box::new(AiderAgent::new()), // Add factory case } } }
Don't forget to add the module declaration:
#![allow(unused)] fn main() { // crates/codeloops-agent/src/agents/mod.rs pub mod aider; pub mod claude; pub mod cursor; pub mod opencode; }
Step 4: Add CLI Support
Edit crates/codeloops/src/main.rs:
#![allow(unused)] fn main() { /// Agent choice for CLI arguments. #[derive(Debug, Clone, Copy, ValueEnum)] pub enum AgentChoice { Claude, Opencode, Cursor, Aider, // Add new variant } impl From<AgentChoice> for AgentType { fn from(choice: AgentChoice) -> Self { match choice { AgentChoice::Claude => AgentType::ClaudeCode, AgentChoice::Opencode => AgentType::OpenCode, AgentChoice::Cursor => AgentType::Cursor, AgentChoice::Aider => AgentType::Aider, // Add mapping } } } }
Step 5: Update Configuration
Edit crates/codeloops/src/config.rs to recognize the new agent string:
#![allow(unused)] fn main() { fn parse_agent(s: &str) -> Option<AgentType> { match s.to_lowercase().as_str() { "claude" => Some(AgentType::ClaudeCode), "opencode" => Some(AgentType::OpenCode), "cursor" => Some(AgentType::Cursor), "aider" => Some(AgentType::Aider), // Add parsing _ => None, } } }
Step 6: Write Tests
Create test file crates/codeloops-agent/src/agents/aider_test.rs:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; #[test] fn test_agent_name() { let agent = AiderAgent::new(); assert_eq!(agent.name(), "Aider"); } #[test] fn test_agent_type() { let agent = AiderAgent::new(); assert_eq!(agent.agent_type(), AgentType::Aider); } #[test] fn test_binary_path() { let agent = AiderAgent::new(); assert_eq!(agent.binary_path(), Path::new("aider")); } #[tokio::test] async fn test_execute_not_available() { // Test behavior when agent is not installed let agent = AiderAgent { binary_path: PathBuf::from("/nonexistent/aider"), }; assert!(!agent.is_available().await); } } }
Step 7: Update Documentation
CLI Reference
Update docs/src/user-guide/cli-reference.md:
Agent values: `claude`, `opencode`, `cursor`, `aider`
Agents Guide
Update docs/src/user-guide/agents.md:
## Supported Agents
| Agent | CLI Value | Binary | Description |
|-------|-----------|--------|-------------|
| Claude Code | `claude` | `claude` | Anthropic's Claude-powered coding agent |
| OpenCode | `opencode` | `opencode` | Multi-model coding agent |
| Cursor | `cursor` | `cursor` | Cursor IDE's agent CLI |
| Aider | `aider` | `aider` | AI pair programming in your terminal |
### Aider
Aider is an AI pair programming tool that works in your terminal.
**Binary**: `aider`
**Strengths**:
- Works with many LLM providers
- Good for iterative editing
- Supports multiple files
**Installation**: Visit [aider.chat](https://aider.chat/)
Configuration Schema
Update docs/src/reference/config-schema.md:
### Agent Values
| Value | Agent |
|-------|-------|
| `"claude"` | Claude Code |
| `"opencode"` | OpenCode |
| `"cursor"` | Cursor |
| `"aider"` | Aider |
Step 8: Test the Integration
# Build
cargo build
# Run tests
cargo test -p codeloops-agent
# Test CLI
./target/debug/codeloops --agent aider --dry-run
# Test actual execution (requires aider installed)
./target/debug/codeloops --agent aider --prompt "Fix typo"
Agent Implementation Tips
Handle Different Output Formats
Agents may produce output in different formats. Normalize output for the critic:
#![allow(unused)] fn main() { fn normalize_output(&self, raw_output: &str) -> String { // Remove agent-specific noise // Standardize formatting raw_output.to_string() } }
Handle Model Selection
Different agents support models differently:
#![allow(unused)] fn main() { // Some agents use --model cmd.arg("--model").arg(model); // Some use environment variables cmd.env("MODEL_NAME", model); // Some use different flag names cmd.arg("-m").arg(model); }
Handle Working Directory
Agents should run in the specified working directory:
#![allow(unused)] fn main() { cmd.current_dir(&config.working_dir); }
Handle Prompts
Different agents accept prompts differently:
#![allow(unused)] fn main() { // Via argument cmd.arg("--message").arg(prompt); // Via stdin cmd.stdin(Stdio::piped()); // Then write prompt to stdin after spawn // Via file let prompt_file = config.working_dir.join(".prompt"); std::fs::write(&prompt_file, prompt)?; cmd.arg("--prompt-file").arg(&prompt_file); }
Handle Errors Gracefully
#![allow(unused)] fn main() { if !status.success() { // Don't fail immediately - let the critic handle it // The critic can provide recovery suggestions } }
Checklist
Before submitting your PR:
- Agent trait implemented
- AgentType enum updated
- Factory function updated
- CLI AgentChoice added
- Configuration parsing updated
- Tests written
- Documentation updated (agents guide, CLI reference, config schema)
-
All tests pass (
cargo test --workspace) -
Code formatted (
cargo fmt) -
No clippy warnings (
cargo clippy --workspace)