Skip to main content

agent_code_lib/services/
coordinator.rs

1//! Multi-agent coordinator.
2//!
3//! Routes tasks to specialized agents based on the task type.
4//! The coordinator acts as an orchestrator, spawning agents with
5//! appropriate configurations and aggregating their results.
6//!
7//! # Agent types
8//!
9//! - `general-purpose`: default agent with full tool access
10//! - `explore`: fast read-only agent for codebase exploration
11//! - `plan`: planning agent restricted to analysis tools
12//!
13//! Agents are defined as configurations that customize the tool
14//! set, system prompt, and permission mode.
15
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18use std::path::PathBuf;
19use std::sync::Arc;
20use tokio::sync::Mutex;
21use tracing::{debug, info, warn};
22
23/// Definition of a specialized agent type.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct AgentDefinition {
26    /// Unique agent type name.
27    pub name: String,
28    /// Description of what this agent specializes in.
29    pub description: String,
30    /// System prompt additions for this agent type.
31    pub system_prompt: Option<String>,
32    /// Model override (if different from default).
33    pub model: Option<String>,
34    /// Tools to include (if empty, use all).
35    pub include_tools: Vec<String>,
36    /// Tools to exclude.
37    pub exclude_tools: Vec<String>,
38    /// Whether this agent runs in read-only mode.
39    pub read_only: bool,
40    /// Maximum turns for this agent type.
41    pub max_turns: Option<usize>,
42}
43
44/// Registry of available agent types.
45pub struct AgentRegistry {
46    agents: HashMap<String, AgentDefinition>,
47}
48
49impl AgentRegistry {
50    /// Create the registry with built-in agent types.
51    pub fn with_defaults() -> Self {
52        let mut agents = HashMap::new();
53
54        agents.insert(
55            "general-purpose".to_string(),
56            AgentDefinition {
57                name: "general-purpose".to_string(),
58                description: "General-purpose agent with full tool access.".to_string(),
59                system_prompt: None,
60                model: None,
61                include_tools: Vec::new(),
62                exclude_tools: Vec::new(),
63                read_only: false,
64                max_turns: None,
65            },
66        );
67
68        agents.insert(
69            "explore".to_string(),
70            AgentDefinition {
71                name: "explore".to_string(),
72                description: "Fast read-only agent for searching and understanding code."
73                    .to_string(),
74                system_prompt: Some(
75                    "You are a fast exploration agent. Focus on finding information \
76                     quickly. Use Grep, Glob, and FileRead to answer questions about \
77                     the codebase. Do not modify files."
78                        .to_string(),
79                ),
80                model: None,
81                include_tools: vec![
82                    "FileRead".into(),
83                    "Grep".into(),
84                    "Glob".into(),
85                    "Bash".into(),
86                    "WebFetch".into(),
87                ],
88                exclude_tools: Vec::new(),
89                read_only: true,
90                max_turns: Some(20),
91            },
92        );
93
94        agents.insert(
95            "plan".to_string(),
96            AgentDefinition {
97                name: "plan".to_string(),
98                description: "Planning agent that designs implementation strategies.".to_string(),
99                system_prompt: Some(
100                    "You are a software architect agent. Design implementation plans, \
101                     identify critical files, and consider architectural trade-offs. \
102                     Do not modify files directly."
103                        .to_string(),
104                ),
105                model: None,
106                include_tools: vec![
107                    "FileRead".into(),
108                    "Grep".into(),
109                    "Glob".into(),
110                    "Bash".into(),
111                ],
112                exclude_tools: Vec::new(),
113                read_only: true,
114                max_turns: Some(30),
115            },
116        );
117
118        Self { agents }
119    }
120
121    /// Look up an agent definition by type name.
122    pub fn get(&self, name: &str) -> Option<&AgentDefinition> {
123        self.agents.get(name)
124    }
125
126    /// Register a custom agent type.
127    pub fn register(&mut self, definition: AgentDefinition) {
128        self.agents.insert(definition.name.clone(), definition);
129    }
130
131    /// List all available agent types.
132    pub fn list(&self) -> Vec<&AgentDefinition> {
133        let mut agents: Vec<_> = self.agents.values().collect();
134        agents.sort_by_key(|a| &a.name);
135        agents
136    }
137
138    /// Load agent definitions from disk (`.agent/agents/` and `~/.config/agent-code/agents/`).
139    /// Each `.md` file is parsed for YAML frontmatter with agent configuration.
140    pub fn load_from_disk(&mut self, cwd: Option<&std::path::Path>) {
141        // Project-level agents.
142        if let Some(cwd) = cwd {
143            let project_dir = cwd.join(".agent").join("agents");
144            self.load_agents_from_dir(&project_dir);
145        }
146
147        // User-level agents.
148        if let Some(config_dir) = dirs::config_dir() {
149            let user_dir = config_dir.join("agent-code").join("agents");
150            self.load_agents_from_dir(&user_dir);
151        }
152    }
153
154    fn load_agents_from_dir(&mut self, dir: &std::path::Path) {
155        let entries = match std::fs::read_dir(dir) {
156            Ok(e) => e,
157            Err(_) => return,
158        };
159
160        for entry in entries.flatten() {
161            let path = entry.path();
162            if path.extension().is_some_and(|e| e == "md")
163                && let Some(def) = parse_agent_file(&path)
164            {
165                self.agents.insert(def.name.clone(), def);
166            }
167        }
168    }
169}
170
171/// Parse an agent definition from a markdown file with YAML frontmatter.
172///
173/// Expected format:
174/// ```markdown
175/// ---
176/// name: my-agent
177/// description: A specialized agent
178/// model: gpt-4.1-mini
179/// read_only: false
180/// max_turns: 20
181/// include_tools: [FileRead, Grep, Glob]
182/// exclude_tools: [Bash]
183/// ---
184///
185/// System prompt additions go here...
186/// ```
187fn parse_agent_file(path: &std::path::Path) -> Option<AgentDefinition> {
188    let content = std::fs::read_to_string(path).ok()?;
189
190    // Parse YAML frontmatter.
191    if !content.starts_with("---") {
192        return None;
193    }
194    let end = content[3..].find("---")?;
195    let frontmatter = &content[3..3 + end];
196    let body = content[3 + end + 3..].trim();
197
198    let mut name = path
199        .file_stem()
200        .and_then(|s| s.to_str())
201        .unwrap_or("custom")
202        .to_string();
203    let mut description = String::new();
204    let mut model = None;
205    let mut read_only = false;
206    let mut max_turns = None;
207    let mut include_tools = Vec::new();
208    let mut exclude_tools = Vec::new();
209
210    for line in frontmatter.lines() {
211        let line = line.trim();
212        if let Some((key, value)) = line.split_once(':') {
213            let key = key.trim();
214            let value = value.trim();
215            match key {
216                "name" => name = value.to_string(),
217                "description" => description = value.to_string(),
218                "model" => model = Some(value.to_string()),
219                "read_only" => read_only = value == "true",
220                "max_turns" => max_turns = value.parse().ok(),
221                "include_tools" => {
222                    include_tools = value
223                        .trim_matches(|c| c == '[' || c == ']')
224                        .split(',')
225                        .map(|s| s.trim().to_string())
226                        .filter(|s| !s.is_empty())
227                        .collect();
228                }
229                "exclude_tools" => {
230                    exclude_tools = value
231                        .trim_matches(|c| c == '[' || c == ']')
232                        .split(',')
233                        .map(|s| s.trim().to_string())
234                        .filter(|s| !s.is_empty())
235                        .collect();
236                }
237                _ => {}
238            }
239        }
240    }
241
242    let system_prompt = if body.is_empty() {
243        None
244    } else {
245        Some(body.to_string())
246    };
247
248    Some(AgentDefinition {
249        name,
250        description,
251        system_prompt,
252        model,
253        include_tools,
254        exclude_tools,
255        read_only,
256        max_turns,
257    })
258}
259
260// ---- Coordinator Runtime ----
261
262/// A running agent instance.
263#[derive(Debug, Clone)]
264pub struct AgentInstance {
265    /// Unique instance ID.
266    pub id: String,
267    /// Human-readable name.
268    pub name: String,
269    /// Agent type definition.
270    pub definition: AgentDefinition,
271    /// Current status.
272    pub status: AgentStatus,
273    /// Messages received from other agents.
274    pub inbox: Vec<AgentMessage>,
275}
276
277/// Status of a running agent.
278#[derive(Debug, Clone, PartialEq, Eq)]
279pub enum AgentStatus {
280    /// Agent is waiting to be started.
281    Pending,
282    /// Agent is currently executing.
283    Running,
284    /// Agent completed successfully.
285    Completed,
286    /// Agent failed with an error.
287    Failed(String),
288}
289
290/// A message sent between agents.
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct AgentMessage {
293    /// ID of the sending agent.
294    pub from: String,
295    /// Message content.
296    pub content: String,
297    /// Timestamp.
298    pub timestamp: String,
299}
300
301/// Result from a completed agent.
302#[derive(Debug, Clone)]
303pub struct AgentResult {
304    /// Agent instance ID.
305    pub agent_id: String,
306    /// Agent name.
307    pub agent_name: String,
308    /// Output text from the agent.
309    pub output: String,
310    /// Whether the agent succeeded.
311    pub success: bool,
312}
313
314/// Team definition for multi-agent orchestration.
315#[derive(Debug, Clone)]
316pub struct Team {
317    /// Team ID.
318    pub id: String,
319    /// Team name.
320    pub name: String,
321    /// Agent instances in this team.
322    pub agents: Vec<String>,
323    /// Working directory for the team.
324    pub cwd: PathBuf,
325}
326
327/// Orchestrates multiple agent instances, routing messages and
328/// collecting results.
329pub struct Coordinator {
330    /// Agent registry for looking up definitions.
331    registry: AgentRegistry,
332    /// Running agent instances, keyed by ID.
333    instances: Arc<Mutex<HashMap<String, AgentInstance>>>,
334    /// Active teams.
335    teams: Arc<Mutex<HashMap<String, Team>>>,
336    /// Working directory.
337    cwd: PathBuf,
338}
339
340/// Build a subprocess command for running an agent.
341///
342/// Shared by `run_agent()` and `run_team()` to avoid duplication.
343fn build_agent_command(
344    definition: &AgentDefinition,
345    prompt: &str,
346    cwd: &std::path::Path,
347) -> tokio::process::Command {
348    let full_prompt = if let Some(ref sys) = definition.system_prompt {
349        format!("{sys}\n\n{prompt}")
350    } else {
351        prompt.to_string()
352    };
353
354    let agent_binary = std::env::current_exe()
355        .map(|p| p.display().to_string())
356        .unwrap_or_else(|_| "agent".to_string());
357
358    let mut cmd = tokio::process::Command::new(agent_binary);
359    cmd.arg("--prompt")
360        .arg(full_prompt)
361        .current_dir(cwd)
362        .stdout(std::process::Stdio::piped())
363        .stderr(std::process::Stdio::piped());
364
365    if let Some(ref model) = definition.model {
366        cmd.arg("--model").arg(model);
367    }
368    if let Some(max_turns) = definition.max_turns {
369        cmd.arg("--max-turns").arg(max_turns.to_string());
370    }
371    if definition.read_only {
372        cmd.arg("--permission-mode").arg("plan");
373    }
374
375    // Pass through API keys so subagents use the same provider.
376    for var in &[
377        "AGENT_CODE_API_KEY",
378        "ANTHROPIC_API_KEY",
379        "OPENAI_API_KEY",
380        "OPENROUTER_API_KEY",
381        "AGENT_CODE_API_BASE_URL",
382        "AGENT_CODE_MODEL",
383    ] {
384        if let Ok(val) = std::env::var(var) {
385            cmd.env(var, val);
386        }
387    }
388
389    cmd
390}
391
392impl Coordinator {
393    /// Create a new coordinator.
394    pub fn new(cwd: PathBuf) -> Self {
395        let mut registry = AgentRegistry::with_defaults();
396        registry.load_from_disk(Some(&cwd));
397
398        Self {
399            registry,
400            instances: Arc::new(Mutex::new(HashMap::new())),
401            teams: Arc::new(Mutex::new(HashMap::new())),
402            cwd,
403        }
404    }
405
406    /// Spawn an agent instance.
407    ///
408    /// Returns the instance ID. The agent is created in `Pending` status
409    /// and must be started with `run_agent()`.
410    pub async fn spawn_agent(
411        &self,
412        agent_type: &str,
413        name: Option<String>,
414    ) -> Result<String, String> {
415        let definition = self
416            .registry
417            .get(agent_type)
418            .ok_or_else(|| format!("Unknown agent type: {agent_type}"))?
419            .clone();
420
421        let id = uuid::Uuid::new_v4()
422            .to_string()
423            .split('-')
424            .next()
425            .unwrap_or("agent")
426            .to_string();
427
428        let display_name = name.unwrap_or_else(|| format!("{}-{}", definition.name, &id[..4]));
429
430        let instance = AgentInstance {
431            id: id.clone(),
432            name: display_name.clone(),
433            definition,
434            status: AgentStatus::Pending,
435            inbox: Vec::new(),
436        };
437
438        self.instances.lock().await.insert(id.clone(), instance);
439        info!("Spawned agent '{display_name}' ({id}) type={agent_type}");
440
441        Ok(id)
442    }
443
444    /// Run an agent with the given prompt.
445    ///
446    /// Executes the agent as a subprocess and returns the result.
447    /// The agent's status is updated throughout the lifecycle.
448    pub async fn run_agent(&self, agent_id: &str, prompt: &str) -> Result<AgentResult, String> {
449        // Single lock acquisition: update status, clone definition and name.
450        let (definition, agent_name) = {
451            let mut instances = self.instances.lock().await;
452            let instance = instances
453                .get_mut(agent_id)
454                .ok_or_else(|| format!("Agent not found: {agent_id}"))?;
455            instance.status = AgentStatus::Running;
456            (instance.definition.clone(), instance.name.clone())
457        };
458
459        debug!("Running agent '{agent_name}' ({agent_id})");
460
461        let mut cmd = build_agent_command(&definition, prompt, &self.cwd);
462        let output = cmd
463            .output()
464            .await
465            .map_err(|e| format!("Spawn failed: {e}"))?;
466
467        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
468        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
469        let success = output.status.success();
470
471        // Update status.
472        {
473            let mut instances = self.instances.lock().await;
474            if let Some(instance) = instances.get_mut(agent_id) {
475                instance.status = if success {
476                    AgentStatus::Completed
477                } else {
478                    AgentStatus::Failed(stderr.clone())
479                };
480            }
481        }
482
483        let result_text = if success {
484            stdout
485        } else {
486            format!("{stdout}\n\nErrors:\n{stderr}")
487        };
488
489        Ok(AgentResult {
490            agent_id: agent_id.to_string(),
491            agent_name,
492            output: result_text,
493            success,
494        })
495    }
496
497    /// Run multiple agents in parallel and collect all results.
498    pub async fn run_team(
499        &self,
500        tasks: Vec<(&str, &str, &str)>, // (agent_type, name, prompt)
501    ) -> Vec<AgentResult> {
502        let mut handles = Vec::new();
503
504        for (agent_type, name, prompt) in tasks {
505            let agent_id = match self.spawn_agent(agent_type, Some(name.to_string())).await {
506                Ok(id) => id,
507                Err(e) => {
508                    warn!("Failed to spawn agent '{name}': {e}");
509                    continue;
510                }
511            };
512
513            let coordinator_instances = Arc::clone(&self.instances);
514            let cwd = self.cwd.clone();
515            let prompt = prompt.to_string();
516            let agent_id_clone = agent_id.clone();
517
518            // Each agent runs in its own tokio task.
519            let handle = tokio::spawn(async move {
520                // We need to re-create a minimal coordinator for the subprocess call.
521                // This is because the coordinator borrows self which can't move into spawn.
522                let definition = {
523                    let instances = coordinator_instances.lock().await;
524                    instances.get(&agent_id_clone).map(|i| i.definition.clone())
525                };
526
527                let Some(definition) = definition else {
528                    return AgentResult {
529                        agent_id: agent_id_clone,
530                        agent_name: "unknown".into(),
531                        output: "Agent not found".into(),
532                        success: false,
533                    };
534                };
535
536                let agent_name = {
537                    let instances = coordinator_instances.lock().await;
538                    instances
539                        .get(&agent_id_clone)
540                        .map(|i| i.name.clone())
541                        .unwrap_or_default()
542                };
543
544                // Update status.
545                {
546                    let mut instances = coordinator_instances.lock().await;
547                    if let Some(inst) = instances.get_mut(&agent_id_clone) {
548                        inst.status = AgentStatus::Running;
549                    }
550                }
551
552                let mut cmd = build_agent_command(&definition, &prompt, &cwd);
553
554                match cmd.output().await {
555                    Ok(output) => {
556                        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
557                        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
558                        let success = output.status.success();
559
560                        {
561                            let mut instances = coordinator_instances.lock().await;
562                            if let Some(inst) = instances.get_mut(&agent_id_clone) {
563                                inst.status = if success {
564                                    AgentStatus::Completed
565                                } else {
566                                    AgentStatus::Failed(stderr.clone())
567                                };
568                            }
569                        }
570
571                        AgentResult {
572                            agent_id: agent_id_clone,
573                            agent_name,
574                            output: if success {
575                                stdout
576                            } else {
577                                format!("{stdout}\nErrors:\n{stderr}")
578                            },
579                            success,
580                        }
581                    }
582                    Err(e) => {
583                        {
584                            let mut instances = coordinator_instances.lock().await;
585                            if let Some(inst) = instances.get_mut(&agent_id_clone) {
586                                inst.status = AgentStatus::Failed(e.to_string());
587                            }
588                        }
589                        AgentResult {
590                            agent_id: agent_id_clone,
591                            agent_name,
592                            output: format!("Spawn failed: {e}"),
593                            success: false,
594                        }
595                    }
596                }
597            });
598
599            handles.push(handle);
600        }
601
602        // Wait for all agents to complete.
603        let mut results = Vec::new();
604        for handle in handles {
605            match handle.await {
606                Ok(result) => results.push(result),
607                Err(e) => warn!("Agent task panicked: {e}"),
608            }
609        }
610
611        info!(
612            "Team completed: {}/{} succeeded",
613            results.iter().filter(|r| r.success).count(),
614            results.len()
615        );
616        results
617    }
618
619    /// Send a message to a running agent.
620    pub async fn send_message(&self, to: &str, from: &str, content: &str) -> Result<(), String> {
621        let mut instances = self.instances.lock().await;
622
623        // Find by ID or name.
624        let instance = instances
625            .values_mut()
626            .find(|i| i.id == to || i.name == to)
627            .ok_or_else(|| format!("Agent not found: {to}"))?;
628
629        instance.inbox.push(AgentMessage {
630            from: from.to_string(),
631            content: content.to_string(),
632            timestamp: chrono::Utc::now().to_rfc3339(),
633        });
634
635        debug!("Message from '{from}' to '{to}': {content}");
636        Ok(())
637    }
638
639    /// List all agent instances.
640    pub async fn list_agents(&self) -> Vec<AgentInstance> {
641        self.instances.lock().await.values().cloned().collect()
642    }
643
644    /// Get agent registry.
645    pub fn registry(&self) -> &AgentRegistry {
646        &self.registry
647    }
648
649    /// Create a new team.
650    pub async fn create_team(&self, name: &str, agent_types: &[&str]) -> Result<String, String> {
651        let team_id = uuid::Uuid::new_v4()
652            .to_string()
653            .split('-')
654            .next()
655            .unwrap_or("team")
656            .to_string();
657
658        let mut agent_ids = Vec::new();
659        for agent_type in agent_types {
660            let id = self.spawn_agent(agent_type, None).await?;
661            agent_ids.push(id);
662        }
663
664        let team = Team {
665            id: team_id.clone(),
666            name: name.to_string(),
667            agents: agent_ids,
668            cwd: self.cwd.clone(),
669        };
670
671        self.teams.lock().await.insert(team_id.clone(), team);
672        info!(
673            "Created team '{name}' ({team_id}) with {} agents",
674            agent_types.len()
675        );
676
677        Ok(team_id)
678    }
679
680    /// List active teams.
681    pub async fn list_teams(&self) -> Vec<Team> {
682        self.teams.lock().await.values().cloned().collect()
683    }
684}
685
686#[cfg(test)]
687mod coordinator_tests {
688    use super::*;
689
690    #[test]
691    fn test_agent_status_eq() {
692        assert_eq!(AgentStatus::Pending, AgentStatus::Pending);
693        assert_eq!(AgentStatus::Running, AgentStatus::Running);
694        assert_eq!(AgentStatus::Completed, AgentStatus::Completed);
695        assert_ne!(AgentStatus::Pending, AgentStatus::Running);
696    }
697
698    #[tokio::test]
699    async fn test_spawn_agent() {
700        let coord = Coordinator::new(std::env::temp_dir());
701        let id = coord
702            .spawn_agent("general-purpose", Some("test-agent".into()))
703            .await;
704        assert!(id.is_ok());
705
706        let agents = coord.list_agents().await;
707        assert_eq!(agents.len(), 1);
708        assert_eq!(agents[0].name, "test-agent");
709        assert_eq!(agents[0].status, AgentStatus::Pending);
710    }
711
712    #[tokio::test]
713    async fn test_spawn_unknown_type() {
714        let coord = Coordinator::new(std::env::temp_dir());
715        let result = coord.spawn_agent("nonexistent", None).await;
716        assert!(result.is_err());
717    }
718
719    #[tokio::test]
720    async fn test_send_message() {
721        let coord = Coordinator::new(std::env::temp_dir());
722        let id = coord
723            .spawn_agent("general-purpose", Some("receiver".into()))
724            .await
725            .unwrap();
726
727        let result = coord.send_message(&id, "sender", "hello").await;
728        assert!(result.is_ok());
729
730        let agents = coord.list_agents().await;
731        assert_eq!(agents[0].inbox.len(), 1);
732        assert_eq!(agents[0].inbox[0].content, "hello");
733    }
734
735    #[tokio::test]
736    async fn test_send_message_by_name() {
737        let coord = Coordinator::new(std::env::temp_dir());
738        coord
739            .spawn_agent("explore", Some("explorer".into()))
740            .await
741            .unwrap();
742
743        let result = coord.send_message("explorer", "lead", "search for X").await;
744        assert!(result.is_ok());
745    }
746
747    #[tokio::test]
748    async fn test_create_team() {
749        let coord = Coordinator::new(std::env::temp_dir());
750        let team_id = coord
751            .create_team("my-team", &["general-purpose", "explore"])
752            .await;
753        assert!(team_id.is_ok());
754
755        let teams = coord.list_teams().await;
756        assert_eq!(teams.len(), 1);
757        assert_eq!(teams[0].agents.len(), 2);
758
759        let agents = coord.list_agents().await;
760        assert_eq!(agents.len(), 2);
761    }
762}