1use crate::config::CodeConfig;
60use crate::permissions::PermissionPolicy;
61use serde::{Deserialize, Serialize};
62use std::collections::HashMap;
63use std::path::Path;
64use std::sync::RwLock;
65
66use crate::error::{read_or_recover, write_or_recover};
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct ModelConfig {
71 pub model: String,
73 pub provider: Option<String>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct AgentDefinition {
82 pub name: String,
84 pub description: String,
86 #[serde(default)]
88 pub native: bool,
89 #[serde(default)]
91 pub hidden: bool,
92 #[serde(default)]
94 pub permissions: PermissionPolicy,
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub model: Option<ModelConfig>,
98 #[serde(skip_serializing_if = "Option::is_none")]
100 pub prompt: Option<String>,
101 #[serde(skip_serializing_if = "Option::is_none")]
103 pub max_steps: Option<usize>,
104}
105
106impl AgentDefinition {
107 pub fn new(name: &str, description: &str) -> Self {
109 Self {
110 name: name.to_string(),
111 description: description.to_string(),
112 native: false,
113 hidden: false,
114 permissions: PermissionPolicy::default(),
115 model: None,
116 prompt: None,
117 max_steps: None,
118 }
119 }
120
121 pub fn native(mut self) -> Self {
123 self.native = true;
124 self
125 }
126
127 pub fn hidden(mut self) -> Self {
129 self.hidden = true;
130 self
131 }
132
133 pub fn with_permissions(mut self, permissions: PermissionPolicy) -> Self {
135 self.permissions = permissions;
136 self
137 }
138
139 pub fn with_model(mut self, model: ModelConfig) -> Self {
141 self.model = Some(model);
142 self
143 }
144
145 pub fn with_prompt(mut self, prompt: &str) -> Self {
147 self.prompt = Some(prompt.to_string());
148 self
149 }
150
151 pub fn with_max_steps(mut self, max_steps: usize) -> Self {
153 self.max_steps = Some(max_steps);
154 self
155 }
156}
157
158pub struct AgentRegistry {
163 agents: RwLock<HashMap<String, AgentDefinition>>,
164}
165
166impl Default for AgentRegistry {
167 fn default() -> Self {
168 Self::new()
169 }
170}
171
172impl AgentRegistry {
173 pub fn new() -> Self {
175 let registry = Self {
176 agents: RwLock::new(HashMap::new()),
177 };
178
179 for agent in builtin_agents() {
181 registry.register(agent);
182 }
183
184 registry
185 }
186
187 pub fn with_config(config: &CodeConfig) -> Self {
191 let registry = Self::new();
192
193 for dir in &config.agent_dirs {
195 let agents = load_agents_from_dir(dir);
196 for agent in agents {
197 tracing::info!("Loaded agent '{}' from {}", agent.name, dir.display());
198 registry.register(agent);
199 }
200 }
201
202 registry
203 }
204
205 pub fn register(&self, agent: AgentDefinition) {
207 let mut agents = write_or_recover(&self.agents);
208 tracing::debug!("Registering agent: {}", agent.name);
209 agents.insert(agent.name.clone(), agent);
210 }
211
212 pub fn unregister(&self, name: &str) -> bool {
216 let mut agents = write_or_recover(&self.agents);
217 agents.remove(name).is_some()
218 }
219
220 pub fn get(&self, name: &str) -> Option<AgentDefinition> {
222 let agents = read_or_recover(&self.agents);
223 agents.get(name).cloned()
224 }
225
226 pub fn list(&self) -> Vec<AgentDefinition> {
228 let agents = read_or_recover(&self.agents);
229 agents.values().cloned().collect()
230 }
231
232 pub fn list_visible(&self) -> Vec<AgentDefinition> {
234 let agents = read_or_recover(&self.agents);
235 agents.values().filter(|a| !a.hidden).cloned().collect()
236 }
237
238 pub fn exists(&self, name: &str) -> bool {
240 let agents = read_or_recover(&self.agents);
241 agents.contains_key(name)
242 }
243
244 pub fn len(&self) -> usize {
246 let agents = read_or_recover(&self.agents);
247 agents.len()
248 }
249
250 pub fn is_empty(&self) -> bool {
252 self.len() == 0
253 }
254}
255
256pub fn parse_agent_yaml(content: &str) -> anyhow::Result<AgentDefinition> {
264 let agent: AgentDefinition = serde_yaml::from_str(content)
265 .map_err(|e| anyhow::anyhow!("Failed to parse agent YAML: {}", e))?;
266
267 if agent.name.is_empty() {
268 return Err(anyhow::anyhow!("Agent name is required"));
269 }
270
271 Ok(agent)
272}
273
274pub fn parse_agent_md(content: &str) -> anyhow::Result<AgentDefinition> {
278 let parts: Vec<&str> = content.splitn(3, "---").collect();
280
281 if parts.len() < 3 {
282 return Err(anyhow::anyhow!(
283 "Invalid markdown format: missing YAML frontmatter"
284 ));
285 }
286
287 let frontmatter = parts[1].trim();
288 let body = parts[2].trim();
289
290 let mut agent: AgentDefinition = serde_yaml::from_str(frontmatter)
292 .map_err(|e| anyhow::anyhow!("Failed to parse agent frontmatter: {}", e))?;
293
294 if agent.name.is_empty() {
295 return Err(anyhow::anyhow!("Agent name is required"));
296 }
297
298 if agent.prompt.is_none() && !body.is_empty() {
300 agent.prompt = Some(body.to_string());
301 }
302
303 Ok(agent)
304}
305
306pub fn load_agents_from_dir(dir: &Path) -> Vec<AgentDefinition> {
311 let mut agents = Vec::new();
312
313 let Ok(entries) = std::fs::read_dir(dir) else {
314 tracing::warn!("Failed to read agent directory: {}", dir.display());
315 return agents;
316 };
317
318 for entry in entries.flatten() {
319 let path = entry.path();
320
321 if !path.is_file() {
323 continue;
324 }
325
326 let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
327 continue;
328 };
329
330 let Ok(content) = std::fs::read_to_string(&path) else {
332 tracing::warn!("Failed to read agent file: {}", path.display());
333 continue;
334 };
335
336 let result = match ext {
338 "yaml" | "yml" => parse_agent_yaml(&content),
339 "md" => parse_agent_md(&content),
340 _ => continue,
341 };
342
343 match result {
344 Ok(agent) => {
345 tracing::debug!("Loaded agent '{}' from {}", agent.name, path.display());
346 agents.push(agent);
347 }
348 Err(e) => {
349 tracing::warn!("Failed to parse agent file {}: {}", path.display(), e);
350 }
351 }
352 }
353
354 agents
355}
356
357pub fn builtin_agents() -> Vec<AgentDefinition> {
359 vec![
360 AgentDefinition::new(
362 "explore",
363 "Fast codebase exploration agent. Use for searching files, reading code, \
364 and understanding codebase structure. Read-only operations only.",
365 )
366 .native()
367 .with_permissions(explore_permissions())
368 .with_max_steps(20)
369 .with_prompt(EXPLORE_PROMPT),
370 AgentDefinition::new(
372 "general",
373 "General-purpose agent for multi-step task execution. Can read, write, \
374 and execute commands.",
375 )
376 .native()
377 .with_permissions(general_permissions())
378 .with_max_steps(50),
379 AgentDefinition::new(
381 "plan",
382 "Planning agent for designing implementation approaches. Read-only access \
383 to explore codebase and create plans.",
384 )
385 .native()
386 .with_permissions(plan_permissions())
387 .with_max_steps(30)
388 .with_prompt(PLAN_PROMPT),
389 AgentDefinition::new(
391 "verification",
392 "Verification agent for adversarial validation. Prefer real checks, \
393 reproductions, and regression testing over code reading alone.",
394 )
395 .native()
396 .with_permissions(verification_permissions())
397 .with_max_steps(30)
398 .with_prompt(VERIFICATION_PROMPT),
399 AgentDefinition::new(
401 "review",
402 "Code review agent focused on correctness, regressions, security, \
403 maintainability, and clear findings.",
404 )
405 .native()
406 .with_permissions(review_permissions())
407 .with_max_steps(25)
408 .with_prompt(REVIEW_PROMPT),
409 AgentDefinition::new(
411 "title",
412 "Generate a concise title for the session based on conversation content.",
413 )
414 .native()
415 .hidden()
416 .with_permissions(PermissionPolicy::new())
417 .with_max_steps(1)
418 .with_prompt(TITLE_PROMPT),
419 AgentDefinition::new(
421 "summary",
422 "Summarize the session conversation for context compaction.",
423 )
424 .native()
425 .hidden()
426 .with_permissions(summary_permissions())
427 .with_max_steps(5)
428 .with_prompt(SUMMARY_PROMPT),
429 ]
430}
431
432fn explore_permissions() -> PermissionPolicy {
438 PermissionPolicy::new()
439 .allow_all(&["read", "grep", "glob", "ls"])
440 .deny_all(&["write", "edit", "task"])
441 .allow("Bash(ls:*)")
442 .allow("Bash(cat:*)")
443 .allow("Bash(head:*)")
444 .allow("Bash(tail:*)")
445 .allow("Bash(find:*)")
446 .allow("Bash(wc:*)")
447 .deny("Bash(rm:*)")
448 .deny("Bash(mv:*)")
449 .deny("Bash(cp:*)")
450}
451
452fn general_permissions() -> PermissionPolicy {
454 PermissionPolicy::new()
455 .allow_all(&["read", "write", "edit", "grep", "glob", "ls", "bash"])
456 .deny("task")
457}
458
459fn plan_permissions() -> PermissionPolicy {
461 PermissionPolicy::new()
462 .allow_all(&["read", "grep", "glob", "ls"])
463 .deny_all(&["write", "edit", "bash", "task"])
464}
465
466fn summary_permissions() -> PermissionPolicy {
468 PermissionPolicy::new()
469 .allow("read")
470 .deny_all(&["write", "edit", "bash", "grep", "glob", "ls", "task"])
471}
472
473fn verification_permissions() -> PermissionPolicy {
475 PermissionPolicy::new()
476 .allow_all(&["read", "grep", "glob", "ls", "bash"])
477 .deny_all(&["write", "edit", "task"])
478}
479
480fn review_permissions() -> PermissionPolicy {
482 PermissionPolicy::new()
483 .allow_all(&["read", "grep", "glob", "ls", "bash"])
484 .deny_all(&["write", "edit", "task"])
485}
486
487const EXPLORE_PROMPT: &str = crate::prompts::SUBAGENT_EXPLORE;
492
493const PLAN_PROMPT: &str = crate::prompts::SUBAGENT_PLAN;
494
495const VERIFICATION_PROMPT: &str = crate::prompts::AGENT_VERIFICATION;
496
497const REVIEW_PROMPT: &str = crate::prompts::SUBAGENT_CODE_REVIEW;
498
499const TITLE_PROMPT: &str = crate::prompts::SUBAGENT_TITLE;
500
501const SUMMARY_PROMPT: &str = crate::prompts::SUBAGENT_SUMMARY;
502
503#[cfg(test)]
508mod tests {
509 use super::*;
510
511 #[test]
512 fn test_agent_definition_builder() {
513 let agent = AgentDefinition::new("test", "Test agent")
514 .native()
515 .hidden()
516 .with_max_steps(10);
517
518 assert_eq!(agent.name, "test");
519 assert_eq!(agent.description, "Test agent");
520 assert!(agent.native);
521 assert!(agent.hidden);
522 assert_eq!(agent.max_steps, Some(10));
523 }
524
525 #[test]
526 fn test_agent_registry_new() {
527 let registry = AgentRegistry::new();
528
529 assert!(registry.exists("explore"));
531 assert!(registry.exists("general"));
532 assert!(registry.exists("plan"));
533 assert!(registry.exists("verification"));
534 assert!(registry.exists("review"));
535 assert!(registry.exists("title"));
536 assert!(registry.exists("summary"));
537 assert_eq!(registry.len(), 7);
538 }
539
540 #[test]
541 fn test_agent_registry_get() {
542 let registry = AgentRegistry::new();
543
544 let explore = registry.get("explore").unwrap();
545 assert_eq!(explore.name, "explore");
546 assert!(explore.native);
547 assert!(!explore.hidden);
548
549 let title = registry.get("title").unwrap();
550 assert!(title.hidden);
551
552 assert!(registry.get("nonexistent").is_none());
553 }
554
555 #[test]
556 fn test_agent_registry_register_unregister() {
557 let registry = AgentRegistry::new();
558 let initial_count = registry.len();
559
560 let custom = AgentDefinition::new("custom", "Custom agent");
562 registry.register(custom);
563 assert_eq!(registry.len(), initial_count + 1);
564 assert!(registry.exists("custom"));
565
566 assert!(registry.unregister("custom"));
568 assert_eq!(registry.len(), initial_count);
569 assert!(!registry.exists("custom"));
570
571 assert!(!registry.unregister("nonexistent"));
573 }
574
575 #[test]
576 fn test_agent_registry_list_visible() {
577 let registry = AgentRegistry::new();
578
579 let visible = registry.list_visible();
580 let all = registry.list();
581
582 assert!(visible.len() < all.len());
584 assert!(visible.iter().all(|a| !a.hidden));
585 }
586
587 #[test]
588 fn test_builtin_agents() {
589 let agents = builtin_agents();
590
591 let names: Vec<&str> = agents.iter().map(|a| a.name.as_str()).collect();
593 assert!(names.contains(&"explore"));
594 assert!(names.contains(&"general"));
595 assert!(names.contains(&"plan"));
596 assert!(names.contains(&"verification"));
597 assert!(names.contains(&"review"));
598 assert!(names.contains(&"title"));
599 assert!(names.contains(&"summary"));
600
601 let explore = agents.iter().find(|a| a.name == "explore").unwrap();
603 assert!(!explore.permissions.deny.is_empty());
604 }
605
606 #[test]
611 fn test_parse_agent_yaml() {
612 let yaml = r#"
613name: test-agent
614description: A test agent
615hidden: false
616max_steps: 20
617"#;
618 let agent = parse_agent_yaml(yaml).unwrap();
619 assert_eq!(agent.name, "test-agent");
620 assert_eq!(agent.description, "A test agent");
621 assert!(!agent.hidden);
622 assert_eq!(agent.max_steps, Some(20));
623 }
624
625 #[test]
626 fn test_parse_agent_yaml_with_permissions() {
627 let yaml = r#"
628name: restricted-agent
629description: Agent with permissions
630permissions:
631 allow:
632 - rule: read
633 - rule: grep
634 deny:
635 - rule: write
636"#;
637 let agent = parse_agent_yaml(yaml).unwrap();
638 assert_eq!(agent.name, "restricted-agent");
639 assert_eq!(agent.permissions.allow.len(), 2);
640 assert_eq!(agent.permissions.deny.len(), 1);
641 assert!(agent.permissions.allow[0].matches("read", &serde_json::json!({})));
643 assert!(agent.permissions.allow[1].matches("grep", &serde_json::json!({})));
644 assert!(agent.permissions.deny[0].matches("write", &serde_json::json!({})));
645 }
646
647 #[test]
648 fn test_parse_agent_yaml_with_plain_string_permissions() {
649 let yaml = r#"
651name: plain-agent
652description: Agent with plain string permissions
653permissions:
654 allow:
655 - read
656 - grep
657 - "Bash(cargo:*)"
658 deny:
659 - write
660"#;
661 let agent = parse_agent_yaml(yaml).unwrap();
662 assert_eq!(agent.name, "plain-agent");
663 assert_eq!(agent.permissions.allow.len(), 3);
664 assert_eq!(agent.permissions.deny.len(), 1);
665 assert!(agent.permissions.allow[0].matches("read", &serde_json::json!({})));
667 assert!(agent.permissions.allow[1].matches("grep", &serde_json::json!({})));
668 assert!(agent.permissions.allow[2]
669 .matches("Bash", &serde_json::json!({"command": "cargo build"})));
670 assert!(agent.permissions.deny[0].matches("write", &serde_json::json!({})));
671 }
672
673 #[test]
674 fn test_parse_agent_yaml_missing_name() {
675 let yaml = r#"
676description: Agent without name
677"#;
678 let result = parse_agent_yaml(yaml);
679 assert!(result.is_err());
680 }
681
682 #[test]
683 fn test_parse_agent_md() {
684 let md = r#"---
685name: md-agent
686description: Agent from markdown
687max_steps: 15
688---
689# System Prompt
690
691You are a helpful agent.
692Do your best work.
693"#;
694 let agent = parse_agent_md(md).unwrap();
695 assert_eq!(agent.name, "md-agent");
696 assert_eq!(agent.description, "Agent from markdown");
697 assert_eq!(agent.max_steps, Some(15));
698 assert!(agent.prompt.is_some());
699 assert!(agent.prompt.unwrap().contains("helpful agent"));
700 }
701
702 #[test]
703 fn test_parse_agent_md_with_prompt_in_frontmatter() {
704 let md = r#"---
705name: prompt-agent
706description: Agent with prompt in frontmatter
707prompt: "Frontmatter prompt"
708---
709Body content that should be ignored
710"#;
711 let agent = parse_agent_md(md).unwrap();
712 assert_eq!(agent.prompt.unwrap(), "Frontmatter prompt");
713 }
714
715 #[test]
716 fn test_parse_agent_md_missing_frontmatter() {
717 let md = "Just markdown without frontmatter";
718 let result = parse_agent_md(md);
719 assert!(result.is_err());
720 }
721
722 #[test]
723 fn test_load_agents_from_dir() {
724 let temp_dir = tempfile::tempdir().unwrap();
725
726 std::fs::write(
728 temp_dir.path().join("agent1.yaml"),
729 r#"
730name: yaml-agent
731description: Agent from YAML file
732"#,
733 )
734 .unwrap();
735
736 std::fs::write(
738 temp_dir.path().join("agent2.md"),
739 r#"---
740name: md-agent
741description: Agent from Markdown file
742---
743System prompt here
744"#,
745 )
746 .unwrap();
747
748 std::fs::write(temp_dir.path().join("invalid.yaml"), "not: valid: yaml: [").unwrap();
750
751 std::fs::write(temp_dir.path().join("readme.txt"), "Just a text file").unwrap();
753
754 let agents = load_agents_from_dir(temp_dir.path());
755 assert_eq!(agents.len(), 2);
756
757 let names: Vec<&str> = agents.iter().map(|a| a.name.as_str()).collect();
758 assert!(names.contains(&"yaml-agent"));
759 assert!(names.contains(&"md-agent"));
760 }
761
762 #[test]
763 fn test_load_agents_from_nonexistent_dir() {
764 let agents = load_agents_from_dir(std::path::Path::new("/nonexistent/dir"));
765 assert!(agents.is_empty());
766 }
767
768 #[test]
769 fn test_registry_with_config() {
770 let temp_dir = tempfile::tempdir().unwrap();
771
772 std::fs::write(
774 temp_dir.path().join("custom.yaml"),
775 r#"
776name: custom-agent
777description: Custom agent from config
778"#,
779 )
780 .unwrap();
781
782 let config = CodeConfig::new().add_agent_dir(temp_dir.path());
783 let registry = AgentRegistry::with_config(&config);
784
785 assert!(registry.exists("explore"));
787 assert!(registry.exists("custom-agent"));
788 assert_eq!(registry.len(), 8); }
790
791 #[test]
792 fn test_agent_definition_with_model() {
793 let model = ModelConfig {
794 model: "claude-3-5-sonnet".to_string(),
795 provider: Some("anthropic".to_string()),
796 };
797 let agent = AgentDefinition::new("test", "Test").with_model(model);
798 assert!(agent.model.is_some());
799 assert_eq!(agent.model.unwrap().provider, Some("anthropic".to_string()));
800 }
801
802 #[test]
803 fn test_agent_registry_default() {
804 let registry = AgentRegistry::default();
805 assert!(!registry.is_empty());
806 assert_eq!(registry.len(), 7);
807 }
808
809 #[test]
810 fn test_agent_registry_is_empty() {
811 let registry = AgentRegistry {
812 agents: RwLock::new(HashMap::new()),
813 };
814 assert!(registry.is_empty());
815 assert_eq!(registry.len(), 0);
816 }
817}