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::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/// Model configuration for agent
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ModelConfig {
69    /// Model identifier (e.g., "claude-3-5-sonnet-20241022")
70    pub model: String,
71    /// Optional provider override
72    pub provider: Option<String>,
73}
74
75/// Agent definition
76///
77/// Defines the configuration and capabilities of an agent type.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct AgentDefinition {
80    /// Agent identifier (e.g., "explore", "plan", "general")
81    pub name: String,
82    /// Description of what the agent does
83    pub description: String,
84    /// Whether this is a built-in agent
85    #[serde(default)]
86    pub native: bool,
87    /// Whether to hide from UI
88    #[serde(default)]
89    pub hidden: bool,
90    /// Permission rules for this agent
91    #[serde(default)]
92    pub permissions: PermissionPolicy,
93    /// Optional model override
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub model: Option<ModelConfig>,
96    /// System prompt for this agent
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub prompt: Option<String>,
99    /// Maximum execution steps (tool rounds)
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub max_steps: Option<usize>,
102}
103
104impl AgentDefinition {
105    /// Create a new agent definition
106    pub fn new(name: &str, description: &str) -> Self {
107        Self {
108            name: name.to_string(),
109            description: description.to_string(),
110            native: false,
111            hidden: false,
112            permissions: PermissionPolicy::default(),
113            model: None,
114            prompt: None,
115            max_steps: None,
116        }
117    }
118
119    /// Mark as native (built-in)
120    pub fn native(mut self) -> Self {
121        self.native = true;
122        self
123    }
124
125    /// Mark as hidden from UI
126    pub fn hidden(mut self) -> Self {
127        self.hidden = true;
128        self
129    }
130
131    /// Set permission policy
132    pub fn with_permissions(mut self, permissions: PermissionPolicy) -> Self {
133        self.permissions = permissions;
134        self
135    }
136
137    /// Set model override
138    pub fn with_model(mut self, model: ModelConfig) -> Self {
139        self.model = Some(model);
140        self
141    }
142
143    /// Set system prompt
144    pub fn with_prompt(mut self, prompt: &str) -> Self {
145        self.prompt = Some(prompt.to_string());
146        self
147    }
148
149    /// Set maximum execution steps
150    pub fn with_max_steps(mut self, max_steps: usize) -> Self {
151        self.max_steps = Some(max_steps);
152        self
153    }
154}
155
156/// Agent registry for managing agent definitions
157///
158/// Thread-safe registry that stores agent definitions and provides
159/// lookup functionality.
160pub struct AgentRegistry {
161    agents: RwLock<HashMap<String, AgentDefinition>>,
162}
163
164impl Default for AgentRegistry {
165    fn default() -> Self {
166        Self::new()
167    }
168}
169
170impl AgentRegistry {
171    /// Create a new agent registry with built-in agents
172    pub fn new() -> Self {
173        let registry = Self {
174            agents: RwLock::new(HashMap::new()),
175        };
176
177        // Register built-in agents
178        for agent in builtin_agents() {
179            registry.register(agent);
180        }
181
182        registry
183    }
184
185    /// Create a new agent registry with configuration
186    ///
187    /// Loads built-in agents first, then loads agents from configured directories.
188    pub fn with_config(config: &CodeConfig) -> Self {
189        let registry = Self::new();
190
191        // Load agents from configured directories
192        for dir in &config.agent_dirs {
193            let agents = load_agents_from_dir(dir);
194            for agent in agents {
195                tracing::info!("Loaded agent '{}' from {}", agent.name, dir.display());
196                registry.register(agent);
197            }
198        }
199
200        registry
201    }
202
203    /// Register an agent definition
204    pub fn register(&self, agent: AgentDefinition) {
205        let mut agents = write_or_recover(&self.agents);
206        tracing::debug!("Registering agent: {}", agent.name);
207        agents.insert(agent.name.clone(), agent);
208    }
209
210    /// Unregister an agent by name
211    ///
212    /// Returns true if the agent was removed, false if not found.
213    pub fn unregister(&self, name: &str) -> bool {
214        let mut agents = write_or_recover(&self.agents);
215        agents.remove(name).is_some()
216    }
217
218    /// Get an agent definition by name
219    pub fn get(&self, name: &str) -> Option<AgentDefinition> {
220        let agents = read_or_recover(&self.agents);
221        agents.get(name).cloned()
222    }
223
224    /// List all registered agents
225    pub fn list(&self) -> Vec<AgentDefinition> {
226        let agents = read_or_recover(&self.agents);
227        agents.values().cloned().collect()
228    }
229
230    /// List visible agents (not hidden)
231    pub fn list_visible(&self) -> Vec<AgentDefinition> {
232        let agents = read_or_recover(&self.agents);
233        agents.values().filter(|a| !a.hidden).cloned().collect()
234    }
235
236    /// Check if an agent exists
237    pub fn exists(&self, name: &str) -> bool {
238        let agents = read_or_recover(&self.agents);
239        agents.contains_key(name)
240    }
241
242    /// Get the number of registered agents
243    pub fn len(&self) -> usize {
244        let agents = read_or_recover(&self.agents);
245        agents.len()
246    }
247
248    /// Check if the registry is empty
249    pub fn is_empty(&self) -> bool {
250        self.len() == 0
251    }
252}
253
254// ============================================================================
255// Agent File Loading
256// ============================================================================
257
258/// Parse an agent definition from YAML content
259///
260/// The YAML should contain fields matching AgentDefinition structure.
261pub fn parse_agent_yaml(content: &str) -> anyhow::Result<AgentDefinition> {
262    let agent: AgentDefinition = serde_yaml::from_str(content)
263        .map_err(|e| anyhow::anyhow!("Failed to parse agent YAML: {}", e))?;
264
265    if agent.name.is_empty() {
266        return Err(anyhow::anyhow!("Agent name is required"));
267    }
268
269    Ok(agent)
270}
271
272/// Parse an agent definition from Markdown with YAML frontmatter
273///
274/// The frontmatter contains agent metadata, and the body becomes the prompt.
275pub fn parse_agent_md(content: &str) -> anyhow::Result<AgentDefinition> {
276    // Parse frontmatter (YAML between --- markers)
277    let parts: Vec<&str> = content.splitn(3, "---").collect();
278
279    if parts.len() < 3 {
280        return Err(anyhow::anyhow!(
281            "Invalid markdown format: missing YAML frontmatter"
282        ));
283    }
284
285    let frontmatter = parts[1].trim();
286    let body = parts[2].trim();
287
288    // Parse the frontmatter as YAML
289    let mut agent: AgentDefinition = serde_yaml::from_str(frontmatter)
290        .map_err(|e| anyhow::anyhow!("Failed to parse agent frontmatter: {}", e))?;
291
292    if agent.name.is_empty() {
293        return Err(anyhow::anyhow!("Agent name is required"));
294    }
295
296    // Use body as prompt if not already set in frontmatter
297    if agent.prompt.is_none() && !body.is_empty() {
298        agent.prompt = Some(body.to_string());
299    }
300
301    Ok(agent)
302}
303
304/// Load all agent definitions from a directory
305///
306/// Scans for *.yaml and *.md files and parses them as agent definitions.
307/// Invalid files are logged and skipped.
308pub fn load_agents_from_dir(dir: &Path) -> Vec<AgentDefinition> {
309    let mut agents = Vec::new();
310
311    let Ok(entries) = std::fs::read_dir(dir) else {
312        tracing::warn!("Failed to read agent directory: {}", dir.display());
313        return agents;
314    };
315
316    for entry in entries.flatten() {
317        let path = entry.path();
318
319        // Skip non-files
320        if !path.is_file() {
321            continue;
322        }
323
324        let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
325            continue;
326        };
327
328        // Read file content
329        let Ok(content) = std::fs::read_to_string(&path) else {
330            tracing::warn!("Failed to read agent file: {}", path.display());
331            continue;
332        };
333
334        // Parse based on extension
335        let result = match ext {
336            "yaml" | "yml" => parse_agent_yaml(&content),
337            "md" => parse_agent_md(&content),
338            _ => continue,
339        };
340
341        match result {
342            Ok(agent) => {
343                tracing::debug!("Loaded agent '{}' from {}", agent.name, path.display());
344                agents.push(agent);
345            }
346            Err(e) => {
347                tracing::warn!("Failed to parse agent file {}: {}", path.display(), e);
348            }
349        }
350    }
351
352    agents
353}
354
355/// Create built-in agent definitions
356pub fn builtin_agents() -> Vec<AgentDefinition> {
357    vec![
358        // Explore agent: Fast codebase exploration (read-only)
359        AgentDefinition::new(
360            "explore",
361            "Fast codebase exploration agent. Use for searching files, reading code, \
362             and understanding codebase structure. Read-only operations only.",
363        )
364        .native()
365        .with_permissions(explore_permissions())
366        .with_max_steps(20)
367        .with_prompt(EXPLORE_PROMPT),
368        // General agent: Multi-step task execution
369        AgentDefinition::new(
370            "general",
371            "General-purpose agent for multi-step task execution. Can read, write, \
372             and execute commands.",
373        )
374        .native()
375        .with_permissions(general_permissions())
376        .with_max_steps(50),
377        // Plan agent: Read-only planning mode
378        AgentDefinition::new(
379            "plan",
380            "Planning agent for designing implementation approaches. Read-only access \
381             to explore codebase and create plans.",
382        )
383        .native()
384        .with_permissions(plan_permissions())
385        .with_max_steps(30)
386        .with_prompt(PLAN_PROMPT),
387        // Verification agent: adversarial validation and repro
388        AgentDefinition::new(
389            "verification",
390            "Verification agent for adversarial validation. Prefer real checks, \
391             reproductions, and regression testing over code reading alone.",
392        )
393        .native()
394        .with_permissions(verification_permissions())
395        .with_max_steps(30)
396        .with_prompt(VERIFICATION_PROMPT),
397        // Review agent: review-focused analysis
398        AgentDefinition::new(
399            "review",
400            "Code review agent focused on correctness, regressions, security, \
401             maintainability, and clear findings.",
402        )
403        .native()
404        .with_permissions(review_permissions())
405        .with_max_steps(25)
406        .with_prompt(REVIEW_PROMPT),
407    ]
408}
409
410// ============================================================================
411// Permission Policies for Built-in Agents
412// ============================================================================
413
414/// Permission policy for explore agent (read-only)
415fn explore_permissions() -> PermissionPolicy {
416    PermissionPolicy::new()
417        .allow_all(&["read", "grep", "glob", "ls"])
418        .deny_all(&["write", "edit", "task"])
419        .allow("Bash(ls:*)")
420        .allow("Bash(cat:*)")
421        .allow("Bash(head:*)")
422        .allow("Bash(tail:*)")
423        .allow("Bash(find:*)")
424        .allow("Bash(wc:*)")
425        .deny("Bash(rm:*)")
426        .deny("Bash(mv:*)")
427        .deny("Bash(cp:*)")
428}
429
430/// Permission policy for general agent (full access except task)
431fn general_permissions() -> PermissionPolicy {
432    PermissionPolicy::new()
433        .allow_all(&["read", "write", "edit", "grep", "glob", "ls", "bash"])
434        .deny("task")
435}
436
437/// Permission policy for plan agent (read-only)
438fn plan_permissions() -> PermissionPolicy {
439    PermissionPolicy::new()
440        .allow_all(&["read", "grep", "glob", "ls"])
441        .deny_all(&["write", "edit", "bash", "task"])
442}
443
444/// Permission policy for verification agent (read-heavy with runtime checks)
445fn verification_permissions() -> PermissionPolicy {
446    PermissionPolicy::new()
447        .allow_all(&["read", "grep", "glob", "ls", "bash"])
448        .deny_all(&["write", "edit", "task"])
449}
450
451/// Permission policy for review agent (read-heavy with optional lightweight checks)
452fn review_permissions() -> PermissionPolicy {
453    PermissionPolicy::new()
454        .allow_all(&["read", "grep", "glob", "ls", "bash"])
455        .deny_all(&["write", "edit", "task"])
456}
457
458// ============================================================================
459// System Prompts for Built-in Agents
460// ============================================================================
461
462const EXPLORE_PROMPT: &str = crate::prompts::AGENT_EXPLORE;
463
464const PLAN_PROMPT: &str = crate::prompts::AGENT_PLAN;
465
466const VERIFICATION_PROMPT: &str = crate::prompts::AGENT_VERIFICATION;
467
468const REVIEW_PROMPT: &str = crate::prompts::AGENT_CODE_REVIEW;
469
470// ============================================================================
471// Tests
472// ============================================================================
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    #[test]
479    fn test_agent_definition_builder() {
480        let agent = AgentDefinition::new("test", "Test agent")
481            .native()
482            .hidden()
483            .with_max_steps(10);
484
485        assert_eq!(agent.name, "test");
486        assert_eq!(agent.description, "Test agent");
487        assert!(agent.native);
488        assert!(agent.hidden);
489        assert_eq!(agent.max_steps, Some(10));
490    }
491
492    #[test]
493    fn test_agent_registry_new() {
494        let registry = AgentRegistry::new();
495
496        // Should have built-in agents
497        assert!(registry.exists("explore"));
498        assert!(registry.exists("general"));
499        assert!(registry.exists("plan"));
500        assert!(registry.exists("verification"));
501        assert!(registry.exists("review"));
502        assert_eq!(registry.len(), 5);
503    }
504
505    #[test]
506    fn test_agent_registry_get() {
507        let registry = AgentRegistry::new();
508
509        let explore = registry.get("explore").unwrap();
510        assert_eq!(explore.name, "explore");
511        assert!(explore.native);
512        assert!(!explore.hidden);
513
514        assert!(registry.get("nonexistent").is_none());
515    }
516
517    #[test]
518    fn test_agent_registry_register_unregister() {
519        let registry = AgentRegistry::new();
520        let initial_count = registry.len();
521
522        // Register custom agent
523        let custom = AgentDefinition::new("custom", "Custom agent");
524        registry.register(custom);
525        assert_eq!(registry.len(), initial_count + 1);
526        assert!(registry.exists("custom"));
527
528        // Unregister
529        assert!(registry.unregister("custom"));
530        assert_eq!(registry.len(), initial_count);
531        assert!(!registry.exists("custom"));
532
533        // Unregister non-existent
534        assert!(!registry.unregister("nonexistent"));
535    }
536
537    #[test]
538    fn test_agent_registry_list_visible() {
539        let registry = AgentRegistry::new();
540
541        let visible = registry.list_visible();
542        let all = registry.list();
543
544        assert_eq!(visible.len(), all.len());
545        assert!(visible.iter().all(|a| !a.hidden));
546    }
547
548    #[test]
549    fn test_builtin_agents() {
550        let agents = builtin_agents();
551
552        // Check we have expected agents
553        let names: Vec<&str> = agents.iter().map(|a| a.name.as_str()).collect();
554        assert!(names.contains(&"explore"));
555        assert!(names.contains(&"general"));
556        assert!(names.contains(&"plan"));
557        assert!(names.contains(&"verification"));
558        assert!(names.contains(&"review"));
559
560        // Check explore is read-only (has deny rules for write)
561        let explore = agents.iter().find(|a| a.name == "explore").unwrap();
562        assert!(!explore.permissions.deny.is_empty());
563    }
564
565    // ========================================================================
566    // Agent File Loading Tests
567    // ========================================================================
568
569    #[test]
570    fn test_parse_agent_yaml() {
571        let yaml = r#"
572name: test-agent
573description: A test agent
574hidden: false
575max_steps: 20
576"#;
577        let agent = parse_agent_yaml(yaml).unwrap();
578        assert_eq!(agent.name, "test-agent");
579        assert_eq!(agent.description, "A test agent");
580        assert!(!agent.hidden);
581        assert_eq!(agent.max_steps, Some(20));
582    }
583
584    #[test]
585    fn test_parse_agent_yaml_with_permissions() {
586        let yaml = r#"
587name: restricted-agent
588description: Agent with permissions
589permissions:
590  allow:
591    - rule: read
592    - rule: grep
593  deny:
594    - rule: write
595"#;
596        let agent = parse_agent_yaml(yaml).unwrap();
597        assert_eq!(agent.name, "restricted-agent");
598        assert_eq!(agent.permissions.allow.len(), 2);
599        assert_eq!(agent.permissions.deny.len(), 1);
600        // Verify that deserialized rules actually match (tool_name populated)
601        assert!(agent.permissions.allow[0].matches("read", &serde_json::json!({})));
602        assert!(agent.permissions.allow[1].matches("grep", &serde_json::json!({})));
603        assert!(agent.permissions.deny[0].matches("write", &serde_json::json!({})));
604    }
605
606    #[test]
607    fn test_parse_agent_yaml_with_plain_string_permissions() {
608        // Users naturally write plain strings in allow/deny lists
609        let yaml = r#"
610name: plain-agent
611description: Agent with plain string permissions
612permissions:
613  allow:
614    - read
615    - grep
616    - "Bash(cargo:*)"
617  deny:
618    - write
619"#;
620        let agent = parse_agent_yaml(yaml).unwrap();
621        assert_eq!(agent.name, "plain-agent");
622        assert_eq!(agent.permissions.allow.len(), 3);
623        assert_eq!(agent.permissions.deny.len(), 1);
624        // Verify rules are functional
625        assert!(agent.permissions.allow[0].matches("read", &serde_json::json!({})));
626        assert!(agent.permissions.allow[1].matches("grep", &serde_json::json!({})));
627        assert!(agent.permissions.allow[2]
628            .matches("Bash", &serde_json::json!({"command": "cargo build"})));
629        assert!(agent.permissions.deny[0].matches("write", &serde_json::json!({})));
630    }
631
632    #[test]
633    fn test_parse_agent_yaml_missing_name() {
634        let yaml = r#"
635description: Agent without name
636"#;
637        let result = parse_agent_yaml(yaml);
638        assert!(result.is_err());
639    }
640
641    #[test]
642    fn test_parse_agent_md() {
643        let md = r#"---
644name: md-agent
645description: Agent from markdown
646max_steps: 15
647---
648# System Prompt
649
650You are a helpful agent.
651Do your best work.
652"#;
653        let agent = parse_agent_md(md).unwrap();
654        assert_eq!(agent.name, "md-agent");
655        assert_eq!(agent.description, "Agent from markdown");
656        assert_eq!(agent.max_steps, Some(15));
657        assert!(agent.prompt.is_some());
658        assert!(agent.prompt.unwrap().contains("helpful agent"));
659    }
660
661    #[test]
662    fn test_parse_agent_md_with_prompt_in_frontmatter() {
663        let md = r#"---
664name: prompt-agent
665description: Agent with prompt in frontmatter
666prompt: "Frontmatter prompt"
667---
668Body content that should be ignored
669"#;
670        let agent = parse_agent_md(md).unwrap();
671        assert_eq!(agent.prompt.unwrap(), "Frontmatter prompt");
672    }
673
674    #[test]
675    fn test_parse_agent_md_missing_frontmatter() {
676        let md = "Just markdown without frontmatter";
677        let result = parse_agent_md(md);
678        assert!(result.is_err());
679    }
680
681    #[test]
682    fn test_load_agents_from_dir() {
683        let temp_dir = tempfile::tempdir().unwrap();
684
685        // Create a YAML agent file
686        std::fs::write(
687            temp_dir.path().join("agent1.yaml"),
688            r#"
689name: yaml-agent
690description: Agent from YAML file
691"#,
692        )
693        .unwrap();
694
695        // Create a Markdown agent file
696        std::fs::write(
697            temp_dir.path().join("agent2.md"),
698            r#"---
699name: md-agent
700description: Agent from Markdown file
701---
702System prompt here
703"#,
704        )
705        .unwrap();
706
707        // Create an invalid file (should be skipped)
708        std::fs::write(temp_dir.path().join("invalid.yaml"), "not: valid: yaml: [").unwrap();
709
710        // Create a non-agent file (should be skipped)
711        std::fs::write(temp_dir.path().join("readme.txt"), "Just a text file").unwrap();
712
713        let agents = load_agents_from_dir(temp_dir.path());
714        assert_eq!(agents.len(), 2);
715
716        let names: Vec<&str> = agents.iter().map(|a| a.name.as_str()).collect();
717        assert!(names.contains(&"yaml-agent"));
718        assert!(names.contains(&"md-agent"));
719    }
720
721    #[test]
722    fn test_load_agents_from_nonexistent_dir() {
723        let agents = load_agents_from_dir(std::path::Path::new("/nonexistent/dir"));
724        assert!(agents.is_empty());
725    }
726
727    #[test]
728    fn test_registry_with_config() {
729        let temp_dir = tempfile::tempdir().unwrap();
730
731        // Create an agent file
732        std::fs::write(
733            temp_dir.path().join("custom.yaml"),
734            r#"
735name: custom-agent
736description: Custom agent from config
737"#,
738        )
739        .unwrap();
740
741        let config = CodeConfig::new().add_agent_dir(temp_dir.path());
742        let registry = AgentRegistry::with_config(&config);
743
744        // Should have built-in agents plus custom agent
745        assert!(registry.exists("explore"));
746        assert!(registry.exists("custom-agent"));
747        assert_eq!(registry.len(), 6); // 5 built-in + 1 custom
748    }
749
750    #[test]
751    fn test_agent_definition_with_model() {
752        let model = ModelConfig {
753            model: "claude-3-5-sonnet".to_string(),
754            provider: Some("anthropic".to_string()),
755        };
756        let agent = AgentDefinition::new("test", "Test").with_model(model);
757        assert!(agent.model.is_some());
758        assert_eq!(agent.model.unwrap().provider, Some("anthropic".to_string()));
759    }
760
761    #[test]
762    fn test_agent_registry_default() {
763        let registry = AgentRegistry::default();
764        assert!(!registry.is_empty());
765        assert_eq!(registry.len(), 5);
766    }
767
768    #[test]
769    fn test_agent_registry_is_empty() {
770        let registry = AgentRegistry {
771            agents: RwLock::new(HashMap::new()),
772        };
773        assert!(registry.is_empty());
774        assert_eq!(registry.len(), 0);
775    }
776}