1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct AgentDefinition {
26 pub name: String,
28 pub description: String,
30 pub system_prompt: Option<String>,
32 pub model: Option<String>,
34 pub include_tools: Vec<String>,
36 pub exclude_tools: Vec<String>,
38 pub read_only: bool,
40 pub max_turns: Option<usize>,
42}
43
44pub struct AgentRegistry {
46 agents: HashMap<String, AgentDefinition>,
47}
48
49impl AgentRegistry {
50 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 pub fn get(&self, name: &str) -> Option<&AgentDefinition> {
123 self.agents.get(name)
124 }
125
126 pub fn register(&mut self, definition: AgentDefinition) {
128 self.agents.insert(definition.name.clone(), definition);
129 }
130
131 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 pub fn load_from_disk(&mut self, cwd: Option<&std::path::Path>) {
141 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 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
171fn parse_agent_file(path: &std::path::Path) -> Option<AgentDefinition> {
188 let content = std::fs::read_to_string(path).ok()?;
189
190 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#[derive(Debug, Clone)]
264pub struct AgentInstance {
265 pub id: String,
267 pub name: String,
269 pub definition: AgentDefinition,
271 pub status: AgentStatus,
273 pub inbox: Vec<AgentMessage>,
275}
276
277#[derive(Debug, Clone, PartialEq, Eq)]
279pub enum AgentStatus {
280 Pending,
282 Running,
284 Completed,
286 Failed(String),
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct AgentMessage {
293 pub from: String,
295 pub content: String,
297 pub timestamp: String,
299}
300
301#[derive(Debug, Clone)]
303pub struct AgentResult {
304 pub agent_id: String,
306 pub agent_name: String,
308 pub output: String,
310 pub success: bool,
312}
313
314#[derive(Debug, Clone)]
316pub struct Team {
317 pub id: String,
319 pub name: String,
321 pub agents: Vec<String>,
323 pub cwd: PathBuf,
325}
326
327pub struct Coordinator {
330 registry: AgentRegistry,
332 instances: Arc<Mutex<HashMap<String, AgentInstance>>>,
334 teams: Arc<Mutex<HashMap<String, Team>>>,
336 cwd: PathBuf,
338}
339
340fn 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 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 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 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 pub async fn run_agent(&self, agent_id: &str, prompt: &str) -> Result<AgentResult, String> {
449 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 {
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 pub async fn run_team(
499 &self,
500 tasks: Vec<(&str, &str, &str)>, ) -> 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 let handle = tokio::spawn(async move {
520 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 {
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 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 pub async fn send_message(&self, to: &str, from: &str, content: &str) -> Result<(), String> {
621 let mut instances = self.instances.lock().await;
622
623 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 pub async fn list_agents(&self) -> Vec<AgentInstance> {
641 self.instances.lock().await.values().cloned().collect()
642 }
643
644 pub fn registry(&self) -> &AgentRegistry {
646 &self.registry
647 }
648
649 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 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}