Skip to main content

batuta/agent/
custom_agents.rs

1//! Custom subagent discovery from project-level markdown files.
2//!
3//! PMAT-CODE-CUSTOM-AGENTS-001: Claude-Code parity for user-defined
4//! subagents. Claude scans `.claude/agents/<name>/AGENT.md` (and
5//! `~/.claude/agents/<name>/AGENT.md` at user scope); `apr code`
6//! mirrors the same pattern at `.apr/agents/` and `~/.config/apr/agents/`.
7//!
8//! The frontmatter format is markdown `---`-fenced key: value lines,
9//! deliberately NOT full YAML so that the loader has no external
10//! dependency and can't accept adversarial nested documents.
11//!
12//! # Example
13//!
14//! `.apr/agents/code-reviewer.md`:
15//!
16//! ```markdown
17//! ---
18//! name: code-reviewer
19//! description: Reviews code for bugs, style, and security issues
20//! max_iterations: 8
21//! ---
22//!
23//! You are a code-reviewing subagent. Focus on bugs, security issues,
24//! and style violations. Return a structured list of findings.
25//! ```
26//!
27//! After loading, `registry.resolve("code-reviewer")` returns a
28//! [`SubagentSpec`] usable by [`super::task_tool::TaskTool`].
29
30use std::fs;
31use std::path::{Path, PathBuf};
32
33use super::task_tool::{SubagentRegistry, SubagentSpec};
34
35/// Default project-scope directory for custom agents (relative to cwd).
36pub const DEFAULT_PROJECT_DIR: &str = ".apr/agents";
37
38/// Legacy / cross-compat directory — Claude Code projects that also
39/// want to work with `apr code` can share a single agents directory.
40pub const CLAUDE_COMPAT_DIR: &str = ".claude/agents";
41
42/// Error shapes from parsing or loading a custom agent.
43#[derive(Debug)]
44pub enum CustomAgentError {
45    /// Frontmatter fence missing or malformed.
46    MissingFrontmatter,
47    /// Required field `name` was absent or empty.
48    MissingName,
49    /// Required field `description` was absent or empty.
50    MissingDescription,
51    /// Body after frontmatter was empty (no system prompt).
52    EmptyBody,
53    /// Filesystem error reading the file.
54    Io(String),
55}
56
57impl std::fmt::Display for CustomAgentError {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        match self {
60            Self::MissingFrontmatter => write!(f, "missing `---`-fenced frontmatter"),
61            Self::MissingName => write!(f, "required field `name` missing or empty"),
62            Self::MissingDescription => {
63                write!(f, "required field `description` missing or empty")
64            }
65            Self::EmptyBody => write!(f, "body (system prompt) is empty"),
66            Self::Io(msg) => write!(f, "I/O error: {msg}"),
67        }
68    }
69}
70
71impl std::error::Error for CustomAgentError {}
72
73/// Parse a custom-agent markdown document into a [`SubagentSpec`].
74///
75/// Frontmatter format: leading `---\n`, then lines of `key: value`,
76/// then `---\n`, then the body (used as the system prompt).
77pub fn parse_agent_md(source: &str) -> Result<SubagentSpec, CustomAgentError> {
78    let trimmed = source.trim_start_matches('\u{feff}');
79    let rest = trimmed
80        .strip_prefix("---\n")
81        .or_else(|| trimmed.strip_prefix("---\r\n"))
82        .ok_or(CustomAgentError::MissingFrontmatter)?;
83
84    let (front, body) = split_at_fence(rest).ok_or(CustomAgentError::MissingFrontmatter)?;
85
86    let mut name = String::new();
87    let mut description = String::new();
88    let mut max_iterations: u32 = 8;
89
90    for line in front.lines() {
91        let line = line.trim();
92        if line.is_empty() || line.starts_with('#') {
93            continue;
94        }
95        let Some((key, value)) = line.split_once(':') else {
96            continue;
97        };
98        let key = key.trim();
99        let value = value.trim().trim_matches('"').trim_matches('\'');
100        match key {
101            "name" => name = value.to_string(),
102            "description" => description = value.to_string(),
103            "max_iterations" => {
104                if let Ok(n) = value.parse::<u32>() {
105                    if n > 0 {
106                        max_iterations = n;
107                    }
108                }
109            }
110            // tools / model fields tolerated but ignored (Claude-compat).
111            _ => {}
112        }
113    }
114
115    if name.is_empty() {
116        return Err(CustomAgentError::MissingName);
117    }
118    if description.is_empty() {
119        return Err(CustomAgentError::MissingDescription);
120    }
121    let system_prompt = body.trim().to_string();
122    if system_prompt.is_empty() {
123        return Err(CustomAgentError::EmptyBody);
124    }
125
126    Ok(SubagentSpec { name, description, system_prompt, max_iterations })
127}
128
129/// Scan a directory for custom-agent `.md` files.
130///
131/// Supports two layouts (both Claude-compatible):
132///   * `dir/<name>.md` — flat file per agent.
133///   * `dir/<name>/AGENT.md` — subdirectory per agent (Claude's default).
134///
135/// Silently skips files that fail to parse (a malformed agent should
136/// not break the entire load).
137pub fn load_custom_agents_from(dir: &Path) -> Vec<SubagentSpec> {
138    let mut specs = Vec::new();
139    let Ok(entries) = fs::read_dir(dir) else {
140        return specs;
141    };
142
143    for entry in entries.flatten() {
144        let path = entry.path();
145        if path.is_file() {
146            if path.extension().is_some_and(|e| e == "md") {
147                if let Some(spec) = try_parse_file(&path) {
148                    specs.push(spec);
149                }
150            }
151        } else if path.is_dir() {
152            let agent_md = path.join("AGENT.md");
153            if agent_md.is_file() {
154                if let Some(spec) = try_parse_file(&agent_md) {
155                    specs.push(spec);
156                }
157            }
158        }
159    }
160    specs
161}
162
163/// Discover custom agents from standard locations (project + user).
164///
165/// Returns the merged list with project-scope taking precedence on
166/// name collision. Returns an empty `Vec` when no discovery dir exists.
167pub fn discover_standard_locations(cwd: &Path) -> Vec<SubagentSpec> {
168    let mut merged: Vec<SubagentSpec> = Vec::new();
169
170    let user_dir = user_agents_dir();
171    if let Some(u) = user_dir.as_deref() {
172        merged.extend(load_custom_agents_from(u));
173    }
174
175    for dir_rel in [DEFAULT_PROJECT_DIR, CLAUDE_COMPAT_DIR] {
176        let project_dir = cwd.join(dir_rel);
177        if project_dir.is_dir() {
178            let project_specs = load_custom_agents_from(&project_dir);
179            for spec in project_specs {
180                merged.retain(|s| s.name != spec.name);
181                merged.push(spec);
182            }
183            break;
184        }
185    }
186
187    merged
188}
189
190/// Register discovered agents into a [`SubagentRegistry`]. Returns the
191/// number of specs registered.
192pub fn register_discovered_into(registry: &mut SubagentRegistry, cwd: &Path) -> usize {
193    let specs = discover_standard_locations(cwd);
194    let n = specs.len();
195    for spec in specs {
196        registry.register(spec);
197    }
198    n
199}
200
201fn try_parse_file(path: &Path) -> Option<SubagentSpec> {
202    let content = fs::read_to_string(path).ok()?;
203    parse_agent_md(&content).ok()
204}
205
206fn split_at_fence(after_open: &str) -> Option<(&str, &str)> {
207    for (idx, line_start) in line_starts(after_open) {
208        let rest_at = &after_open[line_start..];
209        if let Some(line_end) = rest_at.find('\n') {
210            let line = &rest_at[..line_end];
211            if line.trim_end_matches('\r') == "---" {
212                let front_end = line_start;
213                let body_start = line_start + line_end + 1;
214                let _ = idx;
215                return Some((&after_open[..front_end], &after_open[body_start..]));
216            }
217        } else if rest_at.trim_end_matches('\r') == "---" {
218            return Some((&after_open[..line_start], ""));
219        }
220    }
221    None
222}
223
224fn line_starts(s: &str) -> impl Iterator<Item = (usize, usize)> + '_ {
225    std::iter::once((0usize, 0usize))
226        .chain(s.match_indices('\n').enumerate().map(|(i, (pos, _))| (i + 1, pos + 1)))
227}
228
229fn user_agents_dir() -> Option<PathBuf> {
230    let home = std::env::var_os("HOME")?;
231    let home = PathBuf::from(home);
232    let candidate = home.join(".config").join("apr").join("agents");
233    if candidate.is_dir() {
234        Some(candidate)
235    } else {
236        None
237    }
238}
239
240#[cfg(test)]
241mod tests;