Skip to main content

byob_plugins/
agents.rs

1//! Subagent definitions: Markdown files with YAML frontmatter, compatible with Claude Code's
2//! `.claude/agents/*.md`. The frontmatter has `name`, `description`, optional `tools` (a
3//! comma list or a YAML list) and optional `model`; the body is the subagent's system
4//! prompt. Unknown keys (`color`, `permissionMode`, …) are kept in [`AgentDef::meta`] and
5//! otherwise ignored, so files written for Claude Code load unchanged.
6//!
7//! byob looks in `<workspace>/.byob/agents`, `<workspace>/.claude/agents`, `<config>/agents`
8//! and each plugin's `agents/` folder (see [`crate::Plugin::agents`]).
9
10use std::collections::BTreeMap;
11use std::fs;
12use std::path::{Path, PathBuf};
13
14use serde::Serialize;
15
16use crate::LoadError;
17use crate::skills::{frontmatter, read_capped, slug};
18
19/// Biggest agent file we read.
20const MAX_AGENT_BYTES: u64 = 256 * 1024;
21/// Most agent files read from one folder.
22const MAX_AGENTS_PER_DIR: usize = 200;
23/// Longest description kept (they go into every system prompt).
24const MAX_DESCRIPTION_CHARS: usize = 1000;
25
26/// One subagent definition.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
28pub struct AgentDef {
29    /// `[a-z0-9][a-z0-9_-]*`, at most 64 characters; what `delegate { agent }` names.
30    pub name: String,
31    /// When to use it (listed in the main system prompt).
32    pub description: String,
33    /// Tool names it may use as written (Claude Code names like `Read` included; see
34    /// [`tool_aliases`]). `None`: every tool.
35    pub tools: Option<Vec<String>>,
36    /// A model role (`fast`), a raw model id, or Claude Code's `inherit`/`sonnet`/… .
37    pub model: Option<String>,
38    /// The system prompt (the file's body).
39    pub prompt: String,
40    /// The file it came from.
41    pub source: PathBuf,
42    /// Frontmatter keys byob doesn't use, as text.
43    pub meta: BTreeMap<String, String>,
44}
45
46/// `[a-z0-9][a-z0-9_-]{0,63}`
47pub fn agent_name_ok(n: &str) -> bool {
48    let b = n.as_bytes();
49    !b.is_empty()
50        && b.len() <= 64
51        && (b[0].is_ascii_lowercase() || b[0].is_ascii_digit())
52        && b.iter().all(|&c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-' || c == b'_')
53}
54
55/// A name as written (`Code Reviewer`) → a valid agent name (`code-reviewer`).
56pub fn agent_name(n: &str) -> String {
57    let n = n.trim();
58    if agent_name_ok(n) { n.to_owned() } else { slug(n) }
59}
60
61/// Parse an agent file. `file` names the fallback agent (its stem) when the frontmatter has
62/// no `name`; a missing `description` falls back to the body's first line.
63pub fn parse_agent(text: &str, file: &Path) -> Result<AgentDef, String> {
64    let shown = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
65    let stem = file.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
66    let (mut meta, body) = frontmatter(text);
67    let body = body.trim();
68    let name = agent_name(&meta.remove("name").filter(|n| !n.trim().is_empty()).unwrap_or(stem));
69    if !agent_name_ok(&name) {
70        return Err(format!("{shown}: the agent needs a name of letters, digits and dashes"));
71    }
72    if body.is_empty() {
73        return Err(format!("{shown}: the file has no instructions after the frontmatter"));
74    }
75    let description = meta
76        .remove("description")
77        .map(|d| d.replace("\\n", " "))
78        .filter(|d| !d.trim().is_empty())
79        .or_else(|| body.lines().map(|l| l.trim().trim_start_matches('#').trim()).find(|l| !l.is_empty()).map(str::to_owned))
80        .unwrap_or_default();
81    let description: String = description.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(MAX_DESCRIPTION_CHARS).collect();
82    let tools = meta.remove("tools").map(|t| parse_tools(&t)).filter(|t| !t.is_empty());
83    let model = meta.remove("model").map(|m| m.trim().to_owned()).filter(|m| !m.is_empty());
84    Ok(AgentDef { name, description, tools, model, prompt: body.to_owned(), source: file.to_path_buf(), meta })
85}
86
87/// `Read, Grep` / `[Read, Grep]` / a YAML list (already joined by [`frontmatter`]) / space
88/// separated. `Bash(git:*)` is Claude Code's pattern syntax: only the tool name counts.
89fn parse_tools(raw: &str) -> Vec<String> {
90    let mut out: Vec<String> = Vec::new();
91    for part in raw.split([',', ' ', '\t', '\n']) {
92        let name = part.split('(').next().unwrap_or_default().trim().trim_matches(['"', '\'', '[', ']']);
93        // A pattern's inside (`git:*)`) is not a tool.
94        if name.is_empty() || part.trim_start().starts_with(['*', ')']) || name.contains([':', '*', ')']) {
95            continue;
96        }
97        if !out.iter().any(|t| t == name) {
98            out.push(name.to_owned());
99        }
100    }
101    out
102}
103
104/// What a Claude Code tool name means in byob. Names byob has (`read_file`) map to
105/// themselves; Claude Code's own tools map to the closest byob tools; tools with no
106/// counterpart (`WebSearch`, `Task`) map to nothing.
107pub fn tool_aliases(name: &str) -> Vec<&str> {
108    match name {
109        "Read" | "NotebookRead" => vec!["read_file", "read_artifact"],
110        "Write" => vec!["write_file", "create_artifact"],
111        "Edit" | "MultiEdit" | "NotebookEdit" => vec!["write_file", "update_artifact"],
112        "Glob" | "LS" => vec!["list_files"],
113        "Grep" => vec!["list_files", "read_file"],
114        "Bash" | "BashOutput" => vec!["run_command"],
115        "Task" | "Agent" | "WebFetch" | "WebSearch" | "TodoWrite" | "KillBash" | "KillShell" | "ExitPlanMode" | "SlashCommand" | "Skill" => Vec::new(),
116        other => vec![other],
117    }
118}
119
120/// Every `<dir>/*.md` (sorted by file name). Bad files become errors; the rest still load.
121/// A missing `dir` is simply empty. Later files with an existing name are reported and skipped.
122pub fn load_agents_dir(dir: &Path) -> (Vec<AgentDef>, Vec<LoadError>) {
123    let mut agents: Vec<AgentDef> = Vec::new();
124    let mut errors = Vec::new();
125    let Ok(rd) = fs::read_dir(dir) else { return (agents, errors) };
126    let mut files: Vec<PathBuf> = rd
127        .flatten()
128        .filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
129        .map(|e| e.path())
130        .filter(|p| p.is_file() && p.extension().is_some_and(|x| x.eq_ignore_ascii_case("md")))
131        .collect();
132    files.sort();
133    if files.len() > MAX_AGENTS_PER_DIR {
134        errors.push(LoadError { path: dir.to_path_buf(), message: format!("only the first {MAX_AGENTS_PER_DIR} agent files are read") });
135        files.truncate(MAX_AGENTS_PER_DIR);
136    }
137    for file in files {
138        let parsed = read_capped(&file, MAX_AGENT_BYTES).and_then(|text| parse_agent(&text, &file));
139        match parsed {
140            Ok(a) if agents.iter().any(|b| b.name == a.name) => {
141                errors.push(LoadError { path: file, message: format!("agent \"{}\" is defined twice in this folder", a.name) });
142            }
143            Ok(a) => agents.push(a),
144            Err(message) => errors.push(LoadError { path: file, message }),
145        }
146    }
147    (agents, errors)
148}
149
150/// Merge agent lists, earlier lists winning by name (workspace before user before plugins).
151pub fn merge_agents(lists: impl IntoIterator<Item = Vec<AgentDef>>) -> Vec<AgentDef> {
152    let mut out: Vec<AgentDef> = Vec::new();
153    for list in lists {
154        for a in list {
155            if !out.iter().any(|b| b.name == a.name) {
156                out.push(a);
157            }
158        }
159    }
160    out
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    fn parse(text: &str) -> AgentDef {
168        parse_agent(text, Path::new("/x/agents/file-name.md")).unwrap()
169    }
170
171    #[test]
172    fn claude_code_sample_loads_unchanged() {
173        let text = "---\nname: code-reviewer\ndescription: Expert code review specialist. Proactively reviews code for quality, security, and maintainability. Use immediately after writing or modifying code.\ntools: Read, Grep, Glob, Bash\nmodel: inherit\ncolor: blue\n---\n\nYou are a senior code reviewer ensuring high standards of code quality and security.\n\nWhen invoked:\n1. Run git diff to see recent changes\n";
174        let a = parse(text);
175        assert_eq!(a.name, "code-reviewer");
176        assert!(a.description.starts_with("Expert code review specialist."));
177        assert_eq!(a.tools.as_deref(), Some(&["Read".to_owned(), "Grep".into(), "Glob".into(), "Bash".into()][..]));
178        assert_eq!(a.model.as_deref(), Some("inherit"));
179        assert!(a.prompt.starts_with("You are a senior code reviewer"));
180        assert_eq!(a.meta.get("color").map(String::as_str), Some("blue"));
181    }
182
183    #[test]
184    fn tools_in_every_list_form() {
185        let yaml = parse("---\nname: a\ndescription: d\ntools:\n  - Read\n  - \"Bash\"\n---\nbody");
186        assert_eq!(yaml.tools.unwrap(), ["Read", "Bash"]);
187        let inline = parse("---\nname: a\ndescription: d\ntools: [read_file, list_files]\n---\nbody");
188        assert_eq!(inline.tools.unwrap(), ["read_file", "list_files"]);
189        let patterns = parse("---\nname: a\ndescription: d\ntools: Bash(git diff:*), Read\n---\nbody");
190        assert_eq!(patterns.tools.unwrap(), ["Bash", "Read"]);
191        let spaces = parse("---\nname: a\ndescription: d\ntools: Read Grep\n---\nbody");
192        assert_eq!(spaces.tools.unwrap(), ["Read", "Grep"]);
193        // No tools key, or an empty one: every tool.
194        assert_eq!(parse("---\nname: a\ndescription: d\n---\nbody").tools, None);
195        assert_eq!(parse("---\nname: a\ndescription: d\ntools:\n---\nbody").tools, None);
196    }
197
198    #[test]
199    fn missing_frontmatter_falls_back_to_the_file() {
200        let a = parse("# Summariser\n\nSummarise the files you are given.");
201        assert_eq!(a.name, "file-name");
202        assert_eq!(a.description, "Summariser");
203        assert_eq!((a.tools, a.model), (None, None));
204        // Names are normalised; empty bodies refused.
205        assert_eq!(parse("---\nname: Code Reviewer\n---\nx").name, "code-reviewer");
206        assert!(parse_agent("---\nname: a\ndescription: d\n---\n  \n", Path::new("a.md")).unwrap_err().contains("no instructions"));
207        assert!(parse_agent("---\nname: '!!!'\n---\nx", Path::new("!!!.md")).is_err());
208    }
209
210    #[test]
211    fn claude_tools_map_to_yah_tools() {
212        assert_eq!(tool_aliases("Read"), ["read_file", "read_artifact"]);
213        assert_eq!(tool_aliases("Bash"), ["run_command"]);
214        assert!(tool_aliases("WebSearch").is_empty());
215        assert_eq!(tool_aliases("run_python"), ["run_python"]);
216    }
217
218    #[test]
219    fn folders_load_sorted_with_errors_kept_apart() {
220        let d = tempfile::tempdir().unwrap();
221        fs::write(d.path().join("b.md"), "---\nname: beta\ndescription: B\n---\nDo b.").unwrap();
222        fs::write(d.path().join("a.md"), "---\nname: alpha\ndescription: A\n---\nDo a.").unwrap();
223        fs::write(d.path().join("c.md"), "---\nname: alpha\ndescription: again\n---\nDo a.").unwrap();
224        fs::write(d.path().join("empty.md"), "---\nname: e\n---\n").unwrap();
225        fs::write(d.path().join("notes.txt"), "not an agent").unwrap();
226        let (agents, errors) = load_agents_dir(d.path());
227        assert_eq!(agents.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["alpha", "beta"]);
228        assert_eq!(errors.len(), 2, "{errors:?}");
229        assert!(load_agents_dir(&d.path().join("missing")).0.is_empty());
230        let merged = merge_agents([vec![agents[1].clone()], agents.clone()]);
231        assert_eq!(merged.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["beta", "alpha"]);
232    }
233}