Skip to main content

a3s_code_core/
subagent.rs

1//! Delegated Agent System
2//!
3//! Provides a system for delegating specialized tasks to focused child agents.
4//! Each delegated child run uses an isolated child session with restricted permissions.
5//!
6//! ## Architecture
7//!
8//! ```text
9//! Parent Session
10//!   └── Task Tool
11//!         ├── AgentRegistry (lookup agent definitions)
12//!         └── Child Session (isolated execution)
13//!               ├── Restricted permissions
14//!               ├── Optional model override
15//!               └── Event forwarding to parent
16//! ```
17//!
18//! ## Built-in Agents
19//!
20//! - `explore`: Fast codebase exploration (read-only)
21//! - `general`: Multi-step task execution
22//! - `plan`: Read-only planning mode
23//! - `verification`: Adversarial verification specialist
24//! - `review`: Code review specialist
25//!
26//! ## Loading Agents from Files
27//!
28//! Agents can be loaded from YAML or Markdown files:
29//!
30//! ### YAML Format
31//! ```yaml
32//! name: my-agent
33//! description: Custom agent for specific tasks
34//! hidden: false
35//! max_steps: 30
36//! permissions:
37//!   allow:
38//!     - read
39//!     - grep
40//!   deny:
41//!     - write
42//! prompt: |
43//!   You are a specialized agent...
44//! ```
45//!
46//! ### Markdown Format
47//! ```markdown
48//! ---
49//! name: my-agent
50//! description: Custom agent
51//! max_steps: 30
52//! ---
53//! # System Prompt
54//! You are a specialized agent...
55//! ```
56
57use crate::config::CodeConfig;
58use crate::permissions::{PermissionChecker, PermissionDecision, 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/// How a child run resolves tools that require confirmation (PermissionDecision::Ask).
67#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
68#[serde(rename_all = "snake_case")]
69pub enum ConfirmationInheritance {
70    /// Auto-approve all Ask decisions. Safe when the agent has explicit allow-list
71    /// permissions that already define the access boundary.
72    #[default]
73    AutoApprove,
74    /// Deny all Ask decisions (strict mode — only explicitly allowed tools run).
75    DenyOnAsk,
76    /// Inherit the parent session's confirmation manager.
77    InheritParent,
78}
79
80/// Model configuration for agent.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct ModelConfig {
83    /// Model identifier (e.g., "claude-3-5-sonnet-20241022")
84    pub model: String,
85    /// Optional provider override
86    pub provider: Option<String>,
87}
88
89impl ModelConfig {
90    /// Create a model override that inherits the default provider.
91    pub fn new(model: impl Into<String>) -> Self {
92        Self {
93            model: model.into(),
94            provider: None,
95        }
96    }
97
98    /// Create a model override from provider and model parts.
99    pub fn with_provider(provider: impl Into<String>, model: impl Into<String>) -> Self {
100        Self {
101            model: model.into(),
102            provider: Some(provider.into()),
103        }
104    }
105
106    /// Parse a conventional `provider/model` reference.
107    ///
108    /// If no slash is present, the provider stays unset and the session binding
109    /// falls back to the host agent's default provider behavior.
110    pub fn from_model_ref(model_ref: impl AsRef<str>) -> Self {
111        let model_ref = model_ref.as_ref();
112        if let Some((provider, model)) = model_ref.split_once('/') {
113            Self::with_provider(provider, model)
114        } else {
115            Self::new(model_ref)
116        }
117    }
118
119    /// Return the model as `provider/model` when a provider is set.
120    pub fn model_ref(&self) -> String {
121        match &self.provider {
122            Some(provider) => format!("{}/{}", provider, self.model),
123            None => self.model.clone(),
124        }
125    }
126}
127
128/// Cattle-style worker agent role.
129///
130/// A worker role is a reproducible preset for disposable, task-scoped agents.
131/// Use [`WorkerAgentSpec`] to create many consistent workers instead of hand-tuning
132/// unique "pet" agents one by one.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "snake_case")]
135pub enum WorkerAgentKind {
136    /// Read-only exploration: fast code search and inspection.
137    #[serde(alias = "readonly", alias = "read-only", alias = "explore")]
138    ReadOnly,
139    /// Read-only planning: design work without modifying the workspace.
140    #[serde(alias = "plan")]
141    Planner,
142    /// Implementation work: read, edit, write, and run commands; no recursive task spawning.
143    #[serde(alias = "implementation", alias = "general")]
144    Implementer,
145    /// Verification work: run checks and inspect failures without editing files.
146    #[serde(alias = "verification", alias = "verify")]
147    Verifier,
148    /// Review work: inspect changes and report findings.
149    #[serde(alias = "review", alias = "code-review")]
150    Reviewer,
151    /// Strict custom worker: asks for any unspecified tool until permissions are supplied.
152    Custom,
153}
154
155/// Reproducible recipe for a disposable worker/subagent.
156///
157/// This is the public "cattle mode" API: callers define a small, serializable
158/// worker spec and register/spawn it repeatedly. The spec compiles to an
159/// [`AgentDefinition`] consumed by the existing delegation/runtime pipeline.
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct WorkerAgentSpec {
162    /// Stable worker name used in task delegation (e.g., "frontend-fixer").
163    pub name: String,
164    /// Human-readable purpose shown to users and model selectors.
165    pub description: String,
166    /// Preset permission/prompt/step profile.
167    pub kind: WorkerAgentKind,
168    /// Hide from UI lists while still allowing explicit delegation.
169    #[serde(default)]
170    pub hidden: bool,
171    /// Optional permission policy override.
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub permissions: Option<PermissionPolicy>,
174    /// Optional model override.
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub model: Option<ModelConfig>,
177    /// Optional worker-specific prompt appended to the core agentic prompt.
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub prompt: Option<String>,
180    /// Maximum execution steps/tool rounds.
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub max_steps: Option<usize>,
183    /// How child runs resolve Ask decisions.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub confirmation_inheritance: Option<ConfirmationInheritance>,
186}
187
188impl WorkerAgentKind {
189    /// Stable snake_case identifier for this role.
190    pub fn as_str(self) -> &'static str {
191        match self {
192            Self::ReadOnly => "read_only",
193            Self::Planner => "planner",
194            Self::Implementer => "implementer",
195            Self::Verifier => "verifier",
196            Self::Reviewer => "reviewer",
197            Self::Custom => "custom",
198        }
199    }
200
201    fn default_permissions(self) -> PermissionPolicy {
202        match self {
203            Self::ReadOnly => explore_permissions(),
204            Self::Planner => plan_permissions(),
205            Self::Implementer => general_permissions(),
206            Self::Verifier => verification_permissions(),
207            Self::Reviewer => review_permissions(),
208            Self::Custom => PermissionPolicy::strict(),
209        }
210    }
211
212    fn default_prompt(self) -> Option<&'static str> {
213        match self {
214            Self::ReadOnly => Some(EXPLORE_PROMPT),
215            Self::Planner => Some(PLAN_PROMPT),
216            Self::Verifier => Some(VERIFICATION_PROMPT),
217            Self::Reviewer => Some(REVIEW_PROMPT),
218            Self::Implementer | Self::Custom => None,
219        }
220    }
221
222    fn default_max_steps(self) -> usize {
223        match self {
224            Self::ReadOnly => 20,
225            Self::Planner => 30,
226            Self::Implementer => 50,
227            Self::Verifier => 30,
228            Self::Reviewer => 25,
229            Self::Custom => 30,
230        }
231    }
232}
233
234impl std::fmt::Display for WorkerAgentKind {
235    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236        f.write_str(self.as_str())
237    }
238}
239
240impl std::str::FromStr for WorkerAgentKind {
241    type Err = anyhow::Error;
242
243    fn from_str(value: &str) -> anyhow::Result<Self> {
244        match value.trim().to_ascii_lowercase().as_str() {
245            "read_only" | "readonly" | "read-only" | "explore" | "scanner" => Ok(Self::ReadOnly),
246            "planner" | "plan" => Ok(Self::Planner),
247            "implementer" | "implementation" | "general" | "executor" => Ok(Self::Implementer),
248            "verifier" | "verification" | "verify" | "tester" => Ok(Self::Verifier),
249            "reviewer" | "review" | "code-review" | "code_reviewer" => Ok(Self::Reviewer),
250            "custom" => Ok(Self::Custom),
251            other => Err(anyhow::anyhow!("unknown worker agent kind '{}'", other)),
252        }
253    }
254}
255
256/// Backward-friendly alias for callers that name this pattern cattle mode.
257pub type CattleAgentKind = WorkerAgentKind;
258/// Backward-friendly alias for callers that name this pattern cattle mode.
259pub type CattleAgentSpec = WorkerAgentSpec;
260
261impl WorkerAgentSpec {
262    /// Create a worker spec from an explicit preset.
263    pub fn new(
264        kind: WorkerAgentKind,
265        name: impl Into<String>,
266        description: impl Into<String>,
267    ) -> Self {
268        Self {
269            name: name.into(),
270            description: description.into(),
271            kind,
272            hidden: false,
273            permissions: None,
274            model: None,
275            prompt: None,
276            max_steps: None,
277            confirmation_inheritance: None,
278        }
279    }
280
281    /// Read-only exploration worker.
282    pub fn read_only(name: impl Into<String>, description: impl Into<String>) -> Self {
283        Self::new(WorkerAgentKind::ReadOnly, name, description)
284    }
285
286    /// Read-only planning worker.
287    pub fn planner(name: impl Into<String>, description: impl Into<String>) -> Self {
288        Self::new(WorkerAgentKind::Planner, name, description)
289    }
290
291    /// Implementation worker with read/write/bash capability and no recursive task spawning.
292    pub fn implementer(name: impl Into<String>, description: impl Into<String>) -> Self {
293        Self::new(WorkerAgentKind::Implementer, name, description)
294    }
295
296    /// Verification worker for tests/checks/reproductions without edits.
297    pub fn verifier(name: impl Into<String>, description: impl Into<String>) -> Self {
298        Self::new(WorkerAgentKind::Verifier, name, description)
299    }
300
301    /// Review worker for correctness/regression/security findings.
302    pub fn reviewer(name: impl Into<String>, description: impl Into<String>) -> Self {
303        Self::new(WorkerAgentKind::Reviewer, name, description)
304    }
305
306    /// Strict custom worker. Provide permissions explicitly for non-HITL execution.
307    pub fn custom(name: impl Into<String>, description: impl Into<String>) -> Self {
308        Self::new(WorkerAgentKind::Custom, name, description)
309    }
310
311    /// Hide or show this worker in UI lists.
312    pub fn hidden(mut self, hidden: bool) -> Self {
313        self.hidden = hidden;
314        self
315    }
316
317    /// Override the preset permission policy.
318    pub fn with_permissions(mut self, permissions: PermissionPolicy) -> Self {
319        self.permissions = Some(permissions);
320        self
321    }
322
323    /// Override the preset model.
324    pub fn with_model(mut self, model: ModelConfig) -> Self {
325        self.model = Some(model);
326        self
327    }
328
329    /// Override the preset model using `provider/model` or a model id.
330    pub fn with_model_ref(mut self, model_ref: impl AsRef<str>) -> Self {
331        self.model = Some(ModelConfig::from_model_ref(model_ref));
332        self
333    }
334
335    /// Override the preset model using provider and model separately.
336    pub fn with_provider_model(
337        mut self,
338        provider: impl Into<String>,
339        model: impl Into<String>,
340    ) -> Self {
341        self.model = Some(ModelConfig::with_provider(provider, model));
342        self
343    }
344
345    /// Override the preset prompt.
346    pub fn with_prompt(mut self, prompt: impl Into<String>) -> Self {
347        self.prompt = Some(prompt.into());
348        self
349    }
350
351    /// Override the preset step budget.
352    pub fn with_max_steps(mut self, max_steps: usize) -> Self {
353        self.max_steps = Some(max_steps);
354        self
355    }
356
357    /// Set confirmation inheritance policy for child runs.
358    pub fn with_confirmation(mut self, inheritance: ConfirmationInheritance) -> Self {
359        self.confirmation_inheritance = Some(inheritance);
360        self
361    }
362
363    /// Compile this worker recipe into a runtime agent definition.
364    pub fn into_agent_definition(self) -> AgentDefinition {
365        let mut agent = AgentDefinition::new(&self.name, &self.description)
366            .with_permissions(
367                self.permissions
368                    .unwrap_or_else(|| self.kind.default_permissions()),
369            )
370            .with_max_steps(
371                self.max_steps
372                    .unwrap_or_else(|| self.kind.default_max_steps()),
373            );
374
375        if self.hidden {
376            agent = agent.hidden();
377        }
378        if let Some(model) = self.model {
379            agent = agent.with_model(model);
380        }
381        if let Some(prompt) = self
382            .prompt
383            .or_else(|| self.kind.default_prompt().map(str::to_string))
384        {
385            agent = agent.with_prompt(&prompt);
386        }
387        if let Some(ci) = self.confirmation_inheritance {
388            agent = agent.with_confirmation(ci);
389        }
390        agent
391    }
392}
393
394impl From<WorkerAgentSpec> for AgentDefinition {
395    fn from(spec: WorkerAgentSpec) -> Self {
396        spec.into_agent_definition()
397    }
398}
399
400/// Agent definition
401///
402/// Defines the configuration and capabilities of an agent type.
403#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct AgentDefinition {
405    /// Agent identifier (e.g., "explore", "plan", "general")
406    pub name: String,
407    /// Description of what the agent does
408    pub description: String,
409    /// Whether this is a built-in agent
410    #[serde(default)]
411    pub native: bool,
412    /// Whether to hide from UI
413    #[serde(default)]
414    pub hidden: bool,
415    /// Permission rules for this agent
416    #[serde(default)]
417    pub permissions: PermissionPolicy,
418    /// Optional model override
419    #[serde(skip_serializing_if = "Option::is_none")]
420    pub model: Option<ModelConfig>,
421    /// System prompt for this agent
422    #[serde(skip_serializing_if = "Option::is_none")]
423    pub prompt: Option<String>,
424    /// Maximum execution steps (tool rounds)
425    #[serde(skip_serializing_if = "Option::is_none")]
426    pub max_steps: Option<usize>,
427    /// How child runs resolve Ask decisions. Default: AutoApprove when
428    /// the agent has explicit allow rules, DenyOnAsk otherwise.
429    #[serde(default, skip_serializing_if = "Option::is_none")]
430    pub confirmation_inheritance: Option<ConfirmationInheritance>,
431}
432
433impl AgentDefinition {
434    /// Create a new agent definition
435    pub fn new(name: &str, description: &str) -> Self {
436        Self {
437            name: name.to_string(),
438            description: description.to_string(),
439            native: false,
440            hidden: false,
441            permissions: PermissionPolicy::default(),
442            model: None,
443            prompt: None,
444            max_steps: None,
445            confirmation_inheritance: None,
446        }
447    }
448
449    /// Create an agent definition from a disposable worker recipe.
450    pub fn worker(spec: WorkerAgentSpec) -> Self {
451        spec.into_agent_definition()
452    }
453
454    /// Mark as native (built-in)
455    pub fn native(mut self) -> Self {
456        self.native = true;
457        self
458    }
459
460    /// Mark as hidden from UI
461    pub fn hidden(mut self) -> Self {
462        self.hidden = true;
463        self
464    }
465
466    /// Set permission policy
467    pub fn with_permissions(mut self, permissions: PermissionPolicy) -> Self {
468        self.permissions = permissions;
469        self
470    }
471
472    /// Set model override
473    pub fn with_model(mut self, model: ModelConfig) -> Self {
474        self.model = Some(model);
475        self
476    }
477
478    /// Set system prompt
479    pub fn with_prompt(mut self, prompt: &str) -> Self {
480        self.prompt = Some(prompt.to_string());
481        self
482    }
483
484    /// Set maximum execution steps
485    pub fn with_max_steps(mut self, max_steps: usize) -> Self {
486        self.max_steps = Some(max_steps);
487        self
488    }
489
490    /// Whether this definition has non-empty permission rules.
491    pub fn has_defined_permissions(&self) -> bool {
492        !self.permissions.allow.is_empty() || !self.permissions.deny.is_empty()
493    }
494
495    /// Apply this definition's declared configuration to a mutable AgentConfig.
496    ///
497    /// Follows the "host overrides win" principle: only fills fields that are
498    /// currently at their default/None state. Callers who want to force values
499    /// should set them *after* calling `apply_to`.
500    pub(crate) fn apply_to(&self, config: &mut crate::agent::AgentConfig) {
501        use std::sync::Arc;
502
503        if config.permission_checker.is_none() && self.has_defined_permissions() {
504            config.permission_checker =
505                Some(Arc::new(self.permissions.clone()) as Arc<dyn PermissionChecker>);
506            config.permission_policy = Some(self.permissions.clone());
507        }
508
509        if let Some(ref prompt) = self.prompt {
510            if config.prompt_slots.extra.is_none() {
511                config.prompt_slots.extra = Some(prompt.clone());
512            }
513        }
514
515        if let Some(max_steps) = self.max_steps {
516            if config.max_tool_rounds == crate::agent::MAX_TOOL_ROUNDS {
517                config.max_tool_rounds = max_steps;
518            }
519        }
520
521        // Confirmation inheritance: resolve Ask decisions in child runs.
522        if config.confirmation_manager.is_none() {
523            let inheritance = self.confirmation_inheritance.clone().unwrap_or_else(|| {
524                if self.has_defined_permissions() {
525                    ConfirmationInheritance::AutoApprove
526                } else {
527                    ConfirmationInheritance::DenyOnAsk
528                }
529            });
530            match inheritance {
531                ConfirmationInheritance::AutoApprove => {
532                    config.confirmation_manager =
533                        Some(Arc::new(crate::hitl::AutoApproveConfirmation));
534                }
535                ConfirmationInheritance::DenyOnAsk => { /* leave None — safety_gate denies */ }
536                ConfirmationInheritance::InheritParent => { /* caller passes parent's manager */ }
537            }
538        }
539    }
540
541    /// Set confirmation inheritance policy for child runs.
542    pub fn with_confirmation(mut self, inheritance: ConfirmationInheritance) -> Self {
543        self.confirmation_inheritance = Some(inheritance);
544        self
545    }
546}
547
548/// Agent registry for managing agent definitions
549///
550/// Thread-safe registry that stores agent definitions and provides
551/// lookup functionality.
552pub struct AgentRegistry {
553    agents: RwLock<HashMap<String, AgentDefinition>>,
554}
555
556fn canonical_agent_name(name: &str) -> &str {
557    match name.trim() {
558        "general-purpose" | "general_purpose" | "generalpurpose" => "general",
559        "verify" | "verifier" => "verification",
560        "code-review" | "code_reviewer" | "reviewer" => "review",
561        other => other,
562    }
563}
564
565impl Default for AgentRegistry {
566    fn default() -> Self {
567        Self::new()
568    }
569}
570
571impl AgentRegistry {
572    /// Create a new agent registry with built-in agents
573    pub fn new() -> Self {
574        let registry = Self {
575            agents: RwLock::new(HashMap::new()),
576        };
577
578        // Register built-in agents
579        for agent in builtin_agents() {
580            registry.register(agent);
581        }
582
583        registry
584    }
585
586    /// Create a new agent registry with configuration
587    ///
588    /// Loads built-in agents first, then loads agents from configured directories.
589    pub fn with_config(config: &CodeConfig) -> Self {
590        let registry = Self::new();
591
592        // Load agents from configured directories
593        for dir in &config.agent_dirs {
594            let agents = load_agents_from_dir(dir);
595            for agent in agents {
596                tracing::info!("Loaded agent '{}' from {}", agent.name, dir.display());
597                registry.register(agent);
598            }
599        }
600
601        registry
602    }
603
604    /// Register an agent definition
605    pub fn register(&self, agent: AgentDefinition) {
606        let mut agents = write_or_recover(&self.agents);
607        tracing::debug!("Registering agent: {}", agent.name);
608        agents.insert(agent.name.clone(), agent);
609    }
610
611    /// Register a disposable worker agent from a reproducible spec.
612    ///
613    /// Returns the compiled [`AgentDefinition`] so callers can inspect or pass it
614    /// directly to `session_for_agent`.
615    pub fn register_worker(&self, spec: WorkerAgentSpec) -> AgentDefinition {
616        let agent = spec.into_agent_definition();
617        self.register(agent.clone());
618        agent
619    }
620
621    /// Register multiple disposable worker agents and return their definitions.
622    pub fn register_workers<I>(&self, specs: I) -> Vec<AgentDefinition>
623    where
624        I: IntoIterator<Item = WorkerAgentSpec>,
625    {
626        specs
627            .into_iter()
628            .map(|spec| self.register_worker(spec))
629            .collect()
630    }
631
632    /// Unregister an agent by name
633    ///
634    /// Returns true if the agent was removed, false if not found.
635    pub fn unregister(&self, name: &str) -> bool {
636        let mut agents = write_or_recover(&self.agents);
637        agents.remove(name).is_some()
638    }
639
640    /// Get an agent definition by name
641    pub fn get(&self, name: &str) -> Option<AgentDefinition> {
642        let agents = read_or_recover(&self.agents);
643        agents
644            .get(name)
645            .or_else(|| agents.get(canonical_agent_name(name)))
646            .cloned()
647    }
648
649    /// List all registered agents
650    pub fn list(&self) -> Vec<AgentDefinition> {
651        let agents = read_or_recover(&self.agents);
652        agents.values().cloned().collect()
653    }
654
655    /// List visible agents (not hidden)
656    pub fn list_visible(&self) -> Vec<AgentDefinition> {
657        let agents = read_or_recover(&self.agents);
658        agents.values().filter(|a| !a.hidden).cloned().collect()
659    }
660
661    /// Check if an agent exists
662    pub fn exists(&self, name: &str) -> bool {
663        let agents = read_or_recover(&self.agents);
664        agents.contains_key(name) || agents.contains_key(canonical_agent_name(name))
665    }
666
667    /// Get the number of registered agents
668    pub fn len(&self) -> usize {
669        let agents = read_or_recover(&self.agents);
670        agents.len()
671    }
672
673    /// Check if the registry is empty
674    pub fn is_empty(&self) -> bool {
675        self.len() == 0
676    }
677}
678
679// ============================================================================
680// Agent File Loading
681// ============================================================================
682
683/// Parse an agent definition from YAML content.
684///
685/// The YAML can describe either a full [`AgentDefinition`] or a cattle-style
686/// [`WorkerAgentSpec`] by including a `kind` field.
687pub fn parse_agent_yaml(content: &str) -> anyhow::Result<AgentDefinition> {
688    let value: serde_yaml::Value = serde_yaml::from_str(content)
689        .map_err(|e| anyhow::anyhow!("Failed to parse agent YAML: {}", e))?;
690
691    parse_agent_yaml_value(value, "agent YAML")
692}
693
694fn parse_agent_yaml_value(
695    value: serde_yaml::Value,
696    context: &str,
697) -> anyhow::Result<AgentDefinition> {
698    let tools = yaml_get_any(&value, &["tools", "allowedTools", "allowed_tools"])
699        .map(parse_tools_field)
700        .unwrap_or_default();
701    let disallowed_tools = yaml_get_any(
702        &value,
703        &["disallowedTools", "disallowed-tools", "disallowed_tools"],
704    )
705    .map(parse_tools_field)
706    .unwrap_or_default();
707
708    if yaml_value_has_key(&value, "kind") {
709        let mut spec: WorkerAgentSpec = serde_yaml::from_value(value)
710            .map_err(|e| anyhow::anyhow!("Failed to parse worker {}: {}", context, e))?;
711        validate_agent_name(&spec.name)?;
712        apply_claude_style_tools_to_spec(&mut spec, &tools, &disallowed_tools);
713        return Ok(spec.into_agent_definition());
714    }
715
716    let mut agent: AgentDefinition = serde_yaml::from_value(value)
717        .map_err(|e| anyhow::anyhow!("Failed to parse {}: {}", context, e))?;
718    validate_agent_name(&agent.name)?;
719    apply_claude_style_tools_to_agent(&mut agent, &tools, &disallowed_tools);
720    Ok(agent)
721}
722
723fn apply_claude_style_tools_to_agent(
724    agent: &mut AgentDefinition,
725    tools: &[String],
726    disallowed_tools: &[String],
727) {
728    if !tools.is_empty() {
729        agent.permissions = allow_only_permission_policy(tools);
730    }
731    if !disallowed_tools.is_empty() {
732        let base = std::mem::take(&mut agent.permissions);
733        agent.permissions = add_denied_tools(base, disallowed_tools);
734    }
735    if (!tools.is_empty() || !disallowed_tools.is_empty())
736        && agent.confirmation_inheritance.is_none()
737    {
738        agent.confirmation_inheritance = Some(ConfirmationInheritance::AutoApprove);
739    }
740}
741
742fn apply_claude_style_tools_to_spec(
743    spec: &mut WorkerAgentSpec,
744    tools: &[String],
745    disallowed_tools: &[String],
746) {
747    if tools.is_empty() && disallowed_tools.is_empty() {
748        return;
749    }
750
751    let base = if tools.is_empty() {
752        spec.permissions
753            .clone()
754            .unwrap_or_else(|| spec.kind.default_permissions())
755    } else {
756        allow_only_permission_policy(tools)
757    };
758    spec.permissions = Some(add_denied_tools(base, disallowed_tools));
759    if spec.confirmation_inheritance.is_none() {
760        spec.confirmation_inheritance = Some(ConfirmationInheritance::AutoApprove);
761    }
762}
763
764fn parse_worker_yaml_value(
765    value: serde_yaml::Value,
766    context: &str,
767) -> anyhow::Result<WorkerAgentSpec> {
768    let spec: WorkerAgentSpec = serde_yaml::from_value(value)
769        .map_err(|e| anyhow::anyhow!("Failed to parse worker {}: {}", context, e))?;
770    validate_agent_name(&spec.name)?;
771    Ok(spec)
772}
773
774fn yaml_value_has_key(value: &serde_yaml::Value, key: &str) -> bool {
775    value
776        .as_mapping()
777        .map(|mapping| mapping.contains_key(serde_yaml::Value::String(key.to_string())))
778        .unwrap_or(false)
779}
780
781fn yaml_get<'a>(value: &'a serde_yaml::Value, key: &str) -> Option<&'a serde_yaml::Value> {
782    value
783        .as_mapping()
784        .and_then(|mapping| mapping.get(serde_yaml::Value::String(key.to_string())))
785}
786
787fn yaml_get_any<'a>(value: &'a serde_yaml::Value, keys: &[&str]) -> Option<&'a serde_yaml::Value> {
788    keys.iter().find_map(|key| yaml_get(value, key))
789}
790
791fn parse_tools_field(value: &serde_yaml::Value) -> Vec<String> {
792    match value {
793        serde_yaml::Value::String(raw) => raw
794            .split(',')
795            .map(str::trim)
796            .filter(|tool| !tool.is_empty())
797            .map(str::to_string)
798            .collect(),
799        serde_yaml::Value::Sequence(items) => items
800            .iter()
801            .filter_map(|item| item.as_str())
802            .map(str::trim)
803            .filter(|tool| !tool.is_empty())
804            .map(str::to_string)
805            .collect(),
806        _ => Vec::new(),
807    }
808}
809
810fn tool_name_to_permission(tool: &str) -> String {
811    let normalized = tool.trim();
812    match normalized.to_ascii_lowercase().as_str() {
813        "*" => "*".to_string(),
814        "read" => "read(*)".to_string(),
815        "write" => "write(*)".to_string(),
816        "edit" => "edit(*)".to_string(),
817        "grep" => "grep(*)".to_string(),
818        "glob" => "glob(*)".to_string(),
819        "ls" => "ls(*)".to_string(),
820        "bash" => "bash(*)".to_string(),
821        "task" => "task(*)".to_string(),
822        "parallel_task" | "parallel-task" => "parallel_task(*)".to_string(),
823        _ if normalized.contains('(') => normalized.to_string(),
824        _ => format!("{normalized}(*)"),
825    }
826}
827
828fn permission_policy_from_tools(tools: &[String]) -> PermissionPolicy {
829    tools.iter().fold(PermissionPolicy::new(), |policy, tool| {
830        policy.allow(&tool_name_to_permission(tool))
831    })
832}
833
834fn allow_only_permission_policy(tools: &[String]) -> PermissionPolicy {
835    let mut policy = permission_policy_from_tools(tools);
836    policy.default_decision = PermissionDecision::Deny;
837    policy
838}
839
840fn add_denied_tools(mut policy: PermissionPolicy, tools: &[String]) -> PermissionPolicy {
841    for tool in tools {
842        policy = policy.deny(&tool_name_to_permission(tool));
843    }
844    policy
845}
846
847fn validate_agent_name(name: &str) -> anyhow::Result<()> {
848    if name.trim().is_empty() {
849        return Err(anyhow::anyhow!("Agent name is required"));
850    }
851    Ok(())
852}
853
854/// Parse an agent definition from Markdown with YAML frontmatter
855///
856/// The frontmatter contains agent metadata, and the body becomes the prompt.
857pub fn parse_agent_md(content: &str) -> anyhow::Result<AgentDefinition> {
858    // Parse frontmatter (YAML between --- markers)
859    let parts: Vec<&str> = content.splitn(3, "---").collect();
860
861    if parts.len() < 3 {
862        return Err(anyhow::anyhow!(
863            "Invalid markdown format: missing YAML frontmatter"
864        ));
865    }
866
867    let frontmatter = parts[1].trim();
868    let body = parts[2].trim();
869
870    // Parse the frontmatter as YAML. A `kind` field selects WorkerAgentSpec.
871    let value: serde_yaml::Value = serde_yaml::from_str(frontmatter)
872        .map_err(|e| anyhow::anyhow!("Failed to parse agent frontmatter: {}", e))?;
873
874    if yaml_value_has_key(&value, "kind") {
875        let tools = yaml_get_any(&value, &["tools", "allowedTools", "allowed_tools"])
876            .map(parse_tools_field)
877            .unwrap_or_default();
878        let disallowed_tools = yaml_get_any(
879            &value,
880            &["disallowedTools", "disallowed-tools", "disallowed_tools"],
881        )
882        .map(parse_tools_field)
883        .unwrap_or_default();
884        let mut spec = parse_worker_yaml_value(value, "frontmatter")?;
885        if spec.prompt.is_none() && !body.is_empty() {
886            spec.prompt = Some(body.to_string());
887        }
888        apply_claude_style_tools_to_spec(&mut spec, &tools, &disallowed_tools);
889        return Ok(spec.into_agent_definition());
890    }
891
892    let mut agent = parse_agent_yaml_value(value, "agent frontmatter")?;
893
894    // Use body as prompt if not already set in frontmatter.
895    if agent.prompt.is_none() && !body.is_empty() {
896        agent.prompt = Some(body.to_string());
897    }
898
899    Ok(agent)
900}
901
902/// Load all agent definitions from a directory
903///
904/// Scans for *.yaml and *.md files and parses them as agent definitions.
905/// Invalid files are logged and skipped.
906pub fn load_agents_from_dir(dir: &Path) -> Vec<AgentDefinition> {
907    let mut agents = Vec::new();
908    load_agents_from_dir_inner(dir, &mut agents);
909    agents
910}
911
912fn load_agents_from_dir_inner(dir: &Path, agents: &mut Vec<AgentDefinition>) {
913    let Ok(entries) = std::fs::read_dir(dir) else {
914        tracing::warn!("Failed to read agent directory: {}", dir.display());
915        return;
916    };
917
918    for entry in entries.flatten() {
919        let path = entry.path();
920
921        if path.is_dir() {
922            load_agents_from_dir_inner(&path, agents);
923            continue;
924        }
925        if !path.is_file() {
926            continue;
927        }
928
929        let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
930            continue;
931        };
932
933        // Read file content
934        let Ok(content) = std::fs::read_to_string(&path) else {
935            tracing::warn!("Failed to read agent file: {}", path.display());
936            continue;
937        };
938
939        // Parse based on extension
940        let result = match ext {
941            "yaml" | "yml" => parse_agent_yaml(&content),
942            "md" => parse_agent_md(&content),
943            _ => continue,
944        };
945
946        match result {
947            Ok(agent) => {
948                tracing::debug!("Loaded agent '{}' from {}", agent.name, path.display());
949                agents.push(agent);
950            }
951            Err(e) => {
952                tracing::warn!("Failed to parse agent file {}: {}", path.display(), e);
953            }
954        }
955    }
956}
957
958/// Create built-in agent definitions
959pub fn builtin_agents() -> Vec<AgentDefinition> {
960    vec![
961        // Explore agent: Fast codebase exploration (read-only)
962        AgentDefinition::new(
963            "explore",
964            "Fast codebase exploration agent. Use for searching files, reading code, \
965             and understanding codebase structure. Read-only operations only.",
966        )
967        .native()
968        .with_permissions(explore_permissions())
969        .with_max_steps(20)
970        .with_prompt(EXPLORE_PROMPT),
971        // General agent: Multi-step task execution
972        AgentDefinition::new(
973            "general",
974            "General-purpose agent for multi-step task execution. Can read, write, \
975             and execute commands.",
976        )
977        .native()
978        .with_permissions(general_permissions())
979        .with_max_steps(50),
980        // Plan agent: Read-only planning mode
981        AgentDefinition::new(
982            "plan",
983            "Planning agent for designing implementation approaches. Read-only access \
984             to explore codebase and create plans.",
985        )
986        .native()
987        .with_permissions(plan_permissions())
988        .with_max_steps(30)
989        .with_prompt(PLAN_PROMPT),
990        // Verification agent: adversarial validation and repro
991        AgentDefinition::new(
992            "verification",
993            "Verification agent for adversarial validation. Prefer real checks, \
994             reproductions, and regression testing over code reading alone.",
995        )
996        .native()
997        .with_permissions(verification_permissions())
998        .with_max_steps(30)
999        .with_prompt(VERIFICATION_PROMPT),
1000        // Review agent: review-focused analysis
1001        AgentDefinition::new(
1002            "review",
1003            "Code review agent focused on correctness, regressions, security, \
1004             maintainability, and clear findings.",
1005        )
1006        .native()
1007        .with_permissions(review_permissions())
1008        .with_max_steps(25)
1009        .with_prompt(REVIEW_PROMPT),
1010    ]
1011}
1012
1013// ============================================================================
1014// Permission Policies for Built-in Agents
1015// ============================================================================
1016
1017/// Permission policy for explore agent (read-only)
1018fn explore_permissions() -> PermissionPolicy {
1019    let mut policy = PermissionPolicy::new()
1020        .allow_all(&["read", "grep", "glob", "ls"])
1021        .deny_all(&["write", "edit", "task", "parallel_task"])
1022        .allow("Bash(ls:*)")
1023        .allow("Bash(cat:*)")
1024        .allow("Bash(head:*)")
1025        .allow("Bash(tail:*)")
1026        .allow("Bash(find:*)")
1027        .allow("Bash(wc:*)")
1028        .deny("Bash(rm:*)")
1029        .deny("Bash(mv:*)")
1030        .deny("Bash(cp:*)");
1031    policy.default_decision = PermissionDecision::Deny;
1032    policy
1033}
1034
1035/// Permission policy for general agent (full access except task)
1036fn general_permissions() -> PermissionPolicy {
1037    PermissionPolicy::new()
1038        .allow_all(&[
1039            "read",
1040            "write",
1041            "edit",
1042            "grep",
1043            "glob",
1044            "ls",
1045            "bash",
1046            "web_fetch",
1047            "web_search",
1048            "git",
1049            "patch",
1050            "batch",
1051            "generate_object",
1052        ])
1053        .deny("task")
1054        .deny("parallel_task")
1055}
1056
1057/// Permission policy for plan agent (read-only)
1058fn plan_permissions() -> PermissionPolicy {
1059    let mut policy = PermissionPolicy::new()
1060        .allow_all(&["read", "grep", "glob", "ls"])
1061        .deny_all(&["write", "edit", "bash", "task", "parallel_task"]);
1062    policy.default_decision = PermissionDecision::Deny;
1063    policy
1064}
1065
1066/// Permission policy for verification agent (read-heavy with runtime checks)
1067fn verification_permissions() -> PermissionPolicy {
1068    let mut policy = PermissionPolicy::new()
1069        .allow_all(&["read", "grep", "glob", "ls", "bash"])
1070        .deny_all(&["write", "edit", "task", "parallel_task"]);
1071    policy.default_decision = PermissionDecision::Deny;
1072    policy
1073}
1074
1075/// Permission policy for review agent (read-heavy with optional lightweight checks)
1076fn review_permissions() -> PermissionPolicy {
1077    let mut policy = PermissionPolicy::new()
1078        .allow_all(&["read", "grep", "glob", "ls", "bash"])
1079        .deny_all(&["write", "edit", "task", "parallel_task"]);
1080    policy.default_decision = PermissionDecision::Deny;
1081    policy
1082}
1083
1084// ============================================================================
1085// System Prompts for Built-in Agents
1086// ============================================================================
1087
1088const EXPLORE_PROMPT: &str = crate::prompts::AGENT_EXPLORE;
1089
1090const PLAN_PROMPT: &str = crate::prompts::AGENT_PLAN;
1091
1092const VERIFICATION_PROMPT: &str = crate::prompts::AGENT_VERIFICATION;
1093
1094const REVIEW_PROMPT: &str = crate::prompts::AGENT_CODE_REVIEW;
1095
1096// ============================================================================
1097// Tests
1098// ============================================================================
1099
1100#[cfg(test)]
1101mod tests {
1102    use super::*;
1103
1104    #[test]
1105    fn test_agent_definition_builder() {
1106        let agent = AgentDefinition::new("test", "Test agent")
1107            .native()
1108            .hidden()
1109            .with_max_steps(10);
1110
1111        assert_eq!(agent.name, "test");
1112        assert_eq!(agent.description, "Test agent");
1113        assert!(agent.native);
1114        assert!(agent.hidden);
1115        assert_eq!(agent.max_steps, Some(10));
1116    }
1117
1118    #[test]
1119    fn test_agent_registry_new() {
1120        let registry = AgentRegistry::new();
1121
1122        // Should have built-in agents
1123        assert!(registry.exists("explore"));
1124        assert!(registry.exists("general"));
1125        assert!(registry.exists("plan"));
1126        assert!(registry.exists("verification"));
1127        assert!(registry.exists("review"));
1128        assert!(registry.exists("general-purpose"));
1129        assert_eq!(registry.len(), 5);
1130    }
1131
1132    #[test]
1133    fn test_agent_registry_get() {
1134        let registry = AgentRegistry::new();
1135
1136        let explore = registry.get("explore").unwrap();
1137        assert_eq!(explore.name, "explore");
1138        assert!(explore.native);
1139        assert!(!explore.hidden);
1140
1141        let general = registry.get("general-purpose").unwrap();
1142        assert_eq!(general.name, "general");
1143
1144        assert!(registry.get("nonexistent").is_none());
1145    }
1146
1147    #[test]
1148    fn test_agent_registry_register_unregister() {
1149        let registry = AgentRegistry::new();
1150        let initial_count = registry.len();
1151
1152        // Register custom agent
1153        let custom = AgentDefinition::new("custom", "Custom agent");
1154        registry.register(custom);
1155        assert_eq!(registry.len(), initial_count + 1);
1156        assert!(registry.exists("custom"));
1157
1158        // Unregister
1159        assert!(registry.unregister("custom"));
1160        assert_eq!(registry.len(), initial_count);
1161        assert!(!registry.exists("custom"));
1162
1163        // Unregister non-existent
1164        assert!(!registry.unregister("nonexistent"));
1165    }
1166
1167    #[test]
1168    fn test_agent_registry_list_visible() {
1169        let registry = AgentRegistry::new();
1170
1171        let visible = registry.list_visible();
1172        let all = registry.list();
1173
1174        assert_eq!(visible.len(), all.len());
1175        assert!(visible.iter().all(|a| !a.hidden));
1176    }
1177
1178    #[test]
1179    fn test_builtin_agents() {
1180        let agents = builtin_agents();
1181
1182        // Check we have expected agents
1183        let names: Vec<&str> = agents.iter().map(|a| a.name.as_str()).collect();
1184        assert!(names.contains(&"explore"));
1185        assert!(names.contains(&"general"));
1186        assert!(names.contains(&"plan"));
1187        assert!(names.contains(&"verification"));
1188        assert!(names.contains(&"review"));
1189
1190        // Check explore is read-only (has deny rules for write)
1191        let explore = agents.iter().find(|a| a.name == "explore").unwrap();
1192        assert!(!explore.permissions.deny.is_empty());
1193    }
1194
1195    // ========================================================================
1196    // Agent File Loading Tests
1197    // ========================================================================
1198
1199    #[test]
1200    fn test_parse_agent_yaml() {
1201        let yaml = r#"
1202name: test-agent
1203description: A test agent
1204hidden: false
1205max_steps: 20
1206"#;
1207        let agent = parse_agent_yaml(yaml).unwrap();
1208        assert_eq!(agent.name, "test-agent");
1209        assert_eq!(agent.description, "A test agent");
1210        assert!(!agent.hidden);
1211        assert_eq!(agent.max_steps, Some(20));
1212    }
1213
1214    #[test]
1215    fn test_parse_agent_yaml_with_permissions() {
1216        let yaml = r#"
1217name: restricted-agent
1218description: Agent with permissions
1219permissions:
1220  allow:
1221    - rule: read
1222    - rule: grep
1223  deny:
1224    - rule: write
1225"#;
1226        let agent = parse_agent_yaml(yaml).unwrap();
1227        assert_eq!(agent.name, "restricted-agent");
1228        assert_eq!(agent.permissions.allow.len(), 2);
1229        assert_eq!(agent.permissions.deny.len(), 1);
1230        // Verify that deserialized rules actually match (tool_name populated)
1231        assert!(agent.permissions.allow[0].matches("read", &serde_json::json!({})));
1232        assert!(agent.permissions.allow[1].matches("grep", &serde_json::json!({})));
1233        assert!(agent.permissions.deny[0].matches("write", &serde_json::json!({})));
1234    }
1235
1236    #[test]
1237    fn test_parse_agent_yaml_with_plain_string_permissions() {
1238        // Users naturally write plain strings in allow/deny lists
1239        let yaml = r#"
1240name: plain-agent
1241description: Agent with plain string permissions
1242permissions:
1243  allow:
1244    - read
1245    - grep
1246    - "Bash(cargo:*)"
1247  deny:
1248    - write
1249"#;
1250        let agent = parse_agent_yaml(yaml).unwrap();
1251        assert_eq!(agent.name, "plain-agent");
1252        assert_eq!(agent.permissions.allow.len(), 3);
1253        assert_eq!(agent.permissions.deny.len(), 1);
1254        // Verify rules are functional
1255        assert!(agent.permissions.allow[0].matches("read", &serde_json::json!({})));
1256        assert!(agent.permissions.allow[1].matches("grep", &serde_json::json!({})));
1257        assert!(agent.permissions.allow[2]
1258            .matches("Bash", &serde_json::json!({"command": "cargo build"})));
1259        assert!(agent.permissions.deny[0].matches("write", &serde_json::json!({})));
1260    }
1261
1262    #[test]
1263    fn test_parse_claude_style_agent_md_tools_field() {
1264        let md = r#"---
1265name: code-reviewer
1266description: Use proactively after code changes to review quality
1267tools: Read, Grep, Glob, Bash
1268---
1269Review the changed code and return prioritized findings.
1270"#;
1271        let agent = parse_agent_md(md).unwrap();
1272
1273        assert_eq!(agent.name, "code-reviewer");
1274        assert_eq!(
1275            agent.confirmation_inheritance,
1276            Some(ConfirmationInheritance::AutoApprove)
1277        );
1278        assert!(agent
1279            .permissions
1280            .allow
1281            .iter()
1282            .any(|r| r.matches("read", &serde_json::json!({}))));
1283        assert!(agent
1284            .permissions
1285            .allow
1286            .iter()
1287            .any(|r| r.matches("grep", &serde_json::json!({}))));
1288        assert!(agent
1289            .permissions
1290            .allow
1291            .iter()
1292            .any(|r| r.matches("bash", &serde_json::json!({}))));
1293        assert_eq!(
1294            agent
1295                .permissions
1296                .check("write", &serde_json::json!({"file_path": "src/lib.rs"})),
1297            PermissionDecision::Deny
1298        );
1299        assert!(agent
1300            .prompt
1301            .as_deref()
1302            .unwrap_or_default()
1303            .contains("prioritized findings"));
1304    }
1305
1306    #[test]
1307    fn test_parse_claude_style_agent_md_disallowed_tools_field() {
1308        let md = r#"---
1309name: shell-checker
1310description: Use proactively to run safe shell checks
1311tools:
1312  - Read
1313  - Bash
1314disallowedTools:
1315  - Bash(rm:*)
1316  - Write
1317---
1318Run safe checks only.
1319"#;
1320        let agent = parse_agent_md(md).unwrap();
1321
1322        assert_eq!(agent.name, "shell-checker");
1323        assert_eq!(
1324            agent
1325                .permissions
1326                .check("bash", &serde_json::json!({"command": "rm -rf target"})),
1327            PermissionDecision::Deny
1328        );
1329        assert_eq!(
1330            agent
1331                .permissions
1332                .check("bash", &serde_json::json!({"command": "cargo test"})),
1333            PermissionDecision::Allow
1334        );
1335        assert_eq!(
1336            agent
1337                .permissions
1338                .check("write", &serde_json::json!({"file_path": "x"})),
1339            PermissionDecision::Deny
1340        );
1341    }
1342
1343    #[test]
1344    fn test_parse_worker_agent_md_supports_claude_tools_fields() {
1345        let md = r#"---
1346name: planner-worker
1347description: Plan work
1348kind: planner
1349tools: Read, Grep
1350disallowedTools: Grep(secret:*)
1351---
1352Plan without editing.
1353"#;
1354        let agent = parse_agent_md(md).unwrap();
1355
1356        assert_eq!(agent.name, "planner-worker");
1357        assert_eq!(
1358            agent
1359                .permissions
1360                .check("read", &serde_json::json!({"file_path": "src/lib.rs"})),
1361            PermissionDecision::Allow
1362        );
1363        assert_eq!(
1364            agent.permissions.check(
1365                "grep",
1366                &serde_json::json!({"pattern": "secret", "path": "src"})
1367            ),
1368            PermissionDecision::Deny
1369        );
1370        assert_eq!(
1371            agent
1372                .permissions
1373                .check("bash", &serde_json::json!({"command": "echo no"})),
1374            PermissionDecision::Deny
1375        );
1376    }
1377
1378    #[test]
1379    fn test_builtin_agent_permissions_are_bounded() {
1380        let registry = AgentRegistry::new();
1381        let explore = registry.get("explore").unwrap();
1382        let general = registry.get("general-purpose").unwrap();
1383
1384        assert_eq!(
1385            explore
1386                .permissions
1387                .check("bash", &serde_json::json!({"command": "cargo test"})),
1388            PermissionDecision::Deny
1389        );
1390        assert_eq!(
1391            explore
1392                .permissions
1393                .check("bash", &serde_json::json!({"command": "ls src"})),
1394            PermissionDecision::Allow
1395        );
1396        assert_eq!(
1397            general
1398                .permissions
1399                .check("parallel_task", &serde_json::json!({})),
1400            PermissionDecision::Deny
1401        );
1402    }
1403
1404    #[test]
1405    fn test_parse_worker_agent_yaml_uses_cattle_defaults() {
1406        let yaml = r#"
1407name: frontend-fixer
1408description: Disposable frontend implementer
1409kind: implementer
1410max_steps: 7
1411"#;
1412        let agent = parse_agent_yaml(yaml).unwrap();
1413
1414        assert_eq!(agent.name, "frontend-fixer");
1415        assert_eq!(agent.max_steps, Some(7));
1416        assert!(agent
1417            .permissions
1418            .allow
1419            .iter()
1420            .any(|r| r.matches("write", &serde_json::json!({}))));
1421        assert!(agent
1422            .permissions
1423            .deny
1424            .iter()
1425            .any(|r| r.matches("task", &serde_json::json!({}))));
1426    }
1427
1428    #[test]
1429    fn test_parse_agent_yaml_missing_name() {
1430        let yaml = r#"
1431description: Agent without name
1432"#;
1433        let result = parse_agent_yaml(yaml);
1434        assert!(result.is_err());
1435    }
1436
1437    #[test]
1438    fn test_parse_agent_md() {
1439        let md = r#"---
1440name: md-agent
1441description: Agent from markdown
1442max_steps: 15
1443---
1444# System Prompt
1445
1446You are a helpful agent.
1447Do your best work.
1448"#;
1449        let agent = parse_agent_md(md).unwrap();
1450        assert_eq!(agent.name, "md-agent");
1451        assert_eq!(agent.description, "Agent from markdown");
1452        assert_eq!(agent.max_steps, Some(15));
1453        assert!(agent.prompt.is_some());
1454        assert!(agent.prompt.unwrap().contains("helpful agent"));
1455    }
1456
1457    #[test]
1458    fn test_parse_agent_md_with_prompt_in_frontmatter() {
1459        let md = r#"---
1460name: prompt-agent
1461description: Agent with prompt in frontmatter
1462prompt: "Frontmatter prompt"
1463---
1464Body content that should be ignored
1465"#;
1466        let agent = parse_agent_md(md).unwrap();
1467        assert_eq!(agent.prompt.unwrap(), "Frontmatter prompt");
1468    }
1469
1470    #[test]
1471    fn test_parse_worker_agent_md_uses_body_prompt() {
1472        let md = r#"---
1473name: review-cow
1474description: Disposable review worker
1475kind: reviewer
1476---
1477Review only the staged diff and return prioritized findings.
1478"#;
1479        let agent = parse_agent_md(md).unwrap();
1480
1481        assert_eq!(agent.name, "review-cow");
1482        assert_eq!(
1483            agent.prompt.as_deref(),
1484            Some("Review only the staged diff and return prioritized findings.")
1485        );
1486        assert!(agent
1487            .permissions
1488            .deny
1489            .iter()
1490            .any(|r| r.matches("write", &serde_json::json!({}))));
1491    }
1492
1493    #[test]
1494    fn test_parse_agent_md_missing_frontmatter() {
1495        let md = "Just markdown without frontmatter";
1496        let result = parse_agent_md(md);
1497        assert!(result.is_err());
1498    }
1499
1500    #[test]
1501    fn test_load_agents_from_dir() {
1502        let temp_dir = tempfile::tempdir().unwrap();
1503
1504        // Create a YAML agent file
1505        std::fs::write(
1506            temp_dir.path().join("agent1.yaml"),
1507            r#"
1508name: yaml-agent
1509description: Agent from YAML file
1510"#,
1511        )
1512        .unwrap();
1513
1514        // Create a Markdown agent file
1515        std::fs::write(
1516            temp_dir.path().join("agent2.md"),
1517            r#"---
1518name: md-agent
1519description: Agent from Markdown file
1520---
1521System prompt here
1522"#,
1523        )
1524        .unwrap();
1525
1526        // Create an invalid file (should be skipped)
1527        std::fs::write(temp_dir.path().join("invalid.yaml"), "not: valid: yaml: [").unwrap();
1528
1529        // Create a nested agent file (Claude-style directories are recursive)
1530        std::fs::create_dir_all(temp_dir.path().join("nested")).unwrap();
1531        std::fs::write(
1532            temp_dir.path().join("nested").join("agent3.md"),
1533            r#"---
1534name: nested-agent
1535description: Agent from nested Markdown file
1536---
1537Nested prompt
1538"#,
1539        )
1540        .unwrap();
1541
1542        // Create a non-agent file (should be skipped)
1543        std::fs::write(temp_dir.path().join("readme.txt"), "Just a text file").unwrap();
1544
1545        let agents = load_agents_from_dir(temp_dir.path());
1546        assert_eq!(agents.len(), 3);
1547
1548        let names: Vec<&str> = agents.iter().map(|a| a.name.as_str()).collect();
1549        assert!(names.contains(&"yaml-agent"));
1550        assert!(names.contains(&"md-agent"));
1551        assert!(names.contains(&"nested-agent"));
1552    }
1553
1554    #[test]
1555    fn test_load_agents_from_nonexistent_dir() {
1556        let agents = load_agents_from_dir(std::path::Path::new("/nonexistent/dir"));
1557        assert!(agents.is_empty());
1558    }
1559
1560    #[test]
1561    fn test_registry_with_config() {
1562        let temp_dir = tempfile::tempdir().unwrap();
1563
1564        // Create an agent file
1565        std::fs::write(
1566            temp_dir.path().join("custom.yaml"),
1567            r#"
1568name: custom-agent
1569description: Custom agent from config
1570"#,
1571        )
1572        .unwrap();
1573
1574        let config = CodeConfig::new().add_agent_dir(temp_dir.path());
1575        let registry = AgentRegistry::with_config(&config);
1576
1577        // Should have built-in agents plus custom agent
1578        assert!(registry.exists("explore"));
1579        assert!(registry.exists("custom-agent"));
1580        assert_eq!(registry.len(), 6); // 5 built-in + 1 custom
1581    }
1582
1583    #[test]
1584    fn test_agent_definition_with_model() {
1585        let model = ModelConfig {
1586            model: "claude-3-5-sonnet".to_string(),
1587            provider: Some("anthropic".to_string()),
1588        };
1589        let agent = AgentDefinition::new("test", "Test").with_model(model);
1590        assert!(agent.model.is_some());
1591        assert_eq!(agent.model.unwrap().provider, Some("anthropic".to_string()));
1592    }
1593
1594    #[test]
1595    fn test_model_config_from_model_ref() {
1596        let model = ModelConfig::from_model_ref("openai/gpt-4o");
1597        assert_eq!(model.provider.as_deref(), Some("openai"));
1598        assert_eq!(model.model, "gpt-4o");
1599        assert_eq!(model.model_ref(), "openai/gpt-4o");
1600
1601        let inherited = ModelConfig::from_model_ref("claude-sonnet");
1602        assert_eq!(inherited.provider, None);
1603        assert_eq!(inherited.model_ref(), "claude-sonnet");
1604    }
1605
1606    #[test]
1607    fn test_worker_agent_kind_from_str_accepts_aliases() {
1608        assert_eq!(
1609            "explore".parse::<WorkerAgentKind>().unwrap(),
1610            WorkerAgentKind::ReadOnly
1611        );
1612        assert_eq!(
1613            "general".parse::<WorkerAgentKind>().unwrap(),
1614            WorkerAgentKind::Implementer
1615        );
1616        assert!("unknown".parse::<WorkerAgentKind>().is_err());
1617    }
1618
1619    #[test]
1620    fn worker_spec_implementer_creates_cattle_agent_definition() {
1621        let agent = WorkerAgentSpec::implementer("frontend-fixer", "Fix frontend issues")
1622            .with_prompt("Focus on small, verified patches.")
1623            .with_provider_model("anthropic", "claude-sonnet")
1624            .with_max_steps(12)
1625            .into_agent_definition();
1626
1627        assert_eq!(agent.name, "frontend-fixer");
1628        assert_eq!(agent.max_steps, Some(12));
1629        assert_eq!(
1630            agent.prompt.as_deref(),
1631            Some("Focus on small, verified patches.")
1632        );
1633        assert_eq!(agent.model.unwrap().provider.as_deref(), Some("anthropic"));
1634        assert!(agent
1635            .permissions
1636            .allow
1637            .iter()
1638            .any(|r| r.matches("write", &serde_json::json!({}))));
1639        assert!(agent
1640            .permissions
1641            .deny
1642            .iter()
1643            .any(|r| r.matches("task", &serde_json::json!({}))));
1644    }
1645
1646    #[test]
1647    fn worker_spec_read_only_uses_safe_defaults() {
1648        let agent = WorkerAgentSpec::read_only("scanner", "Scan repository")
1649            .hidden(true)
1650            .into_agent_definition();
1651
1652        assert!(agent.hidden);
1653        assert_eq!(agent.max_steps, Some(20));
1654        assert!(agent.prompt.is_some());
1655        assert!(agent
1656            .permissions
1657            .allow
1658            .iter()
1659            .any(|r| r.matches("read", &serde_json::json!({}))));
1660        assert!(agent
1661            .permissions
1662            .deny
1663            .iter()
1664            .any(|r| r.matches("write", &serde_json::json!({}))));
1665    }
1666
1667    #[test]
1668    fn registry_register_worker_returns_and_stores_definition() {
1669        let registry = AgentRegistry::new();
1670        let agent =
1671            registry.register_worker(WorkerAgentSpec::custom("strict-worker", "Strict worker"));
1672
1673        assert_eq!(agent.name, "strict-worker");
1674        assert!(registry.exists("strict-worker"));
1675        assert_eq!(
1676            agent
1677                .permissions
1678                .check("bash", &serde_json::json!({"command":"echo hi"})),
1679            crate::permissions::PermissionDecision::Ask
1680        );
1681    }
1682
1683    #[test]
1684    fn registry_register_workers_batches_cattle_specs() {
1685        let registry = AgentRegistry::new();
1686        let agents = registry.register_workers([
1687            WorkerAgentSpec::planner("planner-cow", "Plan work"),
1688            WorkerAgentSpec::verifier("verify-cow", "Verify work"),
1689        ]);
1690
1691        assert_eq!(agents.len(), 2);
1692        assert!(registry.exists("planner-cow"));
1693        assert!(registry.exists("verify-cow"));
1694    }
1695
1696    #[test]
1697    fn test_agent_registry_default() {
1698        let registry = AgentRegistry::default();
1699        assert!(!registry.is_empty());
1700        assert_eq!(registry.len(), 5);
1701    }
1702
1703    #[test]
1704    fn test_agent_registry_is_empty() {
1705        let registry = AgentRegistry {
1706            agents: RwLock::new(HashMap::new()),
1707        };
1708        assert!(registry.is_empty());
1709        assert_eq!(registry.len(), 0);
1710    }
1711
1712    #[test]
1713    fn test_apply_to_sets_permissions() {
1714        use crate::agent::AgentConfig;
1715        use crate::permissions::PermissionDecision;
1716
1717        let def = AgentDefinition::new("writer", "Write files")
1718            .with_permissions(PermissionPolicy::new().allow("write(*)"));
1719
1720        let mut config = AgentConfig::default();
1721        assert!(config.permission_checker.is_none());
1722
1723        def.apply_to(&mut config);
1724
1725        assert!(config.permission_checker.is_some());
1726        assert!(config.permission_policy.is_some());
1727        let checker = config.permission_checker.unwrap();
1728        assert_eq!(
1729            checker.check(
1730                "write",
1731                &serde_json::json!({"file_path": "x.txt", "content": "hi"})
1732            ),
1733            PermissionDecision::Allow
1734        );
1735    }
1736
1737    #[test]
1738    fn test_apply_to_sets_prompt() {
1739        use crate::agent::AgentConfig;
1740
1741        let def = AgentDefinition::new("helper", "Help").with_prompt("Be helpful.");
1742        let mut config = AgentConfig::default();
1743
1744        def.apply_to(&mut config);
1745
1746        assert_eq!(config.prompt_slots.extra.as_deref(), Some("Be helpful."));
1747    }
1748
1749    #[test]
1750    fn test_apply_to_sets_max_steps() {
1751        use crate::agent::AgentConfig;
1752
1753        let def = AgentDefinition::new("fast", "Fast agent").with_max_steps(7);
1754        let mut config = AgentConfig::default();
1755
1756        def.apply_to(&mut config);
1757
1758        assert_eq!(config.max_tool_rounds, 7);
1759    }
1760
1761    #[test]
1762    fn test_apply_to_respects_host_overrides() {
1763        use crate::agent::AgentConfig;
1764
1765        let def = AgentDefinition::new("agent", "Agent")
1766            .with_permissions(PermissionPolicy::new().allow("bash(*)"))
1767            .with_prompt("Agent prompt.")
1768            .with_max_steps(10);
1769
1770        let mut config = AgentConfig::default();
1771        config.prompt_slots.extra = Some("Host prompt.".to_string());
1772        config.max_tool_rounds = 25;
1773        config.permission_checker = Some(std::sync::Arc::new(PermissionPolicy::new().allow("*")));
1774
1775        def.apply_to(&mut config);
1776
1777        // Host overrides should be preserved
1778        assert_eq!(config.prompt_slots.extra.as_deref(), Some("Host prompt."));
1779        assert_eq!(config.max_tool_rounds, 25);
1780    }
1781
1782    #[test]
1783    fn test_apply_to_skips_empty_permissions() {
1784        use crate::agent::AgentConfig;
1785
1786        let def = AgentDefinition::new("empty", "No permissions");
1787        let mut config = AgentConfig::default();
1788
1789        def.apply_to(&mut config);
1790
1791        assert!(config.permission_checker.is_none());
1792        assert!(config.permission_policy.is_none());
1793    }
1794}