1use crate::config::CodeConfig;
58use crate::permissions::PermissionPolicy;
59use serde::{Deserialize, Serialize};
60use std::collections::HashMap;
61use std::path::Path;
62use std::sync::RwLock;
63
64use crate::error::{read_or_recover, write_or_recover};
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ModelConfig {
69 pub model: String,
71 pub provider: Option<String>,
73}
74
75impl ModelConfig {
76 pub fn new(model: impl Into<String>) -> Self {
78 Self {
79 model: model.into(),
80 provider: None,
81 }
82 }
83
84 pub fn with_provider(provider: impl Into<String>, model: impl Into<String>) -> Self {
86 Self {
87 model: model.into(),
88 provider: Some(provider.into()),
89 }
90 }
91
92 pub fn from_model_ref(model_ref: impl AsRef<str>) -> Self {
97 let model_ref = model_ref.as_ref();
98 if let Some((provider, model)) = model_ref.split_once('/') {
99 Self::with_provider(provider, model)
100 } else {
101 Self::new(model_ref)
102 }
103 }
104
105 pub fn model_ref(&self) -> String {
107 match &self.provider {
108 Some(provider) => format!("{}/{}", provider, self.model),
109 None => self.model.clone(),
110 }
111 }
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum WorkerAgentKind {
122 #[serde(alias = "readonly", alias = "read-only", alias = "explore")]
124 ReadOnly,
125 #[serde(alias = "plan")]
127 Planner,
128 #[serde(alias = "implementation", alias = "general")]
130 Implementer,
131 #[serde(alias = "verification", alias = "verify")]
133 Verifier,
134 #[serde(alias = "review", alias = "code-review")]
136 Reviewer,
137 Custom,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct WorkerAgentSpec {
148 pub name: String,
150 pub description: String,
152 pub kind: WorkerAgentKind,
154 #[serde(default)]
156 pub hidden: bool,
157 #[serde(skip_serializing_if = "Option::is_none")]
159 pub permissions: Option<PermissionPolicy>,
160 #[serde(skip_serializing_if = "Option::is_none")]
162 pub model: Option<ModelConfig>,
163 #[serde(skip_serializing_if = "Option::is_none")]
165 pub prompt: Option<String>,
166 #[serde(skip_serializing_if = "Option::is_none")]
168 pub max_steps: Option<usize>,
169}
170
171impl WorkerAgentKind {
172 pub fn as_str(self) -> &'static str {
174 match self {
175 Self::ReadOnly => "read_only",
176 Self::Planner => "planner",
177 Self::Implementer => "implementer",
178 Self::Verifier => "verifier",
179 Self::Reviewer => "reviewer",
180 Self::Custom => "custom",
181 }
182 }
183
184 fn default_permissions(self) -> PermissionPolicy {
185 match self {
186 Self::ReadOnly => explore_permissions(),
187 Self::Planner => plan_permissions(),
188 Self::Implementer => general_permissions(),
189 Self::Verifier => verification_permissions(),
190 Self::Reviewer => review_permissions(),
191 Self::Custom => PermissionPolicy::strict(),
192 }
193 }
194
195 fn default_prompt(self) -> Option<&'static str> {
196 match self {
197 Self::ReadOnly => Some(EXPLORE_PROMPT),
198 Self::Planner => Some(PLAN_PROMPT),
199 Self::Verifier => Some(VERIFICATION_PROMPT),
200 Self::Reviewer => Some(REVIEW_PROMPT),
201 Self::Implementer | Self::Custom => None,
202 }
203 }
204
205 fn default_max_steps(self) -> usize {
206 match self {
207 Self::ReadOnly => 20,
208 Self::Planner => 30,
209 Self::Implementer => 50,
210 Self::Verifier => 30,
211 Self::Reviewer => 25,
212 Self::Custom => 30,
213 }
214 }
215}
216
217impl std::fmt::Display for WorkerAgentKind {
218 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219 f.write_str(self.as_str())
220 }
221}
222
223impl std::str::FromStr for WorkerAgentKind {
224 type Err = anyhow::Error;
225
226 fn from_str(value: &str) -> anyhow::Result<Self> {
227 match value.trim().to_ascii_lowercase().as_str() {
228 "read_only" | "readonly" | "read-only" | "explore" | "scanner" => Ok(Self::ReadOnly),
229 "planner" | "plan" => Ok(Self::Planner),
230 "implementer" | "implementation" | "general" | "executor" => Ok(Self::Implementer),
231 "verifier" | "verification" | "verify" | "tester" => Ok(Self::Verifier),
232 "reviewer" | "review" | "code-review" | "code_reviewer" => Ok(Self::Reviewer),
233 "custom" => Ok(Self::Custom),
234 other => Err(anyhow::anyhow!("unknown worker agent kind '{}'", other)),
235 }
236 }
237}
238
239pub type CattleAgentKind = WorkerAgentKind;
241pub type CattleAgentSpec = WorkerAgentSpec;
243
244impl WorkerAgentSpec {
245 pub fn new(
247 kind: WorkerAgentKind,
248 name: impl Into<String>,
249 description: impl Into<String>,
250 ) -> Self {
251 Self {
252 name: name.into(),
253 description: description.into(),
254 kind,
255 hidden: false,
256 permissions: None,
257 model: None,
258 prompt: None,
259 max_steps: None,
260 }
261 }
262
263 pub fn read_only(name: impl Into<String>, description: impl Into<String>) -> Self {
265 Self::new(WorkerAgentKind::ReadOnly, name, description)
266 }
267
268 pub fn planner(name: impl Into<String>, description: impl Into<String>) -> Self {
270 Self::new(WorkerAgentKind::Planner, name, description)
271 }
272
273 pub fn implementer(name: impl Into<String>, description: impl Into<String>) -> Self {
275 Self::new(WorkerAgentKind::Implementer, name, description)
276 }
277
278 pub fn verifier(name: impl Into<String>, description: impl Into<String>) -> Self {
280 Self::new(WorkerAgentKind::Verifier, name, description)
281 }
282
283 pub fn reviewer(name: impl Into<String>, description: impl Into<String>) -> Self {
285 Self::new(WorkerAgentKind::Reviewer, name, description)
286 }
287
288 pub fn custom(name: impl Into<String>, description: impl Into<String>) -> Self {
290 Self::new(WorkerAgentKind::Custom, name, description)
291 }
292
293 pub fn hidden(mut self, hidden: bool) -> Self {
295 self.hidden = hidden;
296 self
297 }
298
299 pub fn with_permissions(mut self, permissions: PermissionPolicy) -> Self {
301 self.permissions = Some(permissions);
302 self
303 }
304
305 pub fn with_model(mut self, model: ModelConfig) -> Self {
307 self.model = Some(model);
308 self
309 }
310
311 pub fn with_model_ref(mut self, model_ref: impl AsRef<str>) -> Self {
313 self.model = Some(ModelConfig::from_model_ref(model_ref));
314 self
315 }
316
317 pub fn with_provider_model(
319 mut self,
320 provider: impl Into<String>,
321 model: impl Into<String>,
322 ) -> Self {
323 self.model = Some(ModelConfig::with_provider(provider, model));
324 self
325 }
326
327 pub fn with_prompt(mut self, prompt: impl Into<String>) -> Self {
329 self.prompt = Some(prompt.into());
330 self
331 }
332
333 pub fn with_max_steps(mut self, max_steps: usize) -> Self {
335 self.max_steps = Some(max_steps);
336 self
337 }
338
339 pub fn into_agent_definition(self) -> AgentDefinition {
341 let mut agent = AgentDefinition::new(&self.name, &self.description)
342 .with_permissions(
343 self.permissions
344 .unwrap_or_else(|| self.kind.default_permissions()),
345 )
346 .with_max_steps(
347 self.max_steps
348 .unwrap_or_else(|| self.kind.default_max_steps()),
349 );
350
351 if self.hidden {
352 agent = agent.hidden();
353 }
354 if let Some(model) = self.model {
355 agent = agent.with_model(model);
356 }
357 if let Some(prompt) = self
358 .prompt
359 .or_else(|| self.kind.default_prompt().map(str::to_string))
360 {
361 agent = agent.with_prompt(&prompt);
362 }
363 agent
364 }
365}
366
367impl From<WorkerAgentSpec> for AgentDefinition {
368 fn from(spec: WorkerAgentSpec) -> Self {
369 spec.into_agent_definition()
370 }
371}
372
373#[derive(Debug, Clone, Serialize, Deserialize)]
377pub struct AgentDefinition {
378 pub name: String,
380 pub description: String,
382 #[serde(default)]
384 pub native: bool,
385 #[serde(default)]
387 pub hidden: bool,
388 #[serde(default)]
390 pub permissions: PermissionPolicy,
391 #[serde(skip_serializing_if = "Option::is_none")]
393 pub model: Option<ModelConfig>,
394 #[serde(skip_serializing_if = "Option::is_none")]
396 pub prompt: Option<String>,
397 #[serde(skip_serializing_if = "Option::is_none")]
399 pub max_steps: Option<usize>,
400}
401
402impl AgentDefinition {
403 pub fn new(name: &str, description: &str) -> Self {
405 Self {
406 name: name.to_string(),
407 description: description.to_string(),
408 native: false,
409 hidden: false,
410 permissions: PermissionPolicy::default(),
411 model: None,
412 prompt: None,
413 max_steps: None,
414 }
415 }
416
417 pub fn worker(spec: WorkerAgentSpec) -> Self {
419 spec.into_agent_definition()
420 }
421
422 pub fn native(mut self) -> Self {
424 self.native = true;
425 self
426 }
427
428 pub fn hidden(mut self) -> Self {
430 self.hidden = true;
431 self
432 }
433
434 pub fn with_permissions(mut self, permissions: PermissionPolicy) -> Self {
436 self.permissions = permissions;
437 self
438 }
439
440 pub fn with_model(mut self, model: ModelConfig) -> Self {
442 self.model = Some(model);
443 self
444 }
445
446 pub fn with_prompt(mut self, prompt: &str) -> Self {
448 self.prompt = Some(prompt.to_string());
449 self
450 }
451
452 pub fn with_max_steps(mut self, max_steps: usize) -> Self {
454 self.max_steps = Some(max_steps);
455 self
456 }
457}
458
459pub struct AgentRegistry {
464 agents: RwLock<HashMap<String, AgentDefinition>>,
465}
466
467impl Default for AgentRegistry {
468 fn default() -> Self {
469 Self::new()
470 }
471}
472
473impl AgentRegistry {
474 pub fn new() -> Self {
476 let registry = Self {
477 agents: RwLock::new(HashMap::new()),
478 };
479
480 for agent in builtin_agents() {
482 registry.register(agent);
483 }
484
485 registry
486 }
487
488 pub fn with_config(config: &CodeConfig) -> Self {
492 let registry = Self::new();
493
494 for dir in &config.agent_dirs {
496 let agents = load_agents_from_dir(dir);
497 for agent in agents {
498 tracing::info!("Loaded agent '{}' from {}", agent.name, dir.display());
499 registry.register(agent);
500 }
501 }
502
503 registry
504 }
505
506 pub fn register(&self, agent: AgentDefinition) {
508 let mut agents = write_or_recover(&self.agents);
509 tracing::debug!("Registering agent: {}", agent.name);
510 agents.insert(agent.name.clone(), agent);
511 }
512
513 pub fn register_worker(&self, spec: WorkerAgentSpec) -> AgentDefinition {
518 let agent = spec.into_agent_definition();
519 self.register(agent.clone());
520 agent
521 }
522
523 pub fn register_workers<I>(&self, specs: I) -> Vec<AgentDefinition>
525 where
526 I: IntoIterator<Item = WorkerAgentSpec>,
527 {
528 specs
529 .into_iter()
530 .map(|spec| self.register_worker(spec))
531 .collect()
532 }
533
534 pub fn unregister(&self, name: &str) -> bool {
538 let mut agents = write_or_recover(&self.agents);
539 agents.remove(name).is_some()
540 }
541
542 pub fn get(&self, name: &str) -> Option<AgentDefinition> {
544 let agents = read_or_recover(&self.agents);
545 agents.get(name).cloned()
546 }
547
548 pub fn list(&self) -> Vec<AgentDefinition> {
550 let agents = read_or_recover(&self.agents);
551 agents.values().cloned().collect()
552 }
553
554 pub fn list_visible(&self) -> Vec<AgentDefinition> {
556 let agents = read_or_recover(&self.agents);
557 agents.values().filter(|a| !a.hidden).cloned().collect()
558 }
559
560 pub fn exists(&self, name: &str) -> bool {
562 let agents = read_or_recover(&self.agents);
563 agents.contains_key(name)
564 }
565
566 pub fn len(&self) -> usize {
568 let agents = read_or_recover(&self.agents);
569 agents.len()
570 }
571
572 pub fn is_empty(&self) -> bool {
574 self.len() == 0
575 }
576}
577
578pub fn parse_agent_yaml(content: &str) -> anyhow::Result<AgentDefinition> {
587 let value: serde_yaml::Value = serde_yaml::from_str(content)
588 .map_err(|e| anyhow::anyhow!("Failed to parse agent YAML: {}", e))?;
589
590 parse_agent_yaml_value(value, "agent YAML")
591}
592
593fn parse_agent_yaml_value(
594 value: serde_yaml::Value,
595 context: &str,
596) -> anyhow::Result<AgentDefinition> {
597 if yaml_value_has_key(&value, "kind") {
598 let spec: WorkerAgentSpec = serde_yaml::from_value(value)
599 .map_err(|e| anyhow::anyhow!("Failed to parse worker {}: {}", context, e))?;
600 validate_agent_name(&spec.name)?;
601 return Ok(spec.into_agent_definition());
602 }
603
604 let agent: AgentDefinition = serde_yaml::from_value(value)
605 .map_err(|e| anyhow::anyhow!("Failed to parse {}: {}", context, e))?;
606 validate_agent_name(&agent.name)?;
607 Ok(agent)
608}
609
610fn parse_worker_yaml_value(
611 value: serde_yaml::Value,
612 context: &str,
613) -> anyhow::Result<WorkerAgentSpec> {
614 let spec: WorkerAgentSpec = serde_yaml::from_value(value)
615 .map_err(|e| anyhow::anyhow!("Failed to parse worker {}: {}", context, e))?;
616 validate_agent_name(&spec.name)?;
617 Ok(spec)
618}
619
620fn yaml_value_has_key(value: &serde_yaml::Value, key: &str) -> bool {
621 value
622 .as_mapping()
623 .map(|mapping| mapping.contains_key(serde_yaml::Value::String(key.to_string())))
624 .unwrap_or(false)
625}
626
627fn validate_agent_name(name: &str) -> anyhow::Result<()> {
628 if name.trim().is_empty() {
629 return Err(anyhow::anyhow!("Agent name is required"));
630 }
631 Ok(())
632}
633
634pub fn parse_agent_md(content: &str) -> anyhow::Result<AgentDefinition> {
638 let parts: Vec<&str> = content.splitn(3, "---").collect();
640
641 if parts.len() < 3 {
642 return Err(anyhow::anyhow!(
643 "Invalid markdown format: missing YAML frontmatter"
644 ));
645 }
646
647 let frontmatter = parts[1].trim();
648 let body = parts[2].trim();
649
650 let value: serde_yaml::Value = serde_yaml::from_str(frontmatter)
652 .map_err(|e| anyhow::anyhow!("Failed to parse agent frontmatter: {}", e))?;
653
654 if yaml_value_has_key(&value, "kind") {
655 let mut spec = parse_worker_yaml_value(value, "frontmatter")?;
656 if spec.prompt.is_none() && !body.is_empty() {
657 spec.prompt = Some(body.to_string());
658 }
659 return Ok(spec.into_agent_definition());
660 }
661
662 let mut agent = parse_agent_yaml_value(value, "agent frontmatter")?;
663
664 if agent.prompt.is_none() && !body.is_empty() {
666 agent.prompt = Some(body.to_string());
667 }
668
669 Ok(agent)
670}
671
672pub fn load_agents_from_dir(dir: &Path) -> Vec<AgentDefinition> {
677 let mut agents = Vec::new();
678
679 let Ok(entries) = std::fs::read_dir(dir) else {
680 tracing::warn!("Failed to read agent directory: {}", dir.display());
681 return agents;
682 };
683
684 for entry in entries.flatten() {
685 let path = entry.path();
686
687 if !path.is_file() {
689 continue;
690 }
691
692 let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
693 continue;
694 };
695
696 let Ok(content) = std::fs::read_to_string(&path) else {
698 tracing::warn!("Failed to read agent file: {}", path.display());
699 continue;
700 };
701
702 let result = match ext {
704 "yaml" | "yml" => parse_agent_yaml(&content),
705 "md" => parse_agent_md(&content),
706 _ => continue,
707 };
708
709 match result {
710 Ok(agent) => {
711 tracing::debug!("Loaded agent '{}' from {}", agent.name, path.display());
712 agents.push(agent);
713 }
714 Err(e) => {
715 tracing::warn!("Failed to parse agent file {}: {}", path.display(), e);
716 }
717 }
718 }
719
720 agents
721}
722
723pub fn builtin_agents() -> Vec<AgentDefinition> {
725 vec![
726 AgentDefinition::new(
728 "explore",
729 "Fast codebase exploration agent. Use for searching files, reading code, \
730 and understanding codebase structure. Read-only operations only.",
731 )
732 .native()
733 .with_permissions(explore_permissions())
734 .with_max_steps(20)
735 .with_prompt(EXPLORE_PROMPT),
736 AgentDefinition::new(
738 "general",
739 "General-purpose agent for multi-step task execution. Can read, write, \
740 and execute commands.",
741 )
742 .native()
743 .with_permissions(general_permissions())
744 .with_max_steps(50),
745 AgentDefinition::new(
747 "plan",
748 "Planning agent for designing implementation approaches. Read-only access \
749 to explore codebase and create plans.",
750 )
751 .native()
752 .with_permissions(plan_permissions())
753 .with_max_steps(30)
754 .with_prompt(PLAN_PROMPT),
755 AgentDefinition::new(
757 "verification",
758 "Verification agent for adversarial validation. Prefer real checks, \
759 reproductions, and regression testing over code reading alone.",
760 )
761 .native()
762 .with_permissions(verification_permissions())
763 .with_max_steps(30)
764 .with_prompt(VERIFICATION_PROMPT),
765 AgentDefinition::new(
767 "review",
768 "Code review agent focused on correctness, regressions, security, \
769 maintainability, and clear findings.",
770 )
771 .native()
772 .with_permissions(review_permissions())
773 .with_max_steps(25)
774 .with_prompt(REVIEW_PROMPT),
775 ]
776}
777
778fn explore_permissions() -> PermissionPolicy {
784 PermissionPolicy::new()
785 .allow_all(&["read", "grep", "glob", "ls"])
786 .deny_all(&["write", "edit", "task"])
787 .allow("Bash(ls:*)")
788 .allow("Bash(cat:*)")
789 .allow("Bash(head:*)")
790 .allow("Bash(tail:*)")
791 .allow("Bash(find:*)")
792 .allow("Bash(wc:*)")
793 .deny("Bash(rm:*)")
794 .deny("Bash(mv:*)")
795 .deny("Bash(cp:*)")
796}
797
798fn general_permissions() -> PermissionPolicy {
800 PermissionPolicy::new()
801 .allow_all(&["read", "write", "edit", "grep", "glob", "ls", "bash"])
802 .deny("task")
803}
804
805fn plan_permissions() -> PermissionPolicy {
807 PermissionPolicy::new()
808 .allow_all(&["read", "grep", "glob", "ls"])
809 .deny_all(&["write", "edit", "bash", "task"])
810}
811
812fn verification_permissions() -> PermissionPolicy {
814 PermissionPolicy::new()
815 .allow_all(&["read", "grep", "glob", "ls", "bash"])
816 .deny_all(&["write", "edit", "task"])
817}
818
819fn review_permissions() -> PermissionPolicy {
821 PermissionPolicy::new()
822 .allow_all(&["read", "grep", "glob", "ls", "bash"])
823 .deny_all(&["write", "edit", "task"])
824}
825
826const EXPLORE_PROMPT: &str = crate::prompts::AGENT_EXPLORE;
831
832const PLAN_PROMPT: &str = crate::prompts::AGENT_PLAN;
833
834const VERIFICATION_PROMPT: &str = crate::prompts::AGENT_VERIFICATION;
835
836const REVIEW_PROMPT: &str = crate::prompts::AGENT_CODE_REVIEW;
837
838#[cfg(test)]
843mod tests {
844 use super::*;
845
846 #[test]
847 fn test_agent_definition_builder() {
848 let agent = AgentDefinition::new("test", "Test agent")
849 .native()
850 .hidden()
851 .with_max_steps(10);
852
853 assert_eq!(agent.name, "test");
854 assert_eq!(agent.description, "Test agent");
855 assert!(agent.native);
856 assert!(agent.hidden);
857 assert_eq!(agent.max_steps, Some(10));
858 }
859
860 #[test]
861 fn test_agent_registry_new() {
862 let registry = AgentRegistry::new();
863
864 assert!(registry.exists("explore"));
866 assert!(registry.exists("general"));
867 assert!(registry.exists("plan"));
868 assert!(registry.exists("verification"));
869 assert!(registry.exists("review"));
870 assert_eq!(registry.len(), 5);
871 }
872
873 #[test]
874 fn test_agent_registry_get() {
875 let registry = AgentRegistry::new();
876
877 let explore = registry.get("explore").unwrap();
878 assert_eq!(explore.name, "explore");
879 assert!(explore.native);
880 assert!(!explore.hidden);
881
882 assert!(registry.get("nonexistent").is_none());
883 }
884
885 #[test]
886 fn test_agent_registry_register_unregister() {
887 let registry = AgentRegistry::new();
888 let initial_count = registry.len();
889
890 let custom = AgentDefinition::new("custom", "Custom agent");
892 registry.register(custom);
893 assert_eq!(registry.len(), initial_count + 1);
894 assert!(registry.exists("custom"));
895
896 assert!(registry.unregister("custom"));
898 assert_eq!(registry.len(), initial_count);
899 assert!(!registry.exists("custom"));
900
901 assert!(!registry.unregister("nonexistent"));
903 }
904
905 #[test]
906 fn test_agent_registry_list_visible() {
907 let registry = AgentRegistry::new();
908
909 let visible = registry.list_visible();
910 let all = registry.list();
911
912 assert_eq!(visible.len(), all.len());
913 assert!(visible.iter().all(|a| !a.hidden));
914 }
915
916 #[test]
917 fn test_builtin_agents() {
918 let agents = builtin_agents();
919
920 let names: Vec<&str> = agents.iter().map(|a| a.name.as_str()).collect();
922 assert!(names.contains(&"explore"));
923 assert!(names.contains(&"general"));
924 assert!(names.contains(&"plan"));
925 assert!(names.contains(&"verification"));
926 assert!(names.contains(&"review"));
927
928 let explore = agents.iter().find(|a| a.name == "explore").unwrap();
930 assert!(!explore.permissions.deny.is_empty());
931 }
932
933 #[test]
938 fn test_parse_agent_yaml() {
939 let yaml = r#"
940name: test-agent
941description: A test agent
942hidden: false
943max_steps: 20
944"#;
945 let agent = parse_agent_yaml(yaml).unwrap();
946 assert_eq!(agent.name, "test-agent");
947 assert_eq!(agent.description, "A test agent");
948 assert!(!agent.hidden);
949 assert_eq!(agent.max_steps, Some(20));
950 }
951
952 #[test]
953 fn test_parse_agent_yaml_with_permissions() {
954 let yaml = r#"
955name: restricted-agent
956description: Agent with permissions
957permissions:
958 allow:
959 - rule: read
960 - rule: grep
961 deny:
962 - rule: write
963"#;
964 let agent = parse_agent_yaml(yaml).unwrap();
965 assert_eq!(agent.name, "restricted-agent");
966 assert_eq!(agent.permissions.allow.len(), 2);
967 assert_eq!(agent.permissions.deny.len(), 1);
968 assert!(agent.permissions.allow[0].matches("read", &serde_json::json!({})));
970 assert!(agent.permissions.allow[1].matches("grep", &serde_json::json!({})));
971 assert!(agent.permissions.deny[0].matches("write", &serde_json::json!({})));
972 }
973
974 #[test]
975 fn test_parse_agent_yaml_with_plain_string_permissions() {
976 let yaml = r#"
978name: plain-agent
979description: Agent with plain string permissions
980permissions:
981 allow:
982 - read
983 - grep
984 - "Bash(cargo:*)"
985 deny:
986 - write
987"#;
988 let agent = parse_agent_yaml(yaml).unwrap();
989 assert_eq!(agent.name, "plain-agent");
990 assert_eq!(agent.permissions.allow.len(), 3);
991 assert_eq!(agent.permissions.deny.len(), 1);
992 assert!(agent.permissions.allow[0].matches("read", &serde_json::json!({})));
994 assert!(agent.permissions.allow[1].matches("grep", &serde_json::json!({})));
995 assert!(agent.permissions.allow[2]
996 .matches("Bash", &serde_json::json!({"command": "cargo build"})));
997 assert!(agent.permissions.deny[0].matches("write", &serde_json::json!({})));
998 }
999
1000 #[test]
1001 fn test_parse_worker_agent_yaml_uses_cattle_defaults() {
1002 let yaml = r#"
1003name: frontend-fixer
1004description: Disposable frontend implementer
1005kind: implementer
1006max_steps: 7
1007"#;
1008 let agent = parse_agent_yaml(yaml).unwrap();
1009
1010 assert_eq!(agent.name, "frontend-fixer");
1011 assert_eq!(agent.max_steps, Some(7));
1012 assert!(agent
1013 .permissions
1014 .allow
1015 .iter()
1016 .any(|r| r.matches("write", &serde_json::json!({}))));
1017 assert!(agent
1018 .permissions
1019 .deny
1020 .iter()
1021 .any(|r| r.matches("task", &serde_json::json!({}))));
1022 }
1023
1024 #[test]
1025 fn test_parse_agent_yaml_missing_name() {
1026 let yaml = r#"
1027description: Agent without name
1028"#;
1029 let result = parse_agent_yaml(yaml);
1030 assert!(result.is_err());
1031 }
1032
1033 #[test]
1034 fn test_parse_agent_md() {
1035 let md = r#"---
1036name: md-agent
1037description: Agent from markdown
1038max_steps: 15
1039---
1040# System Prompt
1041
1042You are a helpful agent.
1043Do your best work.
1044"#;
1045 let agent = parse_agent_md(md).unwrap();
1046 assert_eq!(agent.name, "md-agent");
1047 assert_eq!(agent.description, "Agent from markdown");
1048 assert_eq!(agent.max_steps, Some(15));
1049 assert!(agent.prompt.is_some());
1050 assert!(agent.prompt.unwrap().contains("helpful agent"));
1051 }
1052
1053 #[test]
1054 fn test_parse_agent_md_with_prompt_in_frontmatter() {
1055 let md = r#"---
1056name: prompt-agent
1057description: Agent with prompt in frontmatter
1058prompt: "Frontmatter prompt"
1059---
1060Body content that should be ignored
1061"#;
1062 let agent = parse_agent_md(md).unwrap();
1063 assert_eq!(agent.prompt.unwrap(), "Frontmatter prompt");
1064 }
1065
1066 #[test]
1067 fn test_parse_worker_agent_md_uses_body_prompt() {
1068 let md = r#"---
1069name: review-cow
1070description: Disposable review worker
1071kind: reviewer
1072---
1073Review only the staged diff and return prioritized findings.
1074"#;
1075 let agent = parse_agent_md(md).unwrap();
1076
1077 assert_eq!(agent.name, "review-cow");
1078 assert_eq!(
1079 agent.prompt.as_deref(),
1080 Some("Review only the staged diff and return prioritized findings.")
1081 );
1082 assert!(agent
1083 .permissions
1084 .deny
1085 .iter()
1086 .any(|r| r.matches("write", &serde_json::json!({}))));
1087 }
1088
1089 #[test]
1090 fn test_parse_agent_md_missing_frontmatter() {
1091 let md = "Just markdown without frontmatter";
1092 let result = parse_agent_md(md);
1093 assert!(result.is_err());
1094 }
1095
1096 #[test]
1097 fn test_load_agents_from_dir() {
1098 let temp_dir = tempfile::tempdir().unwrap();
1099
1100 std::fs::write(
1102 temp_dir.path().join("agent1.yaml"),
1103 r#"
1104name: yaml-agent
1105description: Agent from YAML file
1106"#,
1107 )
1108 .unwrap();
1109
1110 std::fs::write(
1112 temp_dir.path().join("agent2.md"),
1113 r#"---
1114name: md-agent
1115description: Agent from Markdown file
1116---
1117System prompt here
1118"#,
1119 )
1120 .unwrap();
1121
1122 std::fs::write(temp_dir.path().join("invalid.yaml"), "not: valid: yaml: [").unwrap();
1124
1125 std::fs::write(temp_dir.path().join("readme.txt"), "Just a text file").unwrap();
1127
1128 let agents = load_agents_from_dir(temp_dir.path());
1129 assert_eq!(agents.len(), 2);
1130
1131 let names: Vec<&str> = agents.iter().map(|a| a.name.as_str()).collect();
1132 assert!(names.contains(&"yaml-agent"));
1133 assert!(names.contains(&"md-agent"));
1134 }
1135
1136 #[test]
1137 fn test_load_agents_from_nonexistent_dir() {
1138 let agents = load_agents_from_dir(std::path::Path::new("/nonexistent/dir"));
1139 assert!(agents.is_empty());
1140 }
1141
1142 #[test]
1143 fn test_registry_with_config() {
1144 let temp_dir = tempfile::tempdir().unwrap();
1145
1146 std::fs::write(
1148 temp_dir.path().join("custom.yaml"),
1149 r#"
1150name: custom-agent
1151description: Custom agent from config
1152"#,
1153 )
1154 .unwrap();
1155
1156 let config = CodeConfig::new().add_agent_dir(temp_dir.path());
1157 let registry = AgentRegistry::with_config(&config);
1158
1159 assert!(registry.exists("explore"));
1161 assert!(registry.exists("custom-agent"));
1162 assert_eq!(registry.len(), 6); }
1164
1165 #[test]
1166 fn test_agent_definition_with_model() {
1167 let model = ModelConfig {
1168 model: "claude-3-5-sonnet".to_string(),
1169 provider: Some("anthropic".to_string()),
1170 };
1171 let agent = AgentDefinition::new("test", "Test").with_model(model);
1172 assert!(agent.model.is_some());
1173 assert_eq!(agent.model.unwrap().provider, Some("anthropic".to_string()));
1174 }
1175
1176 #[test]
1177 fn test_model_config_from_model_ref() {
1178 let model = ModelConfig::from_model_ref("openai/gpt-4o");
1179 assert_eq!(model.provider.as_deref(), Some("openai"));
1180 assert_eq!(model.model, "gpt-4o");
1181 assert_eq!(model.model_ref(), "openai/gpt-4o");
1182
1183 let inherited = ModelConfig::from_model_ref("claude-sonnet");
1184 assert_eq!(inherited.provider, None);
1185 assert_eq!(inherited.model_ref(), "claude-sonnet");
1186 }
1187
1188 #[test]
1189 fn test_worker_agent_kind_from_str_accepts_aliases() {
1190 assert_eq!(
1191 "explore".parse::<WorkerAgentKind>().unwrap(),
1192 WorkerAgentKind::ReadOnly
1193 );
1194 assert_eq!(
1195 "general".parse::<WorkerAgentKind>().unwrap(),
1196 WorkerAgentKind::Implementer
1197 );
1198 assert!("unknown".parse::<WorkerAgentKind>().is_err());
1199 }
1200
1201 #[test]
1202 fn worker_spec_implementer_creates_cattle_agent_definition() {
1203 let agent = WorkerAgentSpec::implementer("frontend-fixer", "Fix frontend issues")
1204 .with_prompt("Focus on small, verified patches.")
1205 .with_provider_model("anthropic", "claude-sonnet")
1206 .with_max_steps(12)
1207 .into_agent_definition();
1208
1209 assert_eq!(agent.name, "frontend-fixer");
1210 assert_eq!(agent.max_steps, Some(12));
1211 assert_eq!(
1212 agent.prompt.as_deref(),
1213 Some("Focus on small, verified patches.")
1214 );
1215 assert_eq!(agent.model.unwrap().provider.as_deref(), Some("anthropic"));
1216 assert!(agent
1217 .permissions
1218 .allow
1219 .iter()
1220 .any(|r| r.matches("write", &serde_json::json!({}))));
1221 assert!(agent
1222 .permissions
1223 .deny
1224 .iter()
1225 .any(|r| r.matches("task", &serde_json::json!({}))));
1226 }
1227
1228 #[test]
1229 fn worker_spec_read_only_uses_safe_defaults() {
1230 let agent = WorkerAgentSpec::read_only("scanner", "Scan repository")
1231 .hidden(true)
1232 .into_agent_definition();
1233
1234 assert!(agent.hidden);
1235 assert_eq!(agent.max_steps, Some(20));
1236 assert!(agent.prompt.is_some());
1237 assert!(agent
1238 .permissions
1239 .allow
1240 .iter()
1241 .any(|r| r.matches("read", &serde_json::json!({}))));
1242 assert!(agent
1243 .permissions
1244 .deny
1245 .iter()
1246 .any(|r| r.matches("write", &serde_json::json!({}))));
1247 }
1248
1249 #[test]
1250 fn registry_register_worker_returns_and_stores_definition() {
1251 let registry = AgentRegistry::new();
1252 let agent =
1253 registry.register_worker(WorkerAgentSpec::custom("strict-worker", "Strict worker"));
1254
1255 assert_eq!(agent.name, "strict-worker");
1256 assert!(registry.exists("strict-worker"));
1257 assert_eq!(
1258 agent
1259 .permissions
1260 .check("bash", &serde_json::json!({"command":"echo hi"})),
1261 crate::permissions::PermissionDecision::Ask
1262 );
1263 }
1264
1265 #[test]
1266 fn registry_register_workers_batches_cattle_specs() {
1267 let registry = AgentRegistry::new();
1268 let agents = registry.register_workers([
1269 WorkerAgentSpec::planner("planner-cow", "Plan work"),
1270 WorkerAgentSpec::verifier("verify-cow", "Verify work"),
1271 ]);
1272
1273 assert_eq!(agents.len(), 2);
1274 assert!(registry.exists("planner-cow"));
1275 assert!(registry.exists("verify-cow"));
1276 }
1277
1278 #[test]
1279 fn test_agent_registry_default() {
1280 let registry = AgentRegistry::default();
1281 assert!(!registry.is_empty());
1282 assert_eq!(registry.len(), 5);
1283 }
1284
1285 #[test]
1286 fn test_agent_registry_is_empty() {
1287 let registry = AgentRegistry {
1288 agents: RwLock::new(HashMap::new()),
1289 };
1290 assert!(registry.is_empty());
1291 assert_eq!(registry.len(), 0);
1292 }
1293}