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, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
70#[serde(rename_all = "snake_case")]
71pub enum AgentMode {
72 #[default]
74 Primary,
75 Subagent,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct ModelConfig {
82 pub model: String,
84 pub provider: Option<String>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct AgentDefinition {
93 pub name: String,
95 pub description: String,
97 #[serde(default)]
99 pub mode: AgentMode,
100 #[serde(default)]
102 pub native: bool,
103 #[serde(default)]
105 pub hidden: bool,
106 #[serde(default)]
108 pub permissions: PermissionPolicy,
109 #[serde(skip_serializing_if = "Option::is_none")]
111 pub model: Option<ModelConfig>,
112 #[serde(skip_serializing_if = "Option::is_none")]
114 pub prompt: Option<String>,
115 #[serde(skip_serializing_if = "Option::is_none")]
117 pub max_steps: Option<usize>,
118 #[serde(default)]
120 pub can_spawn_subagents: bool,
121}
122
123impl AgentDefinition {
124 pub fn new(name: &str, description: &str) -> Self {
126 Self {
127 name: name.to_string(),
128 description: description.to_string(),
129 mode: AgentMode::Subagent,
130 native: false,
131 hidden: false,
132 permissions: PermissionPolicy::default(),
133 model: None,
134 prompt: None,
135 max_steps: None,
136 can_spawn_subagents: false,
137 }
138 }
139
140 pub fn with_mode(mut self, mode: AgentMode) -> Self {
142 self.mode = mode;
143 self
144 }
145
146 pub fn native(mut self) -> Self {
148 self.native = true;
149 self
150 }
151
152 pub fn hidden(mut self) -> Self {
154 self.hidden = true;
155 self
156 }
157
158 pub fn with_permissions(mut self, permissions: PermissionPolicy) -> Self {
160 self.permissions = permissions;
161 self
162 }
163
164 pub fn with_model(mut self, model: ModelConfig) -> Self {
166 self.model = Some(model);
167 self
168 }
169
170 pub fn with_prompt(mut self, prompt: &str) -> Self {
172 self.prompt = Some(prompt.to_string());
173 self
174 }
175
176 pub fn with_max_steps(mut self, max_steps: usize) -> Self {
178 self.max_steps = Some(max_steps);
179 self
180 }
181
182 pub fn allow_subagents(mut self) -> Self {
184 self.can_spawn_subagents = true;
185 self
186 }
187}
188
189pub struct AgentRegistry {
194 agents: RwLock<HashMap<String, AgentDefinition>>,
195}
196
197impl Default for AgentRegistry {
198 fn default() -> Self {
199 Self::new()
200 }
201}
202
203impl AgentRegistry {
204 pub fn new() -> Self {
206 let registry = Self {
207 agents: RwLock::new(HashMap::new()),
208 };
209
210 for agent in builtin_agents() {
212 registry.register(agent);
213 }
214
215 registry
216 }
217
218 pub fn with_config(config: &CodeConfig) -> Self {
222 let registry = Self::new();
223
224 for dir in &config.agent_dirs {
226 let agents = load_agents_from_dir(dir);
227 for agent in agents {
228 tracing::info!("Loaded agent '{}' from {}", agent.name, dir.display());
229 registry.register(agent);
230 }
231 }
232
233 registry
234 }
235
236 pub fn register(&self, agent: AgentDefinition) {
238 let mut agents = write_or_recover(&self.agents);
239 tracing::debug!("Registering agent: {}", agent.name);
240 agents.insert(agent.name.clone(), agent);
241 }
242
243 pub fn unregister(&self, name: &str) -> bool {
247 let mut agents = write_or_recover(&self.agents);
248 agents.remove(name).is_some()
249 }
250
251 pub fn get(&self, name: &str) -> Option<AgentDefinition> {
253 let agents = read_or_recover(&self.agents);
254 agents.get(name).cloned()
255 }
256
257 pub fn list(&self) -> Vec<AgentDefinition> {
259 let agents = read_or_recover(&self.agents);
260 agents.values().cloned().collect()
261 }
262
263 pub fn list_visible(&self) -> Vec<AgentDefinition> {
265 let agents = read_or_recover(&self.agents);
266 agents.values().filter(|a| !a.hidden).cloned().collect()
267 }
268
269 pub fn exists(&self, name: &str) -> bool {
271 let agents = read_or_recover(&self.agents);
272 agents.contains_key(name)
273 }
274
275 pub fn len(&self) -> usize {
277 let agents = read_or_recover(&self.agents);
278 agents.len()
279 }
280
281 pub fn is_empty(&self) -> bool {
283 self.len() == 0
284 }
285}
286
287pub fn parse_agent_yaml(content: &str) -> anyhow::Result<AgentDefinition> {
295 let agent: AgentDefinition = serde_yaml::from_str(content)
296 .map_err(|e| anyhow::anyhow!("Failed to parse agent YAML: {}", e))?;
297
298 if agent.name.is_empty() {
299 return Err(anyhow::anyhow!("Agent name is required"));
300 }
301
302 Ok(agent)
303}
304
305pub fn parse_agent_md(content: &str) -> anyhow::Result<AgentDefinition> {
309 let parts: Vec<&str> = content.splitn(3, "---").collect();
311
312 if parts.len() < 3 {
313 return Err(anyhow::anyhow!(
314 "Invalid markdown format: missing YAML frontmatter"
315 ));
316 }
317
318 let frontmatter = parts[1].trim();
319 let body = parts[2].trim();
320
321 let mut agent: AgentDefinition = serde_yaml::from_str(frontmatter)
323 .map_err(|e| anyhow::anyhow!("Failed to parse agent frontmatter: {}", e))?;
324
325 if agent.name.is_empty() {
326 return Err(anyhow::anyhow!("Agent name is required"));
327 }
328
329 if agent.prompt.is_none() && !body.is_empty() {
331 agent.prompt = Some(body.to_string());
332 }
333
334 Ok(agent)
335}
336
337pub fn load_agents_from_dir(dir: &Path) -> Vec<AgentDefinition> {
342 let mut agents = Vec::new();
343
344 let Ok(entries) = std::fs::read_dir(dir) else {
345 tracing::warn!("Failed to read agent directory: {}", dir.display());
346 return agents;
347 };
348
349 for entry in entries.flatten() {
350 let path = entry.path();
351
352 if !path.is_file() {
354 continue;
355 }
356
357 let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
358 continue;
359 };
360
361 let Ok(content) = std::fs::read_to_string(&path) else {
363 tracing::warn!("Failed to read agent file: {}", path.display());
364 continue;
365 };
366
367 let result = match ext {
369 "yaml" | "yml" => parse_agent_yaml(&content),
370 "md" => parse_agent_md(&content),
371 _ => continue,
372 };
373
374 match result {
375 Ok(agent) => {
376 tracing::debug!("Loaded agent '{}' from {}", agent.name, path.display());
377 agents.push(agent);
378 }
379 Err(e) => {
380 tracing::warn!("Failed to parse agent file {}: {}", path.display(), e);
381 }
382 }
383 }
384
385 agents
386}
387
388pub fn builtin_agents() -> Vec<AgentDefinition> {
390 vec![
391 AgentDefinition::new(
393 "explore",
394 "Fast codebase exploration agent. Use for searching files, reading code, \
395 and understanding codebase structure. Read-only operations only.",
396 )
397 .native()
398 .with_permissions(explore_permissions())
399 .with_max_steps(20)
400 .with_prompt(EXPLORE_PROMPT),
401 AgentDefinition::new(
403 "general",
404 "General-purpose agent for multi-step task execution. Can read, write, \
405 and execute commands. Cannot spawn subagents.",
406 )
407 .native()
408 .with_permissions(general_permissions())
409 .with_max_steps(50),
410 AgentDefinition::new(
412 "plan",
413 "Planning agent for designing implementation approaches. Read-only access \
414 to explore codebase and create plans.",
415 )
416 .native()
417 .with_mode(AgentMode::Primary)
418 .with_permissions(plan_permissions())
419 .with_max_steps(30)
420 .with_prompt(PLAN_PROMPT),
421 AgentDefinition::new(
423 "title",
424 "Generate a concise title for the session based on conversation content.",
425 )
426 .native()
427 .hidden()
428 .with_mode(AgentMode::Primary)
429 .with_permissions(PermissionPolicy::new())
430 .with_max_steps(1)
431 .with_prompt(TITLE_PROMPT),
432 AgentDefinition::new(
434 "summary",
435 "Summarize the session conversation for context compaction.",
436 )
437 .native()
438 .hidden()
439 .with_mode(AgentMode::Primary)
440 .with_permissions(summary_permissions())
441 .with_max_steps(5)
442 .with_prompt(SUMMARY_PROMPT),
443 ]
444}
445
446fn explore_permissions() -> PermissionPolicy {
452 PermissionPolicy::new()
453 .allow_all(&["read", "grep", "glob", "ls"])
454 .deny_all(&["write", "edit", "task"])
455 .allow("Bash(ls:*)")
456 .allow("Bash(cat:*)")
457 .allow("Bash(head:*)")
458 .allow("Bash(tail:*)")
459 .allow("Bash(find:*)")
460 .allow("Bash(wc:*)")
461 .deny("Bash(rm:*)")
462 .deny("Bash(mv:*)")
463 .deny("Bash(cp:*)")
464}
465
466fn general_permissions() -> PermissionPolicy {
468 PermissionPolicy::new()
469 .allow_all(&["read", "write", "edit", "grep", "glob", "ls", "bash"])
470 .deny("task")
471}
472
473fn plan_permissions() -> PermissionPolicy {
475 PermissionPolicy::new()
476 .allow_all(&["read", "grep", "glob", "ls"])
477 .deny_all(&["write", "edit", "bash", "task"])
478}
479
480fn summary_permissions() -> PermissionPolicy {
482 PermissionPolicy::new()
483 .allow("read")
484 .deny_all(&["write", "edit", "bash", "grep", "glob", "ls", "task"])
485}
486
487const EXPLORE_PROMPT: &str = crate::prompts::SUBAGENT_EXPLORE;
492
493const PLAN_PROMPT: &str = crate::prompts::SUBAGENT_PLAN;
494
495const TITLE_PROMPT: &str = crate::prompts::SUBAGENT_TITLE;
496
497const SUMMARY_PROMPT: &str = crate::prompts::SUBAGENT_SUMMARY;
498
499#[cfg(test)]
504mod tests {
505 use super::*;
506
507 #[test]
508 fn test_agent_definition_builder() {
509 let agent = AgentDefinition::new("test", "Test agent")
510 .native()
511 .hidden()
512 .with_max_steps(10);
513
514 assert_eq!(agent.name, "test");
515 assert_eq!(agent.description, "Test agent");
516 assert!(agent.native);
517 assert!(agent.hidden);
518 assert_eq!(agent.max_steps, Some(10));
519 assert!(!agent.can_spawn_subagents);
520 }
521
522 #[test]
523 fn test_agent_registry_new() {
524 let registry = AgentRegistry::new();
525
526 assert!(registry.exists("explore"));
528 assert!(registry.exists("general"));
529 assert!(registry.exists("plan"));
530 assert!(registry.exists("title"));
531 assert!(registry.exists("summary"));
532 assert_eq!(registry.len(), 5);
533 }
534
535 #[test]
536 fn test_agent_registry_get() {
537 let registry = AgentRegistry::new();
538
539 let explore = registry.get("explore").unwrap();
540 assert_eq!(explore.name, "explore");
541 assert!(explore.native);
542 assert!(!explore.hidden);
543
544 let title = registry.get("title").unwrap();
545 assert!(title.hidden);
546
547 assert!(registry.get("nonexistent").is_none());
548 }
549
550 #[test]
551 fn test_agent_registry_register_unregister() {
552 let registry = AgentRegistry::new();
553 let initial_count = registry.len();
554
555 let custom = AgentDefinition::new("custom", "Custom agent");
557 registry.register(custom);
558 assert_eq!(registry.len(), initial_count + 1);
559 assert!(registry.exists("custom"));
560
561 assert!(registry.unregister("custom"));
563 assert_eq!(registry.len(), initial_count);
564 assert!(!registry.exists("custom"));
565
566 assert!(!registry.unregister("nonexistent"));
568 }
569
570 #[test]
571 fn test_agent_registry_list_visible() {
572 let registry = AgentRegistry::new();
573
574 let visible = registry.list_visible();
575 let all = registry.list();
576
577 assert!(visible.len() < all.len());
579 assert!(visible.iter().all(|a| !a.hidden));
580 }
581
582 #[test]
583 fn test_builtin_agents() {
584 let agents = builtin_agents();
585
586 let names: Vec<&str> = agents.iter().map(|a| a.name.as_str()).collect();
588 assert!(names.contains(&"explore"));
589 assert!(names.contains(&"general"));
590 assert!(names.contains(&"plan"));
591 assert!(names.contains(&"title"));
592 assert!(names.contains(&"summary"));
593
594 let explore = agents.iter().find(|a| a.name == "explore").unwrap();
596 assert!(!explore.permissions.deny.is_empty());
597
598 let general = agents.iter().find(|a| a.name == "general").unwrap();
600 assert!(!general.can_spawn_subagents);
601 }
602
603 #[test]
604 fn test_agent_mode_default() {
605 let mode = AgentMode::default();
606 assert_eq!(mode, AgentMode::Primary);
607 }
608
609 #[test]
614 fn test_parse_agent_yaml() {
615 let yaml = r#"
616name: test-agent
617description: A test agent
618mode: subagent
619hidden: false
620max_steps: 20
621"#;
622 let agent = parse_agent_yaml(yaml).unwrap();
623 assert_eq!(agent.name, "test-agent");
624 assert_eq!(agent.description, "A test agent");
625 assert_eq!(agent.mode, AgentMode::Subagent);
626 assert!(!agent.hidden);
627 assert_eq!(agent.max_steps, Some(20));
628 }
629
630 #[test]
631 fn test_parse_agent_yaml_with_permissions() {
632 let yaml = r#"
633name: restricted-agent
634description: Agent with permissions
635permissions:
636 allow:
637 - rule: read
638 - rule: grep
639 deny:
640 - rule: write
641"#;
642 let agent = parse_agent_yaml(yaml).unwrap();
643 assert_eq!(agent.name, "restricted-agent");
644 assert_eq!(agent.permissions.allow.len(), 2);
645 assert_eq!(agent.permissions.deny.len(), 1);
646 assert!(agent.permissions.allow[0].matches("read", &serde_json::json!({})));
648 assert!(agent.permissions.allow[1].matches("grep", &serde_json::json!({})));
649 assert!(agent.permissions.deny[0].matches("write", &serde_json::json!({})));
650 }
651
652 #[test]
653 fn test_parse_agent_yaml_with_plain_string_permissions() {
654 let yaml = r#"
656name: plain-agent
657description: Agent with plain string permissions
658permissions:
659 allow:
660 - read
661 - grep
662 - "Bash(cargo:*)"
663 deny:
664 - write
665"#;
666 let agent = parse_agent_yaml(yaml).unwrap();
667 assert_eq!(agent.name, "plain-agent");
668 assert_eq!(agent.permissions.allow.len(), 3);
669 assert_eq!(agent.permissions.deny.len(), 1);
670 assert!(agent.permissions.allow[0].matches("read", &serde_json::json!({})));
672 assert!(agent.permissions.allow[1].matches("grep", &serde_json::json!({})));
673 assert!(agent.permissions.allow[2]
674 .matches("Bash", &serde_json::json!({"command": "cargo build"})));
675 assert!(agent.permissions.deny[0].matches("write", &serde_json::json!({})));
676 }
677
678 #[test]
679 fn test_parse_agent_yaml_missing_name() {
680 let yaml = r#"
681description: Agent without name
682"#;
683 let result = parse_agent_yaml(yaml);
684 assert!(result.is_err());
685 }
686
687 #[test]
688 fn test_parse_agent_md() {
689 let md = r#"---
690name: md-agent
691description: Agent from markdown
692mode: subagent
693max_steps: 15
694---
695# System Prompt
696
697You are a helpful agent.
698Do your best work.
699"#;
700 let agent = parse_agent_md(md).unwrap();
701 assert_eq!(agent.name, "md-agent");
702 assert_eq!(agent.description, "Agent from markdown");
703 assert_eq!(agent.max_steps, Some(15));
704 assert!(agent.prompt.is_some());
705 assert!(agent.prompt.unwrap().contains("helpful agent"));
706 }
707
708 #[test]
709 fn test_parse_agent_md_with_prompt_in_frontmatter() {
710 let md = r#"---
711name: prompt-agent
712description: Agent with prompt in frontmatter
713prompt: "Frontmatter prompt"
714---
715Body content that should be ignored
716"#;
717 let agent = parse_agent_md(md).unwrap();
718 assert_eq!(agent.prompt.unwrap(), "Frontmatter prompt");
719 }
720
721 #[test]
722 fn test_parse_agent_md_missing_frontmatter() {
723 let md = "Just markdown without frontmatter";
724 let result = parse_agent_md(md);
725 assert!(result.is_err());
726 }
727
728 #[test]
729 fn test_load_agents_from_dir() {
730 let temp_dir = tempfile::tempdir().unwrap();
731
732 std::fs::write(
734 temp_dir.path().join("agent1.yaml"),
735 r#"
736name: yaml-agent
737description: Agent from YAML file
738"#,
739 )
740 .unwrap();
741
742 std::fs::write(
744 temp_dir.path().join("agent2.md"),
745 r#"---
746name: md-agent
747description: Agent from Markdown file
748---
749System prompt here
750"#,
751 )
752 .unwrap();
753
754 std::fs::write(temp_dir.path().join("invalid.yaml"), "not: valid: yaml: [").unwrap();
756
757 std::fs::write(temp_dir.path().join("readme.txt"), "Just a text file").unwrap();
759
760 let agents = load_agents_from_dir(temp_dir.path());
761 assert_eq!(agents.len(), 2);
762
763 let names: Vec<&str> = agents.iter().map(|a| a.name.as_str()).collect();
764 assert!(names.contains(&"yaml-agent"));
765 assert!(names.contains(&"md-agent"));
766 }
767
768 #[test]
769 fn test_load_agents_from_nonexistent_dir() {
770 let agents = load_agents_from_dir(std::path::Path::new("/nonexistent/dir"));
771 assert!(agents.is_empty());
772 }
773
774 #[test]
775 fn test_registry_with_config() {
776 let temp_dir = tempfile::tempdir().unwrap();
777
778 std::fs::write(
780 temp_dir.path().join("custom.yaml"),
781 r#"
782name: custom-agent
783description: Custom agent from config
784"#,
785 )
786 .unwrap();
787
788 let config = CodeConfig::new().add_agent_dir(temp_dir.path());
789 let registry = AgentRegistry::with_config(&config);
790
791 assert!(registry.exists("explore"));
793 assert!(registry.exists("custom-agent"));
794 assert_eq!(registry.len(), 6); }
796
797 #[test]
798 fn test_agent_definition_with_model() {
799 let model = ModelConfig {
800 model: "claude-3-5-sonnet".to_string(),
801 provider: Some("anthropic".to_string()),
802 };
803 let agent = AgentDefinition::new("test", "Test").with_model(model);
804 assert!(agent.model.is_some());
805 assert_eq!(agent.model.unwrap().provider, Some("anthropic".to_string()));
806 }
807
808 #[test]
809 fn test_agent_definition_allow_subagents() {
810 let agent = AgentDefinition::new("test", "Test").allow_subagents();
811 assert!(agent.can_spawn_subagents);
812 }
813
814 #[test]
815 fn test_agent_registry_default() {
816 let registry = AgentRegistry::default();
817 assert!(!registry.is_empty());
818 assert_eq!(registry.len(), 5);
819 }
820
821 #[test]
822 fn test_agent_registry_is_empty() {
823 let registry = AgentRegistry {
824 agents: RwLock::new(HashMap::new()),
825 };
826 assert!(registry.is_empty());
827 assert_eq!(registry.len(), 0);
828 }
829}