1use crate::config::CodeConfig;
59use crate::permissions::{PermissionChecker, PermissionDecision, PermissionPolicy};
60use serde::{Deserialize, Serialize};
61use std::collections::HashMap;
62use std::path::Path;
63use std::sync::RwLock;
64
65use crate::error::{read_or_recover, write_or_recover};
66
67#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
69#[serde(rename_all = "snake_case")]
70pub enum ConfirmationInheritance {
71 #[default]
74 AutoApprove,
75 DenyOnAsk,
77 InheritParent,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct ModelConfig {
84 pub model: String,
86 pub provider: Option<String>,
88}
89
90impl ModelConfig {
91 pub fn new(model: impl Into<String>) -> Self {
93 Self {
94 model: model.into(),
95 provider: None,
96 }
97 }
98
99 pub fn with_provider(provider: impl Into<String>, model: impl Into<String>) -> Self {
101 Self {
102 model: model.into(),
103 provider: Some(provider.into()),
104 }
105 }
106
107 pub fn from_model_ref(model_ref: impl AsRef<str>) -> Self {
112 let model_ref = model_ref.as_ref();
113 if let Some((provider, model)) = model_ref.split_once('/') {
114 Self::with_provider(provider, model)
115 } else {
116 Self::new(model_ref)
117 }
118 }
119
120 pub fn model_ref(&self) -> String {
122 match &self.provider {
123 Some(provider) => format!("{}/{}", provider, self.model),
124 None => self.model.clone(),
125 }
126 }
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136pub enum WorkerAgentKind {
137 #[serde(alias = "readonly", alias = "read-only", alias = "explore")]
139 ReadOnly,
140 #[serde(alias = "plan")]
142 Planner,
143 #[serde(alias = "implementation", alias = "general")]
145 Implementer,
146 #[serde(alias = "verification", alias = "verify")]
148 Verifier,
149 #[serde(alias = "review", alias = "code-review")]
151 Reviewer,
152 Custom,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct WorkerAgentSpec {
163 pub name: String,
165 pub description: String,
167 pub kind: WorkerAgentKind,
169 #[serde(default)]
171 pub hidden: bool,
172 #[serde(skip_serializing_if = "Option::is_none")]
174 pub permissions: Option<PermissionPolicy>,
175 #[serde(skip_serializing_if = "Option::is_none")]
177 pub model: Option<ModelConfig>,
178 #[serde(skip_serializing_if = "Option::is_none")]
180 pub prompt: Option<String>,
181 #[serde(skip_serializing_if = "Option::is_none")]
183 pub max_steps: Option<usize>,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub confirmation_inheritance: Option<ConfirmationInheritance>,
187}
188
189impl WorkerAgentKind {
190 pub fn as_str(self) -> &'static str {
192 match self {
193 Self::ReadOnly => "read_only",
194 Self::Planner => "planner",
195 Self::Implementer => "implementer",
196 Self::Verifier => "verifier",
197 Self::Reviewer => "reviewer",
198 Self::Custom => "custom",
199 }
200 }
201
202 fn default_permissions(self) -> PermissionPolicy {
203 match self {
204 Self::ReadOnly => explore_permissions(),
205 Self::Planner => plan_permissions(),
206 Self::Implementer => general_permissions(),
207 Self::Verifier => verification_permissions(),
208 Self::Reviewer => review_permissions(),
209 Self::Custom => PermissionPolicy::strict(),
210 }
211 }
212
213 fn default_prompt(self) -> Option<&'static str> {
214 match self {
215 Self::ReadOnly => Some(EXPLORE_PROMPT),
216 Self::Planner => Some(PLAN_PROMPT),
217 Self::Verifier => Some(VERIFICATION_PROMPT),
218 Self::Reviewer => Some(REVIEW_PROMPT),
219 Self::Implementer | Self::Custom => None,
220 }
221 }
222
223 fn default_max_steps(self) -> usize {
224 match self {
225 Self::ReadOnly => 20,
226 Self::Planner => 30,
227 Self::Implementer => 50,
228 Self::Verifier => 30,
229 Self::Reviewer => 25,
230 Self::Custom => 30,
231 }
232 }
233}
234
235impl std::fmt::Display for WorkerAgentKind {
236 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237 f.write_str(self.as_str())
238 }
239}
240
241impl std::str::FromStr for WorkerAgentKind {
242 type Err = anyhow::Error;
243
244 fn from_str(value: &str) -> anyhow::Result<Self> {
245 match value.trim().to_ascii_lowercase().as_str() {
246 "read_only" | "readonly" | "read-only" | "explore" | "scanner" => Ok(Self::ReadOnly),
247 "planner" | "plan" => Ok(Self::Planner),
248 "implementer" | "implementation" | "general" | "executor" => Ok(Self::Implementer),
249 "verifier" | "verification" | "verify" | "tester" => Ok(Self::Verifier),
250 "reviewer" | "review" | "code-review" | "code_reviewer" => Ok(Self::Reviewer),
251 "custom" => Ok(Self::Custom),
252 other => Err(anyhow::anyhow!("unknown worker agent kind '{}'", other)),
253 }
254 }
255}
256
257pub type CattleAgentKind = WorkerAgentKind;
259pub type CattleAgentSpec = WorkerAgentSpec;
261
262impl WorkerAgentSpec {
263 pub fn new(
265 kind: WorkerAgentKind,
266 name: impl Into<String>,
267 description: impl Into<String>,
268 ) -> Self {
269 Self {
270 name: name.into(),
271 description: description.into(),
272 kind,
273 hidden: false,
274 permissions: None,
275 model: None,
276 prompt: None,
277 max_steps: None,
278 confirmation_inheritance: None,
279 }
280 }
281
282 pub fn read_only(name: impl Into<String>, description: impl Into<String>) -> Self {
284 Self::new(WorkerAgentKind::ReadOnly, name, description)
285 }
286
287 pub fn planner(name: impl Into<String>, description: impl Into<String>) -> Self {
289 Self::new(WorkerAgentKind::Planner, name, description)
290 }
291
292 pub fn implementer(name: impl Into<String>, description: impl Into<String>) -> Self {
294 Self::new(WorkerAgentKind::Implementer, name, description)
295 }
296
297 pub fn verifier(name: impl Into<String>, description: impl Into<String>) -> Self {
299 Self::new(WorkerAgentKind::Verifier, name, description)
300 }
301
302 pub fn reviewer(name: impl Into<String>, description: impl Into<String>) -> Self {
304 Self::new(WorkerAgentKind::Reviewer, name, description)
305 }
306
307 pub fn custom(name: impl Into<String>, description: impl Into<String>) -> Self {
309 Self::new(WorkerAgentKind::Custom, name, description)
310 }
311
312 pub fn hidden(mut self, hidden: bool) -> Self {
314 self.hidden = hidden;
315 self
316 }
317
318 pub fn with_permissions(mut self, permissions: PermissionPolicy) -> Self {
320 self.permissions = Some(permissions);
321 self
322 }
323
324 pub fn with_model(mut self, model: ModelConfig) -> Self {
326 self.model = Some(model);
327 self
328 }
329
330 pub fn with_model_ref(mut self, model_ref: impl AsRef<str>) -> Self {
332 self.model = Some(ModelConfig::from_model_ref(model_ref));
333 self
334 }
335
336 pub fn with_provider_model(
338 mut self,
339 provider: impl Into<String>,
340 model: impl Into<String>,
341 ) -> Self {
342 self.model = Some(ModelConfig::with_provider(provider, model));
343 self
344 }
345
346 pub fn with_prompt(mut self, prompt: impl Into<String>) -> Self {
348 self.prompt = Some(prompt.into());
349 self
350 }
351
352 pub fn with_max_steps(mut self, max_steps: usize) -> Self {
354 self.max_steps = Some(max_steps);
355 self
356 }
357
358 pub fn with_confirmation(mut self, inheritance: ConfirmationInheritance) -> Self {
360 self.confirmation_inheritance = Some(inheritance);
361 self
362 }
363
364 pub fn into_agent_definition(self) -> AgentDefinition {
366 let mut agent = AgentDefinition::new(&self.name, &self.description)
367 .with_permissions(
368 self.permissions
369 .unwrap_or_else(|| self.kind.default_permissions()),
370 )
371 .with_max_steps(
372 self.max_steps
373 .unwrap_or_else(|| self.kind.default_max_steps()),
374 );
375
376 if self.hidden {
377 agent = agent.hidden();
378 }
379 if let Some(model) = self.model {
380 agent = agent.with_model(model);
381 }
382 if let Some(prompt) = self
383 .prompt
384 .or_else(|| self.kind.default_prompt().map(str::to_string))
385 {
386 agent = agent.with_prompt(&prompt);
387 }
388 if let Some(ci) = self.confirmation_inheritance {
389 agent = agent.with_confirmation(ci);
390 }
391 agent
392 }
393}
394
395impl From<WorkerAgentSpec> for AgentDefinition {
396 fn from(spec: WorkerAgentSpec) -> Self {
397 spec.into_agent_definition()
398 }
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize)]
405pub struct AgentDefinition {
406 pub name: String,
408 pub description: String,
410 #[serde(default)]
412 pub native: bool,
413 #[serde(default)]
415 pub hidden: bool,
416 #[serde(default)]
418 pub permissions: PermissionPolicy,
419 #[serde(skip_serializing_if = "Option::is_none")]
421 pub model: Option<ModelConfig>,
422 #[serde(skip_serializing_if = "Option::is_none")]
424 pub prompt: Option<String>,
425 #[serde(skip_serializing_if = "Option::is_none")]
427 pub max_steps: Option<usize>,
428 #[serde(default, skip_serializing_if = "Option::is_none")]
431 pub confirmation_inheritance: Option<ConfirmationInheritance>,
432}
433
434impl AgentDefinition {
435 pub fn new(name: &str, description: &str) -> Self {
437 Self {
438 name: name.to_string(),
439 description: description.to_string(),
440 native: false,
441 hidden: false,
442 permissions: PermissionPolicy::default(),
443 model: None,
444 prompt: None,
445 max_steps: None,
446 confirmation_inheritance: None,
447 }
448 }
449
450 pub fn worker(spec: WorkerAgentSpec) -> Self {
452 spec.into_agent_definition()
453 }
454
455 pub fn native(mut self) -> Self {
457 self.native = true;
458 self
459 }
460
461 pub fn hidden(mut self) -> Self {
463 self.hidden = true;
464 self
465 }
466
467 pub fn with_permissions(mut self, permissions: PermissionPolicy) -> Self {
469 self.permissions = permissions;
470 self
471 }
472
473 pub fn with_model(mut self, model: ModelConfig) -> Self {
475 self.model = Some(model);
476 self
477 }
478
479 pub fn with_prompt(mut self, prompt: &str) -> Self {
481 self.prompt = Some(prompt.to_string());
482 self
483 }
484
485 pub fn with_max_steps(mut self, max_steps: usize) -> Self {
487 self.max_steps = Some(max_steps);
488 self
489 }
490
491 pub fn has_defined_permissions(&self) -> bool {
493 !self.permissions.allow.is_empty() || !self.permissions.deny.is_empty()
494 }
495
496 pub(crate) fn apply_to(&self, config: &mut crate::agent::AgentConfig) {
502 use std::sync::Arc;
503
504 if config.permission_checker.is_none() && self.has_defined_permissions() {
505 config.permission_checker =
506 Some(Arc::new(self.permissions.clone()) as Arc<dyn PermissionChecker>);
507 config.permission_policy = Some(self.permissions.clone());
508 }
509
510 if let Some(ref prompt) = self.prompt {
511 if config.prompt_slots.extra.is_none() {
512 config.prompt_slots.extra = Some(prompt.clone());
513 }
514 }
515
516 if let Some(max_steps) = self.max_steps {
517 if config.max_tool_rounds == crate::agent::MAX_TOOL_ROUNDS {
518 config.max_tool_rounds = max_steps;
519 }
520 }
521
522 if config.confirmation_manager.is_none() {
524 let inheritance = self.confirmation_inheritance.clone().unwrap_or_else(|| {
525 if self.has_defined_permissions() {
526 ConfirmationInheritance::AutoApprove
527 } else {
528 ConfirmationInheritance::DenyOnAsk
529 }
530 });
531 match inheritance {
532 ConfirmationInheritance::AutoApprove => {
533 config.confirmation_manager =
534 Some(Arc::new(crate::hitl::AutoApproveConfirmation));
535 }
536 ConfirmationInheritance::DenyOnAsk => { }
537 ConfirmationInheritance::InheritParent => { }
538 }
539 }
540 }
541
542 pub fn with_confirmation(mut self, inheritance: ConfirmationInheritance) -> Self {
544 self.confirmation_inheritance = Some(inheritance);
545 self
546 }
547}
548
549pub struct AgentRegistry {
554 agents: RwLock<HashMap<String, AgentDefinition>>,
555}
556
557fn canonical_agent_name(name: &str) -> &str {
558 match name.trim() {
559 "general-purpose" | "general_purpose" | "generalpurpose" => "general",
560 "deep_research" | "deepresearch" => "deep-research",
561 "verify" | "verifier" => "verification",
562 "code-review" | "code_reviewer" | "reviewer" => "review",
563 other => other,
564 }
565}
566
567impl Default for AgentRegistry {
568 fn default() -> Self {
569 Self::new()
570 }
571}
572
573impl AgentRegistry {
574 pub fn new() -> Self {
576 let registry = Self {
577 agents: RwLock::new(HashMap::new()),
578 };
579
580 for agent in builtin_agents() {
582 registry.register(agent);
583 }
584
585 registry
586 }
587
588 pub fn with_config(config: &CodeConfig) -> Self {
592 let registry = Self::new();
593
594 for dir in &config.agent_dirs {
596 let agents = load_agents_from_dir(dir);
597 for agent in agents {
598 tracing::info!("Loaded agent '{}' from {}", agent.name, dir.display());
599 registry.register(agent);
600 }
601 }
602
603 registry
604 }
605
606 pub fn register(&self, agent: AgentDefinition) {
608 let mut agents = write_or_recover(&self.agents);
609 tracing::debug!("Registering agent: {}", agent.name);
610 agents.insert(agent.name.clone(), agent);
611 }
612
613 pub fn register_worker(&self, spec: WorkerAgentSpec) -> AgentDefinition {
618 let agent = spec.into_agent_definition();
619 self.register(agent.clone());
620 agent
621 }
622
623 pub fn register_workers<I>(&self, specs: I) -> Vec<AgentDefinition>
625 where
626 I: IntoIterator<Item = WorkerAgentSpec>,
627 {
628 specs
629 .into_iter()
630 .map(|spec| self.register_worker(spec))
631 .collect()
632 }
633
634 pub fn unregister(&self, name: &str) -> bool {
638 let mut agents = write_or_recover(&self.agents);
639 agents.remove(name).is_some()
640 }
641
642 pub fn get(&self, name: &str) -> Option<AgentDefinition> {
644 let agents = read_or_recover(&self.agents);
645 agents
646 .get(name)
647 .or_else(|| agents.get(canonical_agent_name(name)))
648 .cloned()
649 }
650
651 pub fn list(&self) -> Vec<AgentDefinition> {
653 let agents = read_or_recover(&self.agents);
654 agents.values().cloned().collect()
655 }
656
657 pub fn list_visible(&self) -> Vec<AgentDefinition> {
659 let agents = read_or_recover(&self.agents);
660 agents.values().filter(|a| !a.hidden).cloned().collect()
661 }
662
663 pub fn exists(&self, name: &str) -> bool {
665 let agents = read_or_recover(&self.agents);
666 agents.contains_key(name) || agents.contains_key(canonical_agent_name(name))
667 }
668
669 pub fn len(&self) -> usize {
671 let agents = read_or_recover(&self.agents);
672 agents.len()
673 }
674
675 pub fn is_empty(&self) -> bool {
677 self.len() == 0
678 }
679}
680
681pub fn parse_agent_yaml(content: &str) -> anyhow::Result<AgentDefinition> {
690 let value: serde_yaml::Value = serde_yaml::from_str(content)
691 .map_err(|e| anyhow::anyhow!("Failed to parse agent YAML: {}", e))?;
692
693 parse_agent_yaml_value(value, "agent YAML")
694}
695
696fn parse_agent_yaml_value(
697 value: serde_yaml::Value,
698 context: &str,
699) -> anyhow::Result<AgentDefinition> {
700 let tools = yaml_get_any(&value, &["tools", "allowedTools", "allowed_tools"])
701 .map(parse_tools_field)
702 .unwrap_or_default();
703 let disallowed_tools = yaml_get_any(
704 &value,
705 &["disallowedTools", "disallowed-tools", "disallowed_tools"],
706 )
707 .map(parse_tools_field)
708 .unwrap_or_default();
709
710 if yaml_value_has_key(&value, "kind") {
711 let mut spec: WorkerAgentSpec = serde_yaml::from_value(value)
712 .map_err(|e| anyhow::anyhow!("Failed to parse worker {}: {}", context, e))?;
713 validate_agent_name(&spec.name)?;
714 apply_claude_style_tools_to_spec(&mut spec, &tools, &disallowed_tools);
715 return Ok(spec.into_agent_definition());
716 }
717
718 let mut agent: AgentDefinition = serde_yaml::from_value(value)
719 .map_err(|e| anyhow::anyhow!("Failed to parse {}: {}", context, e))?;
720 validate_agent_name(&agent.name)?;
721 apply_claude_style_tools_to_agent(&mut agent, &tools, &disallowed_tools);
722 Ok(agent)
723}
724
725fn apply_claude_style_tools_to_agent(
726 agent: &mut AgentDefinition,
727 tools: &[String],
728 disallowed_tools: &[String],
729) {
730 if !tools.is_empty() {
731 agent.permissions = allow_only_permission_policy(tools);
732 }
733 if !disallowed_tools.is_empty() {
734 let base = std::mem::take(&mut agent.permissions);
735 agent.permissions = add_denied_tools(base, disallowed_tools);
736 }
737 if (!tools.is_empty() || !disallowed_tools.is_empty())
738 && agent.confirmation_inheritance.is_none()
739 {
740 agent.confirmation_inheritance = Some(ConfirmationInheritance::AutoApprove);
741 }
742}
743
744fn apply_claude_style_tools_to_spec(
745 spec: &mut WorkerAgentSpec,
746 tools: &[String],
747 disallowed_tools: &[String],
748) {
749 if tools.is_empty() && disallowed_tools.is_empty() {
750 return;
751 }
752
753 let base = if tools.is_empty() {
754 spec.permissions
755 .clone()
756 .unwrap_or_else(|| spec.kind.default_permissions())
757 } else {
758 allow_only_permission_policy(tools)
759 };
760 spec.permissions = Some(add_denied_tools(base, disallowed_tools));
761 if spec.confirmation_inheritance.is_none() {
762 spec.confirmation_inheritance = Some(ConfirmationInheritance::AutoApprove);
763 }
764}
765
766fn parse_worker_yaml_value(
767 value: serde_yaml::Value,
768 context: &str,
769) -> anyhow::Result<WorkerAgentSpec> {
770 let spec: WorkerAgentSpec = serde_yaml::from_value(value)
771 .map_err(|e| anyhow::anyhow!("Failed to parse worker {}: {}", context, e))?;
772 validate_agent_name(&spec.name)?;
773 Ok(spec)
774}
775
776fn yaml_value_has_key(value: &serde_yaml::Value, key: &str) -> bool {
777 value
778 .as_mapping()
779 .map(|mapping| mapping.contains_key(serde_yaml::Value::String(key.to_string())))
780 .unwrap_or(false)
781}
782
783fn yaml_get<'a>(value: &'a serde_yaml::Value, key: &str) -> Option<&'a serde_yaml::Value> {
784 value
785 .as_mapping()
786 .and_then(|mapping| mapping.get(serde_yaml::Value::String(key.to_string())))
787}
788
789fn yaml_get_any<'a>(value: &'a serde_yaml::Value, keys: &[&str]) -> Option<&'a serde_yaml::Value> {
790 keys.iter().find_map(|key| yaml_get(value, key))
791}
792
793fn parse_tools_field(value: &serde_yaml::Value) -> Vec<String> {
794 match value {
795 serde_yaml::Value::String(raw) => raw
796 .split(',')
797 .map(str::trim)
798 .filter(|tool| !tool.is_empty())
799 .map(str::to_string)
800 .collect(),
801 serde_yaml::Value::Sequence(items) => items
802 .iter()
803 .filter_map(|item| item.as_str())
804 .map(str::trim)
805 .filter(|tool| !tool.is_empty())
806 .map(str::to_string)
807 .collect(),
808 _ => Vec::new(),
809 }
810}
811
812fn tool_name_to_permission(tool: &str) -> String {
813 let normalized = tool.trim();
814 match normalized.to_ascii_lowercase().as_str() {
815 "*" => "*".to_string(),
816 "read" => "read(*)".to_string(),
817 "write" => "write(*)".to_string(),
818 "edit" => "edit(*)".to_string(),
819 "grep" => "grep(*)".to_string(),
820 "glob" => "glob(*)".to_string(),
821 "ls" => "ls(*)".to_string(),
822 "bash" => "bash(*)".to_string(),
823 "task" => "task(*)".to_string(),
824 "parallel_task" | "parallel-task" => "parallel_task(*)".to_string(),
825 _ if normalized.contains('(') => normalized.to_string(),
826 _ => format!("{normalized}(*)"),
827 }
828}
829
830fn permission_policy_from_tools(tools: &[String]) -> PermissionPolicy {
831 tools.iter().fold(PermissionPolicy::new(), |policy, tool| {
832 policy.allow(&tool_name_to_permission(tool))
833 })
834}
835
836fn allow_only_permission_policy(tools: &[String]) -> PermissionPolicy {
837 let mut policy = permission_policy_from_tools(tools);
838 policy.default_decision = PermissionDecision::Deny;
839 policy
840}
841
842fn add_denied_tools(mut policy: PermissionPolicy, tools: &[String]) -> PermissionPolicy {
843 for tool in tools {
844 policy = policy.deny(&tool_name_to_permission(tool));
845 }
846 policy
847}
848
849fn validate_agent_name(name: &str) -> anyhow::Result<()> {
850 if name.trim().is_empty() {
851 return Err(anyhow::anyhow!("Agent name is required"));
852 }
853 Ok(())
854}
855
856pub fn parse_agent_md(content: &str) -> anyhow::Result<AgentDefinition> {
860 let parts: Vec<&str> = content.splitn(3, "---").collect();
862
863 if parts.len() < 3 {
864 return Err(anyhow::anyhow!(
865 "Invalid markdown format: missing YAML frontmatter"
866 ));
867 }
868
869 let frontmatter = parts[1].trim();
870 let body = parts[2].trim();
871
872 let value: serde_yaml::Value = serde_yaml::from_str(frontmatter)
874 .map_err(|e| anyhow::anyhow!("Failed to parse agent frontmatter: {}", e))?;
875
876 if yaml_value_has_key(&value, "kind") {
877 let tools = yaml_get_any(&value, &["tools", "allowedTools", "allowed_tools"])
878 .map(parse_tools_field)
879 .unwrap_or_default();
880 let disallowed_tools = yaml_get_any(
881 &value,
882 &["disallowedTools", "disallowed-tools", "disallowed_tools"],
883 )
884 .map(parse_tools_field)
885 .unwrap_or_default();
886 let mut spec = parse_worker_yaml_value(value, "frontmatter")?;
887 if spec.prompt.is_none() && !body.is_empty() {
888 spec.prompt = Some(body.to_string());
889 }
890 apply_claude_style_tools_to_spec(&mut spec, &tools, &disallowed_tools);
891 return Ok(spec.into_agent_definition());
892 }
893
894 let mut agent = parse_agent_yaml_value(value, "agent frontmatter")?;
895
896 if agent.prompt.is_none() && !body.is_empty() {
898 agent.prompt = Some(body.to_string());
899 }
900
901 Ok(agent)
902}
903
904pub fn load_agents_from_dir(dir: &Path) -> Vec<AgentDefinition> {
909 let mut agents = Vec::new();
910 load_agents_from_dir_inner(dir, &mut agents);
911 agents
912}
913
914fn load_agents_from_dir_inner(dir: &Path, agents: &mut Vec<AgentDefinition>) {
915 let Ok(entries) = std::fs::read_dir(dir) else {
916 tracing::warn!("Failed to read agent directory: {}", dir.display());
917 return;
918 };
919
920 for entry in entries.flatten() {
921 let path = entry.path();
922
923 if path.is_dir() {
924 load_agents_from_dir_inner(&path, agents);
925 continue;
926 }
927 if !path.is_file() {
928 continue;
929 }
930
931 let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
932 continue;
933 };
934
935 let Ok(content) = std::fs::read_to_string(&path) else {
937 tracing::warn!("Failed to read agent file: {}", path.display());
938 continue;
939 };
940
941 let result = match ext {
943 "yaml" | "yml" => parse_agent_yaml(&content),
944 "md" => parse_agent_md(&content),
945 _ => continue,
946 };
947
948 match result {
949 Ok(agent) => {
950 tracing::debug!("Loaded agent '{}' from {}", agent.name, path.display());
951 agents.push(agent);
952 }
953 Err(e) => {
954 tracing::warn!("Failed to parse agent file {}: {}", path.display(), e);
955 }
956 }
957 }
958}
959
960pub fn builtin_agents() -> Vec<AgentDefinition> {
962 vec![
963 AgentDefinition::new(
965 "explore",
966 "Fast read-only exploration agent. Use for searching files, reading code, \
967 understanding codebase structure, and gathering external web evidence.",
968 )
969 .native()
970 .with_permissions(explore_permissions())
971 .with_max_steps(20)
972 .with_prompt(EXPLORE_PROMPT),
973 AgentDefinition::new(
975 "general",
976 "General-purpose agent for multi-step task execution. Can read, write, \
977 and execute commands.",
978 )
979 .native()
980 .with_permissions(general_permissions())
981 .with_max_steps(50),
982 AgentDefinition::new(
985 "deep-research",
986 "DeepResearch evidence agent. Inherits the parent session's tools, \
987 skills, permissions, and HITL policy for bounded evidence collection.",
988 )
989 .native()
990 .hidden()
991 .with_confirmation(ConfirmationInheritance::InheritParent)
992 .with_max_steps(50),
993 AgentDefinition::new(
995 "plan",
996 "Planning agent for designing implementation approaches. Read-only access \
997 to explore codebase and create plans.",
998 )
999 .native()
1000 .with_permissions(plan_permissions())
1001 .with_max_steps(30)
1002 .with_prompt(PLAN_PROMPT),
1003 AgentDefinition::new(
1005 "verification",
1006 "Verification agent for adversarial validation. Prefer real checks, \
1007 reproductions, and regression testing over code reading alone.",
1008 )
1009 .native()
1010 .with_permissions(verification_permissions())
1011 .with_max_steps(30)
1012 .with_prompt(VERIFICATION_PROMPT),
1013 AgentDefinition::new(
1015 "review",
1016 "Code review agent focused on correctness, regressions, security, \
1017 maintainability, and clear findings.",
1018 )
1019 .native()
1020 .with_permissions(review_permissions())
1021 .with_max_steps(25)
1022 .with_prompt(REVIEW_PROMPT),
1023 ]
1024}
1025
1026fn explore_permissions() -> PermissionPolicy {
1032 let mut policy = PermissionPolicy::new()
1033 .allow_all(&["read", "grep", "glob", "ls", "web_fetch", "web_search"])
1034 .deny_all(&["write", "edit", "task", "parallel_task"])
1035 .allow("Bash(ls:*)")
1036 .allow("Bash(cat:*)")
1037 .allow("Bash(head:*)")
1038 .allow("Bash(tail:*)")
1039 .allow("Bash(find:*)")
1040 .allow("Bash(wc:*)")
1041 .deny("Bash(rm:*)")
1042 .deny("Bash(mv:*)")
1043 .deny("Bash(cp:*)");
1044 policy.default_decision = PermissionDecision::Deny;
1045 policy
1046}
1047
1048fn general_permissions() -> PermissionPolicy {
1050 PermissionPolicy::new()
1051 .allow_all(&[
1052 "read",
1053 "write",
1054 "edit",
1055 "grep",
1056 "glob",
1057 "ls",
1058 "bash",
1059 "web_fetch",
1060 "web_search",
1061 "git",
1062 "patch",
1063 "batch",
1064 "generate_object",
1065 ])
1066 .deny("task")
1067 .deny("parallel_task")
1068}
1069
1070fn plan_permissions() -> PermissionPolicy {
1072 let mut policy = PermissionPolicy::new()
1073 .allow_all(&["read", "grep", "glob", "ls"])
1074 .deny_all(&["write", "edit", "bash", "task", "parallel_task"]);
1075 policy.default_decision = PermissionDecision::Deny;
1076 policy
1077}
1078
1079fn verification_permissions() -> PermissionPolicy {
1081 let mut policy = PermissionPolicy::new()
1082 .allow_all(&[
1083 "read",
1084 "grep",
1085 "glob",
1086 "ls",
1087 "bash",
1088 "web_fetch",
1089 "web_search",
1090 ])
1091 .deny_all(&["write", "edit", "task", "parallel_task"]);
1092 policy.default_decision = PermissionDecision::Deny;
1093 policy
1094}
1095
1096fn review_permissions() -> PermissionPolicy {
1098 let mut policy = PermissionPolicy::new()
1099 .allow_all(&[
1100 "read",
1101 "grep",
1102 "glob",
1103 "ls",
1104 "bash",
1105 "web_fetch",
1106 "web_search",
1107 ])
1108 .deny_all(&["write", "edit", "task", "parallel_task"]);
1109 policy.default_decision = PermissionDecision::Deny;
1110 policy
1111}
1112
1113const EXPLORE_PROMPT: &str = crate::prompts::AGENT_EXPLORE;
1118
1119const PLAN_PROMPT: &str = crate::prompts::AGENT_PLAN;
1120
1121const VERIFICATION_PROMPT: &str = crate::prompts::AGENT_VERIFICATION;
1122
1123const REVIEW_PROMPT: &str = crate::prompts::AGENT_CODE_REVIEW;
1124
1125#[cfg(test)]
1130mod tests {
1131 use super::*;
1132
1133 #[test]
1134 fn test_agent_definition_builder() {
1135 let agent = AgentDefinition::new("test", "Test agent")
1136 .native()
1137 .hidden()
1138 .with_max_steps(10);
1139
1140 assert_eq!(agent.name, "test");
1141 assert_eq!(agent.description, "Test agent");
1142 assert!(agent.native);
1143 assert!(agent.hidden);
1144 assert_eq!(agent.max_steps, Some(10));
1145 }
1146
1147 #[test]
1148 fn test_agent_registry_new() {
1149 let registry = AgentRegistry::new();
1150
1151 assert!(registry.exists("explore"));
1153 assert!(registry.exists("general"));
1154 assert!(registry.exists("plan"));
1155 assert!(registry.exists("verification"));
1156 assert!(registry.exists("review"));
1157 assert!(registry.exists("general-purpose"));
1158 assert!(registry.exists("deepresearch"));
1159 assert_eq!(registry.len(), 6);
1160 }
1161
1162 #[test]
1163 fn test_agent_registry_get() {
1164 let registry = AgentRegistry::new();
1165
1166 let explore = registry.get("explore").unwrap();
1167 assert_eq!(explore.name, "explore");
1168 assert!(explore.native);
1169 assert!(!explore.hidden);
1170
1171 let general = registry.get("general-purpose").unwrap();
1172 assert_eq!(general.name, "general");
1173
1174 assert!(registry.get("nonexistent").is_none());
1175 }
1176
1177 #[test]
1178 fn test_agent_registry_register_unregister() {
1179 let registry = AgentRegistry::new();
1180 let initial_count = registry.len();
1181
1182 let custom = AgentDefinition::new("custom", "Custom agent");
1184 registry.register(custom);
1185 assert_eq!(registry.len(), initial_count + 1);
1186 assert!(registry.exists("custom"));
1187
1188 assert!(registry.unregister("custom"));
1190 assert_eq!(registry.len(), initial_count);
1191 assert!(!registry.exists("custom"));
1192
1193 assert!(!registry.unregister("nonexistent"));
1195 }
1196
1197 #[test]
1198 fn test_agent_registry_list_visible() {
1199 let registry = AgentRegistry::new();
1200
1201 let visible = registry.list_visible();
1202 let all = registry.list();
1203
1204 assert!(visible.len() < all.len());
1205 assert!(visible.iter().all(|a| !a.hidden));
1206 assert!(!visible.iter().any(|a| a.name == "deep-research"));
1207 }
1208
1209 #[test]
1210 fn test_builtin_agents() {
1211 let agents = builtin_agents();
1212
1213 let names: Vec<&str> = agents.iter().map(|a| a.name.as_str()).collect();
1215 assert!(names.contains(&"explore"));
1216 assert!(names.contains(&"general"));
1217 assert!(names.contains(&"deep-research"));
1218 assert!(names.contains(&"plan"));
1219 assert!(names.contains(&"verification"));
1220 assert!(names.contains(&"review"));
1221
1222 let explore = agents.iter().find(|a| a.name == "explore").unwrap();
1224 assert!(!explore.permissions.deny.is_empty());
1225 let deep_research = agents.iter().find(|a| a.name == "deep-research").unwrap();
1226 assert!(deep_research.hidden);
1227 assert!(deep_research.permissions.allow.is_empty());
1228 assert!(deep_research.permissions.deny.is_empty());
1229 assert_eq!(
1230 deep_research.confirmation_inheritance,
1231 Some(ConfirmationInheritance::InheritParent)
1232 );
1233 }
1234
1235 #[test]
1240 fn test_parse_agent_yaml() {
1241 let yaml = r#"
1242name: test-agent
1243description: A test agent
1244hidden: false
1245max_steps: 20
1246"#;
1247 let agent = parse_agent_yaml(yaml).unwrap();
1248 assert_eq!(agent.name, "test-agent");
1249 assert_eq!(agent.description, "A test agent");
1250 assert!(!agent.hidden);
1251 assert_eq!(agent.max_steps, Some(20));
1252 }
1253
1254 #[test]
1255 fn test_parse_agent_yaml_with_permissions() {
1256 let yaml = r#"
1257name: restricted-agent
1258description: Agent with permissions
1259permissions:
1260 allow:
1261 - rule: read
1262 - rule: grep
1263 deny:
1264 - rule: write
1265"#;
1266 let agent = parse_agent_yaml(yaml).unwrap();
1267 assert_eq!(agent.name, "restricted-agent");
1268 assert_eq!(agent.permissions.allow.len(), 2);
1269 assert_eq!(agent.permissions.deny.len(), 1);
1270 assert!(agent.permissions.allow[0].matches("read", &serde_json::json!({})));
1272 assert!(agent.permissions.allow[1].matches("grep", &serde_json::json!({})));
1273 assert!(agent.permissions.deny[0].matches("write", &serde_json::json!({})));
1274 }
1275
1276 #[test]
1277 fn test_parse_agent_yaml_with_plain_string_permissions() {
1278 let yaml = r#"
1280name: plain-agent
1281description: Agent with plain string permissions
1282permissions:
1283 allow:
1284 - read
1285 - grep
1286 - "Bash(cargo:*)"
1287 deny:
1288 - write
1289"#;
1290 let agent = parse_agent_yaml(yaml).unwrap();
1291 assert_eq!(agent.name, "plain-agent");
1292 assert_eq!(agent.permissions.allow.len(), 3);
1293 assert_eq!(agent.permissions.deny.len(), 1);
1294 assert!(agent.permissions.allow[0].matches("read", &serde_json::json!({})));
1296 assert!(agent.permissions.allow[1].matches("grep", &serde_json::json!({})));
1297 assert!(agent.permissions.allow[2]
1298 .matches("Bash", &serde_json::json!({"command": "cargo build"})));
1299 assert!(agent.permissions.deny[0].matches("write", &serde_json::json!({})));
1300 }
1301
1302 #[test]
1303 fn test_parse_claude_style_agent_md_tools_field() {
1304 let md = r#"---
1305name: code-reviewer
1306description: Use proactively after code changes to review quality
1307tools: Read, Grep, Glob, Bash
1308---
1309Review the changed code and return prioritized findings.
1310"#;
1311 let agent = parse_agent_md(md).unwrap();
1312
1313 assert_eq!(agent.name, "code-reviewer");
1314 assert_eq!(
1315 agent.confirmation_inheritance,
1316 Some(ConfirmationInheritance::AutoApprove)
1317 );
1318 assert!(agent
1319 .permissions
1320 .allow
1321 .iter()
1322 .any(|r| r.matches("read", &serde_json::json!({}))));
1323 assert!(agent
1324 .permissions
1325 .allow
1326 .iter()
1327 .any(|r| r.matches("grep", &serde_json::json!({}))));
1328 assert!(agent
1329 .permissions
1330 .allow
1331 .iter()
1332 .any(|r| r.matches("bash", &serde_json::json!({}))));
1333 assert_eq!(
1334 agent
1335 .permissions
1336 .check("write", &serde_json::json!({"file_path": "src/lib.rs"})),
1337 PermissionDecision::Deny
1338 );
1339 assert!(agent
1340 .prompt
1341 .as_deref()
1342 .unwrap_or_default()
1343 .contains("prioritized findings"));
1344 }
1345
1346 #[test]
1347 fn test_parse_claude_style_agent_md_disallowed_tools_field() {
1348 let md = r#"---
1349name: shell-checker
1350description: Use proactively to run safe shell checks
1351tools:
1352 - Read
1353 - Bash
1354disallowedTools:
1355 - Bash(rm:*)
1356 - Write
1357---
1358Run safe checks only.
1359"#;
1360 let agent = parse_agent_md(md).unwrap();
1361
1362 assert_eq!(agent.name, "shell-checker");
1363 assert_eq!(
1364 agent
1365 .permissions
1366 .check("bash", &serde_json::json!({"command": "rm -rf target"})),
1367 PermissionDecision::Deny
1368 );
1369 assert_eq!(
1370 agent
1371 .permissions
1372 .check("bash", &serde_json::json!({"command": "cargo test"})),
1373 PermissionDecision::Allow
1374 );
1375 assert_eq!(
1376 agent
1377 .permissions
1378 .check("write", &serde_json::json!({"file_path": "x"})),
1379 PermissionDecision::Deny
1380 );
1381 }
1382
1383 #[test]
1384 fn test_parse_worker_agent_md_supports_claude_tools_fields() {
1385 let md = r#"---
1386name: planner-worker
1387description: Plan work
1388kind: planner
1389tools: Read, Grep
1390disallowedTools: Grep(secret:*)
1391---
1392Plan without editing.
1393"#;
1394 let agent = parse_agent_md(md).unwrap();
1395
1396 assert_eq!(agent.name, "planner-worker");
1397 assert_eq!(
1398 agent
1399 .permissions
1400 .check("read", &serde_json::json!({"file_path": "src/lib.rs"})),
1401 PermissionDecision::Allow
1402 );
1403 assert_eq!(
1404 agent.permissions.check(
1405 "grep",
1406 &serde_json::json!({"pattern": "secret", "path": "src"})
1407 ),
1408 PermissionDecision::Deny
1409 );
1410 assert_eq!(
1411 agent
1412 .permissions
1413 .check("bash", &serde_json::json!({"command": "echo no"})),
1414 PermissionDecision::Deny
1415 );
1416 }
1417
1418 #[test]
1419 fn test_builtin_agent_permissions_are_bounded() {
1420 let registry = AgentRegistry::new();
1421 let explore = registry.get("explore").unwrap();
1422 let general = registry.get("general-purpose").unwrap();
1423 let deep_research = registry.get("deepresearch").unwrap();
1424 let verification = registry.get("verification").unwrap();
1425 let review = registry.get("review").unwrap();
1426
1427 assert_eq!(
1428 explore
1429 .permissions
1430 .check("bash", &serde_json::json!({"command": "cargo test"})),
1431 PermissionDecision::Deny
1432 );
1433 assert_eq!(
1434 explore
1435 .permissions
1436 .check("bash", &serde_json::json!({"command": "ls src"})),
1437 PermissionDecision::Allow
1438 );
1439 assert!(
1440 !deep_research.has_defined_permissions(),
1441 "DeepResearch child agent should inherit parent session permissions"
1442 );
1443 assert_eq!(
1444 explore
1445 .permissions
1446 .check("web_search", &serde_json::json!({"query": "a3s"})),
1447 PermissionDecision::Allow
1448 );
1449 assert_eq!(
1450 explore.permissions.check(
1451 "web_fetch",
1452 &serde_json::json!({"url": "https://example.com"})
1453 ),
1454 PermissionDecision::Allow
1455 );
1456 assert_eq!(
1457 explore
1458 .permissions
1459 .check("write", &serde_json::json!({"file_path": "x"})),
1460 PermissionDecision::Deny
1461 );
1462 assert_eq!(
1463 general
1464 .permissions
1465 .check("parallel_task", &serde_json::json!({})),
1466 PermissionDecision::Deny
1467 );
1468 for agent in [verification, review] {
1469 assert_eq!(
1470 agent
1471 .permissions
1472 .check("bash", &serde_json::json!({"command": "cargo test"})),
1473 PermissionDecision::Allow,
1474 "{} should allow runtime checks",
1475 agent.name
1476 );
1477 assert_eq!(
1478 agent
1479 .permissions
1480 .check("web_search", &serde_json::json!({"query": "a3s"})),
1481 PermissionDecision::Allow,
1482 "{} should allow evidence search",
1483 agent.name
1484 );
1485 assert_eq!(
1486 agent.permissions.check(
1487 "web_fetch",
1488 &serde_json::json!({"url": "https://example.com"})
1489 ),
1490 PermissionDecision::Allow,
1491 "{} should allow source fetches",
1492 agent.name
1493 );
1494 assert_eq!(
1495 agent
1496 .permissions
1497 .check("write", &serde_json::json!({"file_path": "x"})),
1498 PermissionDecision::Deny,
1499 "{} should stay read-only for workspace writes",
1500 agent.name
1501 );
1502 assert_eq!(
1503 agent
1504 .permissions
1505 .check("parallel_task", &serde_json::json!({})),
1506 PermissionDecision::Deny,
1507 "{} should not recurse into more subagents",
1508 agent.name
1509 );
1510 }
1511 }
1512
1513 #[test]
1514 fn test_parse_worker_agent_yaml_uses_cattle_defaults() {
1515 let yaml = r#"
1516name: frontend-fixer
1517description: Disposable frontend implementer
1518kind: implementer
1519max_steps: 7
1520"#;
1521 let agent = parse_agent_yaml(yaml).unwrap();
1522
1523 assert_eq!(agent.name, "frontend-fixer");
1524 assert_eq!(agent.max_steps, Some(7));
1525 assert!(agent
1526 .permissions
1527 .allow
1528 .iter()
1529 .any(|r| r.matches("write", &serde_json::json!({}))));
1530 assert!(agent
1531 .permissions
1532 .deny
1533 .iter()
1534 .any(|r| r.matches("task", &serde_json::json!({}))));
1535 }
1536
1537 #[test]
1538 fn test_parse_agent_yaml_missing_name() {
1539 let yaml = r#"
1540description: Agent without name
1541"#;
1542 let result = parse_agent_yaml(yaml);
1543 assert!(result.is_err());
1544 }
1545
1546 #[test]
1547 fn test_parse_agent_md() {
1548 let md = r#"---
1549name: md-agent
1550description: Agent from markdown
1551max_steps: 15
1552---
1553# System Prompt
1554
1555You are a helpful agent.
1556Do your best work.
1557"#;
1558 let agent = parse_agent_md(md).unwrap();
1559 assert_eq!(agent.name, "md-agent");
1560 assert_eq!(agent.description, "Agent from markdown");
1561 assert_eq!(agent.max_steps, Some(15));
1562 assert!(agent.prompt.is_some());
1563 assert!(agent.prompt.unwrap().contains("helpful agent"));
1564 }
1565
1566 #[test]
1567 fn test_parse_agent_md_with_prompt_in_frontmatter() {
1568 let md = r#"---
1569name: prompt-agent
1570description: Agent with prompt in frontmatter
1571prompt: "Frontmatter prompt"
1572---
1573Body content that should be ignored
1574"#;
1575 let agent = parse_agent_md(md).unwrap();
1576 assert_eq!(agent.prompt.unwrap(), "Frontmatter prompt");
1577 }
1578
1579 #[test]
1580 fn test_parse_worker_agent_md_uses_body_prompt() {
1581 let md = r#"---
1582name: review-cow
1583description: Disposable review worker
1584kind: reviewer
1585---
1586Review only the staged diff and return prioritized findings.
1587"#;
1588 let agent = parse_agent_md(md).unwrap();
1589
1590 assert_eq!(agent.name, "review-cow");
1591 assert_eq!(
1592 agent.prompt.as_deref(),
1593 Some("Review only the staged diff and return prioritized findings.")
1594 );
1595 assert!(agent
1596 .permissions
1597 .deny
1598 .iter()
1599 .any(|r| r.matches("write", &serde_json::json!({}))));
1600 }
1601
1602 #[test]
1603 fn test_parse_agent_md_missing_frontmatter() {
1604 let md = "Just markdown without frontmatter";
1605 let result = parse_agent_md(md);
1606 assert!(result.is_err());
1607 }
1608
1609 #[test]
1610 fn test_load_agents_from_dir() {
1611 let temp_dir = tempfile::tempdir().unwrap();
1612
1613 std::fs::write(
1615 temp_dir.path().join("agent1.yaml"),
1616 r#"
1617name: yaml-agent
1618description: Agent from YAML file
1619"#,
1620 )
1621 .unwrap();
1622
1623 std::fs::write(
1625 temp_dir.path().join("agent2.md"),
1626 r#"---
1627name: md-agent
1628description: Agent from Markdown file
1629---
1630System prompt here
1631"#,
1632 )
1633 .unwrap();
1634
1635 std::fs::write(temp_dir.path().join("invalid.yaml"), "not: valid: yaml: [").unwrap();
1637
1638 std::fs::create_dir_all(temp_dir.path().join("nested")).unwrap();
1640 std::fs::write(
1641 temp_dir.path().join("nested").join("agent3.md"),
1642 r#"---
1643name: nested-agent
1644description: Agent from nested Markdown file
1645---
1646Nested prompt
1647"#,
1648 )
1649 .unwrap();
1650
1651 std::fs::write(temp_dir.path().join("readme.txt"), "Just a text file").unwrap();
1653
1654 let agents = load_agents_from_dir(temp_dir.path());
1655 assert_eq!(agents.len(), 3);
1656
1657 let names: Vec<&str> = agents.iter().map(|a| a.name.as_str()).collect();
1658 assert!(names.contains(&"yaml-agent"));
1659 assert!(names.contains(&"md-agent"));
1660 assert!(names.contains(&"nested-agent"));
1661 }
1662
1663 #[test]
1664 fn test_load_agents_from_nonexistent_dir() {
1665 let agents = load_agents_from_dir(std::path::Path::new("/nonexistent/dir"));
1666 assert!(agents.is_empty());
1667 }
1668
1669 #[test]
1670 fn test_registry_with_config() {
1671 let temp_dir = tempfile::tempdir().unwrap();
1672
1673 std::fs::write(
1675 temp_dir.path().join("custom.yaml"),
1676 r#"
1677name: custom-agent
1678description: Custom agent from config
1679"#,
1680 )
1681 .unwrap();
1682
1683 let config = CodeConfig::new().add_agent_dir(temp_dir.path());
1684 let registry = AgentRegistry::with_config(&config);
1685
1686 assert!(registry.exists("explore"));
1688 assert!(registry.exists("custom-agent"));
1689 assert_eq!(registry.len(), 7); }
1691
1692 #[test]
1693 fn test_agent_definition_with_model() {
1694 let model = ModelConfig {
1695 model: "claude-3-5-sonnet".to_string(),
1696 provider: Some("anthropic".to_string()),
1697 };
1698 let agent = AgentDefinition::new("test", "Test").with_model(model);
1699 assert!(agent.model.is_some());
1700 assert_eq!(agent.model.unwrap().provider, Some("anthropic".to_string()));
1701 }
1702
1703 #[test]
1704 fn test_model_config_from_model_ref() {
1705 let model = ModelConfig::from_model_ref("openai/gpt-4o");
1706 assert_eq!(model.provider.as_deref(), Some("openai"));
1707 assert_eq!(model.model, "gpt-4o");
1708 assert_eq!(model.model_ref(), "openai/gpt-4o");
1709
1710 let inherited = ModelConfig::from_model_ref("claude-sonnet");
1711 assert_eq!(inherited.provider, None);
1712 assert_eq!(inherited.model_ref(), "claude-sonnet");
1713 }
1714
1715 #[test]
1716 fn test_worker_agent_kind_from_str_accepts_aliases() {
1717 assert_eq!(
1718 "explore".parse::<WorkerAgentKind>().unwrap(),
1719 WorkerAgentKind::ReadOnly
1720 );
1721 assert_eq!(
1722 "general".parse::<WorkerAgentKind>().unwrap(),
1723 WorkerAgentKind::Implementer
1724 );
1725 assert!("unknown".parse::<WorkerAgentKind>().is_err());
1726 }
1727
1728 #[test]
1729 fn worker_spec_implementer_creates_cattle_agent_definition() {
1730 let agent = WorkerAgentSpec::implementer("frontend-fixer", "Fix frontend issues")
1731 .with_prompt("Focus on small, verified patches.")
1732 .with_provider_model("anthropic", "claude-sonnet")
1733 .with_max_steps(12)
1734 .into_agent_definition();
1735
1736 assert_eq!(agent.name, "frontend-fixer");
1737 assert_eq!(agent.max_steps, Some(12));
1738 assert_eq!(
1739 agent.prompt.as_deref(),
1740 Some("Focus on small, verified patches.")
1741 );
1742 assert_eq!(agent.model.unwrap().provider.as_deref(), Some("anthropic"));
1743 assert!(agent
1744 .permissions
1745 .allow
1746 .iter()
1747 .any(|r| r.matches("write", &serde_json::json!({}))));
1748 assert!(agent
1749 .permissions
1750 .deny
1751 .iter()
1752 .any(|r| r.matches("task", &serde_json::json!({}))));
1753 }
1754
1755 #[test]
1756 fn worker_spec_read_only_uses_safe_defaults() {
1757 let agent = WorkerAgentSpec::read_only("scanner", "Scan repository")
1758 .hidden(true)
1759 .into_agent_definition();
1760
1761 assert!(agent.hidden);
1762 assert_eq!(agent.max_steps, Some(20));
1763 assert!(agent.prompt.is_some());
1764 assert!(agent
1765 .permissions
1766 .allow
1767 .iter()
1768 .any(|r| r.matches("read", &serde_json::json!({}))));
1769 assert!(agent
1770 .permissions
1771 .deny
1772 .iter()
1773 .any(|r| r.matches("write", &serde_json::json!({}))));
1774 }
1775
1776 #[test]
1777 fn registry_register_worker_returns_and_stores_definition() {
1778 let registry = AgentRegistry::new();
1779 let agent =
1780 registry.register_worker(WorkerAgentSpec::custom("strict-worker", "Strict worker"));
1781
1782 assert_eq!(agent.name, "strict-worker");
1783 assert!(registry.exists("strict-worker"));
1784 assert_eq!(
1785 agent
1786 .permissions
1787 .check("bash", &serde_json::json!({"command":"echo hi"})),
1788 crate::permissions::PermissionDecision::Ask
1789 );
1790 }
1791
1792 #[test]
1793 fn registry_register_workers_batches_cattle_specs() {
1794 let registry = AgentRegistry::new();
1795 let agents = registry.register_workers([
1796 WorkerAgentSpec::planner("planner-cow", "Plan work"),
1797 WorkerAgentSpec::verifier("verify-cow", "Verify work"),
1798 ]);
1799
1800 assert_eq!(agents.len(), 2);
1801 assert!(registry.exists("planner-cow"));
1802 assert!(registry.exists("verify-cow"));
1803 }
1804
1805 #[test]
1806 fn test_agent_registry_default() {
1807 let registry = AgentRegistry::default();
1808 assert!(!registry.is_empty());
1809 assert_eq!(registry.len(), 6);
1810 }
1811
1812 #[test]
1813 fn test_agent_registry_is_empty() {
1814 let registry = AgentRegistry {
1815 agents: RwLock::new(HashMap::new()),
1816 };
1817 assert!(registry.is_empty());
1818 assert_eq!(registry.len(), 0);
1819 }
1820
1821 #[test]
1822 fn test_apply_to_sets_permissions() {
1823 use crate::agent::AgentConfig;
1824 use crate::permissions::PermissionDecision;
1825
1826 let def = AgentDefinition::new("writer", "Write files")
1827 .with_permissions(PermissionPolicy::new().allow("write(*)"));
1828
1829 let mut config = AgentConfig::default();
1830 assert!(config.permission_checker.is_none());
1831
1832 def.apply_to(&mut config);
1833
1834 assert!(config.permission_checker.is_some());
1835 assert!(config.permission_policy.is_some());
1836 let checker = config.permission_checker.unwrap();
1837 assert_eq!(
1838 checker.check(
1839 "write",
1840 &serde_json::json!({"file_path": "x.txt", "content": "hi"})
1841 ),
1842 PermissionDecision::Allow
1843 );
1844 }
1845
1846 #[test]
1847 fn test_apply_to_sets_prompt() {
1848 use crate::agent::AgentConfig;
1849
1850 let def = AgentDefinition::new("helper", "Help").with_prompt("Be helpful.");
1851 let mut config = AgentConfig::default();
1852
1853 def.apply_to(&mut config);
1854
1855 assert_eq!(config.prompt_slots.extra.as_deref(), Some("Be helpful."));
1856 }
1857
1858 #[test]
1859 fn test_apply_to_sets_max_steps() {
1860 use crate::agent::AgentConfig;
1861
1862 let def = AgentDefinition::new("fast", "Fast agent").with_max_steps(7);
1863 let mut config = AgentConfig::default();
1864
1865 def.apply_to(&mut config);
1866
1867 assert_eq!(config.max_tool_rounds, 7);
1868 }
1869
1870 #[test]
1871 fn test_apply_to_respects_host_overrides() {
1872 use crate::agent::AgentConfig;
1873
1874 let def = AgentDefinition::new("agent", "Agent")
1875 .with_permissions(PermissionPolicy::new().allow("bash(*)"))
1876 .with_prompt("Agent prompt.")
1877 .with_max_steps(10);
1878
1879 let mut config = AgentConfig::default();
1880 config.prompt_slots.extra = Some("Host prompt.".to_string());
1881 config.max_tool_rounds = 25;
1882 config.permission_checker = Some(std::sync::Arc::new(PermissionPolicy::new().allow("*")));
1883
1884 def.apply_to(&mut config);
1885
1886 assert_eq!(config.prompt_slots.extra.as_deref(), Some("Host prompt."));
1888 assert_eq!(config.max_tool_rounds, 25);
1889 }
1890
1891 #[test]
1892 fn test_apply_to_skips_empty_permissions() {
1893 use crate::agent::AgentConfig;
1894
1895 let def = AgentDefinition::new("empty", "No permissions");
1896 let mut config = AgentConfig::default();
1897
1898 def.apply_to(&mut config);
1899
1900 assert!(config.permission_checker.is_none());
1901 assert!(config.permission_policy.is_none());
1902 }
1903}