1use crate::blueprint::{AgentDef, AgentKind, AgentProfile};
40use serde_json::{Map, Value};
41use std::fs;
42use std::path::Path;
43
44#[derive(Debug)]
46pub enum LoadError {
47 Io(std::io::Error),
49 NoFrontmatter {
52 path: String,
54 },
55 Yaml {
57 path: String,
59 source: serde_yaml::Error,
61 },
62 MissingName {
65 path: String,
67 },
68}
69
70impl std::fmt::Display for LoadError {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 match self {
73 LoadError::Io(e) => write!(f, "io error: {e}"),
74 LoadError::NoFrontmatter { path } => {
75 write!(f, "no frontmatter delimiter `---` in {path}")
76 }
77 LoadError::Yaml { path, source } => write!(f, "yaml parse error in {path}: {source}"),
78 LoadError::MissingName { path } => {
79 write!(f, "frontmatter missing required `name` field in {path}")
80 }
81 }
82 }
83}
84
85impl std::error::Error for LoadError {}
86
87impl From<std::io::Error> for LoadError {
88 fn from(e: std::io::Error) -> Self {
89 LoadError::Io(e)
90 }
91}
92
93pub fn load_file(path: impl AsRef<Path>, kind: AgentKind) -> Result<AgentDef, LoadError> {
103 let path = path.as_ref();
104 let text = fs::read_to_string(path)?;
105 parse(&text, &path.display().to_string(), kind)
106}
107
108pub fn load_dir(dir: impl AsRef<Path>, kind: AgentKind) -> Result<Vec<AgentDef>, LoadError> {
119 let dir = dir.as_ref();
120 let mut entries: Vec<_> = fs::read_dir(dir)?
121 .filter_map(|e| e.ok())
122 .map(|e| e.path())
123 .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("md"))
124 .collect();
125 entries.sort();
126 let mut out = Vec::new();
127 for p in entries {
128 match load_file(&p, kind.clone()) {
129 Ok(def) => out.push(def),
130 Err(LoadError::NoFrontmatter { .. }) => continue,
131 Err(e) => return Err(e),
132 }
133 }
134 Ok(out)
135}
136
137pub fn parse(text: &str, source_label: &str, kind: AgentKind) -> Result<AgentDef, LoadError> {
141 let (front, body) = split_frontmatter(text).ok_or_else(|| LoadError::NoFrontmatter {
142 path: source_label.into(),
143 })?;
144 let yaml: Value = serde_yaml::from_str(front).map_err(|e| LoadError::Yaml {
145 path: source_label.into(),
146 source: e,
147 })?;
148 let obj = yaml.as_object().cloned().unwrap_or_default();
149
150 let name = obj
151 .get("name")
152 .and_then(|v| v.as_str())
153 .map(|s| s.to_string())
154 .ok_or_else(|| LoadError::MissingName {
155 path: source_label.into(),
156 })?;
157
158 let description = obj
159 .get("description")
160 .and_then(|v| v.as_str())
161 .map(|s| s.trim().to_string());
162 let model = obj
163 .get("model")
164 .and_then(|v| v.as_str())
165 .map(|s| s.to_string());
166 let effort = obj
167 .get("effort")
168 .and_then(|v| v.as_str())
169 .map(|s| s.to_string());
170 let tools = obj.get("tools").map(normalize_tools).unwrap_or_default();
171
172 let known = ["name", "description", "model", "effort", "tools"];
175 let mut extras = Map::new();
176 for (k, v) in &obj {
177 if !known.contains(&k.as_str()) {
178 extras.insert(k.clone(), v.clone());
179 }
180 }
181
182 let version_hash = Some(compute_body_hash(body));
183
184 let profile = AgentProfile {
185 system_prompt: body.to_string(),
186 model,
187 effort,
188 tools,
189 description: description.clone(),
190 extras: if extras.is_empty() {
191 Value::Null
192 } else {
193 Value::Object(extras)
194 },
195 version_hash,
196 };
197
198 Ok(AgentDef {
199 name,
200 kind,
201 spec: Value::Null,
202 profile: Some(profile),
203 meta: None,
204 })
205}
206
207pub fn compute_body_hash(body: &str) -> String {
216 blake3::hash(body.as_bytes()).to_hex().to_string()
217}
218
219fn split_frontmatter(text: &str) -> Option<(&str, &str)> {
222 let t = text
223 .strip_prefix("---\n")
224 .or_else(|| text.strip_prefix("---\r\n"))?;
225 let mut search_from = 0;
227 while let Some(idx) = t[search_from..].find("---") {
228 let abs = search_from + idx;
229 if abs == 0 || t.as_bytes()[abs - 1] == b'\n' {
231 let after = &t[abs + 3..];
232 let body = after
233 .strip_prefix("\r\n")
234 .or_else(|| after.strip_prefix('\n'))
235 .unwrap_or(after);
236 return Some((&t[..abs], body));
237 }
238 search_from = abs + 3;
239 }
240 None
241}
242
243fn normalize_tools(v: &Value) -> Vec<String> {
247 if let Some(arr) = v.as_array() {
248 return arr
249 .iter()
250 .filter_map(|x| x.as_str().map(|s| s.trim().to_string()))
251 .filter(|s| !s.is_empty())
252 .collect();
253 }
254 if let Some(s) = v.as_str() {
255 return s
256 .split(',')
257 .map(|s| s.trim().to_string())
258 .filter(|s| !s.is_empty())
259 .collect();
260 }
261 Vec::new()
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 const SAMPLE: &str = "---\nname: impl-lead\ndescription: Implementation worker\nmodel: sonnet\neffort: high\ntools: Read, Edit, Grep\npermissionMode: bypassPermissions\nmemory: user\nabtest: true\n---\nYou are the implementation lead.\n\nWork in the caller-provided task directory.\n";
269
270 #[test]
271 fn parses_full_frontmatter() {
272 let def = parse(SAMPLE, "sample", AgentKind::Operator).expect("parse ok");
273 assert_eq!(def.name, "impl-lead");
274 assert!(matches!(def.kind, AgentKind::Operator));
275 let p = def.profile.expect("profile present");
276 assert_eq!(p.model.as_deref(), Some("sonnet"));
277 assert_eq!(p.effort.as_deref(), Some("high"));
278 assert_eq!(p.tools, vec!["Read", "Edit", "Grep"]);
279 assert_eq!(p.description.as_deref(), Some("Implementation worker"));
280 assert!(p
281 .system_prompt
282 .starts_with("You are the implementation lead."));
283 let extras = p.extras.as_object().expect("extras object");
285 assert_eq!(
286 extras.get("permissionMode").and_then(|v| v.as_str()),
287 Some("bypassPermissions")
288 );
289 assert_eq!(extras.get("memory").and_then(|v| v.as_str()), Some("user"));
290 assert_eq!(extras.get("abtest").and_then(|v| v.as_bool()), Some(true));
291 }
292
293 #[test]
294 fn tools_accepts_yaml_array() {
295 let t = "---\nname: x\ntools:\n - Read\n - Edit\n---\nbody\n";
296 let def = parse(t, "x", AgentKind::Operator).unwrap();
297 assert_eq!(def.profile.unwrap().tools, vec!["Read", "Edit"]);
298 }
299
300 #[test]
301 fn missing_name_errors() {
302 let t = "---\nmodel: sonnet\n---\nbody\n";
303 assert!(matches!(
304 parse(t, "x", AgentKind::Operator),
305 Err(LoadError::MissingName { .. })
306 ));
307 }
308
309 #[test]
310 fn no_frontmatter_errors() {
311 let t = "plain body without frontmatter";
312 assert!(matches!(
313 parse(t, "x", AgentKind::Operator),
314 Err(LoadError::NoFrontmatter { .. })
315 ));
316 }
317
318 #[test]
319 fn body_preserves_markdown() {
320 let t = "---\nname: x\n---\n# Heading\n\nparagraph with `code`.\n";
321 let p = parse(t, "x", AgentKind::Operator).unwrap().profile.unwrap();
322 assert_eq!(p.system_prompt, "# Heading\n\nparagraph with `code`.\n");
323 }
324
325 #[test]
326 fn populates_version_hash_from_body() {
327 let def = parse(SAMPLE, "sample", AgentKind::Operator).unwrap();
328 let p = def.profile.unwrap();
329 let expected = compute_body_hash(&p.system_prompt);
330 assert_eq!(p.version_hash.as_deref(), Some(expected.as_str()));
331 assert_eq!(expected.len(), 64);
333 }
334
335 #[test]
336 fn version_hash_changes_with_body() {
337 let t1 = "---\nname: x\n---\nbody one\n";
338 let t2 = "---\nname: x\n---\nbody two\n";
339 let h1 = parse(t1, "x", AgentKind::Operator)
340 .unwrap()
341 .profile
342 .unwrap()
343 .version_hash;
344 let h2 = parse(t2, "x", AgentKind::Operator)
345 .unwrap()
346 .profile
347 .unwrap()
348 .version_hash;
349 assert!(h1.is_some() && h2.is_some());
350 assert_ne!(h1, h2);
351 }
352
353 #[test]
354 fn version_hash_stable_across_frontmatter_reorder() {
355 let t1 = "---\nname: x\nmodel: sonnet\n---\nsame body\n";
357 let t2 = "---\nmodel: sonnet\nname: x\n---\nsame body\n";
358 let h1 = parse(t1, "x", AgentKind::Operator)
359 .unwrap()
360 .profile
361 .unwrap()
362 .version_hash;
363 let h2 = parse(t2, "x", AgentKind::Operator)
364 .unwrap()
365 .profile
366 .unwrap()
367 .version_hash;
368 assert_eq!(h1, h2);
369 }
370}