1use 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
19const MAX_AGENT_BYTES: u64 = 256 * 1024;
21const MAX_AGENTS_PER_DIR: usize = 200;
23const MAX_DESCRIPTION_CHARS: usize = 1000;
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
28pub struct AgentDef {
29 pub name: String,
31 pub description: String,
33 pub tools: Option<Vec<String>>,
36 pub model: Option<String>,
38 pub prompt: String,
40 pub source: PathBuf,
42 pub meta: BTreeMap<String, String>,
44}
45
46pub 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
55pub 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
61pub 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
87fn 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 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
104pub 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
121pub 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
151pub 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 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 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}