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", "look_at_image"],
110        "Write" => vec!["write_file", "create_artifact"],
111        "Edit" | "MultiEdit" => vec!["edit_file", "update_artifact"],
112        "NotebookEdit" => vec!["write_file", "update_artifact"],
113        "Glob" | "LS" => vec!["list_files", "search_files"],
114        "Grep" => vec!["search_files"],
115        "Bash" | "BashOutput" => vec!["run_command"],
116        "Task" | "Agent" | "WebFetch" | "WebSearch" | "TodoWrite" | "KillBash" | "KillShell" | "ExitPlanMode" | "SlashCommand" | "Skill" => Vec::new(),
117        other => vec![other],
118    }
119}
120
121/// Every `<dir>/*.md` (sorted by file name). Bad files become errors; the rest still load.
122/// A missing `dir` is simply empty. Later files with an existing name are reported and skipped.
123pub fn load_agents_dir(dir: &Path) -> (Vec<AgentDef>, Vec<LoadError>) {
124    let mut agents: Vec<AgentDef> = Vec::new();
125    let mut errors = Vec::new();
126    let Ok(rd) = fs::read_dir(dir) else { return (agents, errors) };
127    let mut files: Vec<PathBuf> = rd
128        .flatten()
129        .filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
130        .map(|e| e.path())
131        .filter(|p| p.is_file() && p.extension().is_some_and(|x| x.eq_ignore_ascii_case("md")))
132        .collect();
133    files.sort();
134    if files.len() > MAX_AGENTS_PER_DIR {
135        errors.push(LoadError { path: dir.to_path_buf(), message: format!("only the first {MAX_AGENTS_PER_DIR} agent files are read") });
136        files.truncate(MAX_AGENTS_PER_DIR);
137    }
138    for file in files {
139        let parsed = read_capped(&file, MAX_AGENT_BYTES).and_then(|text| parse_agent(&text, &file));
140        match parsed {
141            Ok(a) if agents.iter().any(|b| b.name == a.name) => {
142                errors.push(LoadError { path: file, message: format!("agent \"{}\" is defined twice in this folder", a.name) });
143            }
144            Ok(a) => agents.push(a),
145            Err(message) => errors.push(LoadError { path: file, message }),
146        }
147    }
148    (agents, errors)
149}
150
151/// Merge agent lists, earlier lists winning by name (workspace before user before plugins).
152pub fn merge_agents(lists: impl IntoIterator<Item = Vec<AgentDef>>) -> Vec<AgentDef> {
153    let mut out: Vec<AgentDef> = Vec::new();
154    for list in lists {
155        for a in list {
156            if !out.iter().any(|b| b.name == a.name) {
157                out.push(a);
158            }
159        }
160    }
161    out
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    fn parse(text: &str) -> AgentDef {
169        parse_agent(text, Path::new("/x/agents/file-name.md")).unwrap()
170    }
171
172    #[test]
173    fn claude_code_sample_loads_unchanged() {
174        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";
175        let a = parse(text);
176        assert_eq!(a.name, "code-reviewer");
177        assert!(a.description.starts_with("Expert code review specialist."));
178        assert_eq!(a.tools.as_deref(), Some(&["Read".to_owned(), "Grep".into(), "Glob".into(), "Bash".into()][..]));
179        assert_eq!(a.model.as_deref(), Some("inherit"));
180        assert!(a.prompt.starts_with("You are a senior code reviewer"));
181        assert_eq!(a.meta.get("color").map(String::as_str), Some("blue"));
182    }
183
184    #[test]
185    fn tools_in_every_list_form() {
186        let yaml = parse("---\nname: a\ndescription: d\ntools:\n  - Read\n  - \"Bash\"\n---\nbody");
187        assert_eq!(yaml.tools.unwrap(), ["Read", "Bash"]);
188        let inline = parse("---\nname: a\ndescription: d\ntools: [read_file, list_files]\n---\nbody");
189        assert_eq!(inline.tools.unwrap(), ["read_file", "list_files"]);
190        let patterns = parse("---\nname: a\ndescription: d\ntools: Bash(git diff:*), Read\n---\nbody");
191        assert_eq!(patterns.tools.unwrap(), ["Bash", "Read"]);
192        let spaces = parse("---\nname: a\ndescription: d\ntools: Read Grep\n---\nbody");
193        assert_eq!(spaces.tools.unwrap(), ["Read", "Grep"]);
194        // No tools key, or an empty one: every tool.
195        assert_eq!(parse("---\nname: a\ndescription: d\n---\nbody").tools, None);
196        assert_eq!(parse("---\nname: a\ndescription: d\ntools:\n---\nbody").tools, None);
197    }
198
199    #[test]
200    fn missing_frontmatter_falls_back_to_the_file() {
201        let a = parse("# Summariser\n\nSummarise the files you are given.");
202        assert_eq!(a.name, "file-name");
203        assert_eq!(a.description, "Summariser");
204        assert_eq!((a.tools, a.model), (None, None));
205        // Names are normalised; empty bodies refused.
206        assert_eq!(parse("---\nname: Code Reviewer\n---\nx").name, "code-reviewer");
207        assert!(parse_agent("---\nname: a\ndescription: d\n---\n  \n", Path::new("a.md")).unwrap_err().contains("no instructions"));
208        assert!(parse_agent("---\nname: '!!!'\n---\nx", Path::new("!!!.md")).is_err());
209    }
210
211    #[test]
212    fn claude_tools_map_to_yah_tools() {
213        assert_eq!(tool_aliases("Read"), ["read_file", "read_artifact", "look_at_image"]);
214        assert_eq!(tool_aliases("Bash"), ["run_command"]);
215        assert!(tool_aliases("WebSearch").is_empty());
216        assert_eq!(tool_aliases("run_python"), ["run_python"]);
217    }
218
219    #[test]
220    fn folders_load_sorted_with_errors_kept_apart() {
221        let d = tempfile::tempdir().unwrap();
222        fs::write(d.path().join("b.md"), "---\nname: beta\ndescription: B\n---\nDo b.").unwrap();
223        fs::write(d.path().join("a.md"), "---\nname: alpha\ndescription: A\n---\nDo a.").unwrap();
224        fs::write(d.path().join("c.md"), "---\nname: alpha\ndescription: again\n---\nDo a.").unwrap();
225        fs::write(d.path().join("empty.md"), "---\nname: e\n---\n").unwrap();
226        fs::write(d.path().join("notes.txt"), "not an agent").unwrap();
227        let (agents, errors) = load_agents_dir(d.path());
228        assert_eq!(agents.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["alpha", "beta"]);
229        assert_eq!(errors.len(), 2, "{errors:?}");
230        assert!(load_agents_dir(&d.path().join("missing")).0.is_empty());
231        let merged = merge_agents([vec![agents[1].clone()], agents.clone()]);
232        assert_eq!(merged.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["beta", "alpha"]);
233    }
234}