1use crate::blueprint::{AgentDef, AgentKind, AgentProfile};
50use serde_json::{Map, Value};
51use std::fs;
52use std::path::Path;
53
54#[derive(Debug)]
56pub enum LoadError {
57 Io(std::io::Error),
59 NoFrontmatter {
62 path: String,
64 },
65 Yaml {
67 path: String,
69 source: serde_yaml::Error,
71 },
72 MissingName {
75 path: String,
77 },
78}
79
80impl std::fmt::Display for LoadError {
81 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 match self {
83 LoadError::Io(e) => write!(f, "io error: {e}"),
84 LoadError::NoFrontmatter { path } => {
85 write!(f, "no frontmatter delimiter `---` in {path}")
86 }
87 LoadError::Yaml { path, source } => write!(f, "yaml parse error in {path}: {source}"),
88 LoadError::MissingName { path } => {
89 write!(f, "frontmatter missing required `name` field in {path}")
90 }
91 }
92 }
93}
94
95impl std::error::Error for LoadError {}
96
97impl From<std::io::Error> for LoadError {
98 fn from(e: std::io::Error) -> Self {
99 LoadError::Io(e)
100 }
101}
102
103pub fn load_file(path: impl AsRef<Path>, kind: AgentKind) -> Result<AgentDef, LoadError> {
113 let path = path.as_ref();
114 let text = fs::read_to_string(path)?;
115 parse(&text, &path.display().to_string(), kind)
116}
117
118pub fn load_dir(dir: impl AsRef<Path>, kind: AgentKind) -> Result<Vec<AgentDef>, LoadError> {
129 let dir = dir.as_ref();
130 let mut entries: Vec<_> = fs::read_dir(dir)?
131 .filter_map(|e| e.ok())
132 .map(|e| e.path())
133 .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("md"))
134 .collect();
135 entries.sort();
136 let mut out = Vec::new();
137 for p in entries {
138 match load_file(&p, kind.clone()) {
139 Ok(def) => out.push(def),
140 Err(LoadError::NoFrontmatter { .. }) => continue,
141 Err(e) => return Err(e),
142 }
143 }
144 Ok(out)
145}
146
147pub fn parse(text: &str, source_label: &str, kind: AgentKind) -> Result<AgentDef, LoadError> {
151 let (front, body) = split_frontmatter(text).ok_or_else(|| LoadError::NoFrontmatter {
152 path: source_label.into(),
153 })?;
154 let yaml: Value = serde_yaml::from_str(front).map_err(|e| LoadError::Yaml {
155 path: source_label.into(),
156 source: e,
157 })?;
158 let obj = yaml.as_object().cloned().unwrap_or_default();
159
160 let name = obj
161 .get("name")
162 .and_then(|v| v.as_str())
163 .map(|s| s.to_string())
164 .ok_or_else(|| LoadError::MissingName {
165 path: source_label.into(),
166 })?;
167
168 let description = obj
169 .get("description")
170 .and_then(|v| v.as_str())
171 .map(|s| s.trim().to_string());
172 let model = obj
173 .get("model")
174 .and_then(|v| v.as_str())
175 .map(|s| s.to_string());
176 let effort = obj
177 .get("effort")
178 .and_then(|v| v.as_str())
179 .map(|s| s.to_string());
180 let tools = obj.get("tools").map(normalize_tools).unwrap_or_default();
181 let worker_binding = obj
182 .get("worker_binding")
183 .and_then(|v| v.as_str())
184 .map(|s| s.to_string());
185
186 let known = [
189 "name",
190 "description",
191 "model",
192 "effort",
193 "tools",
194 "worker_binding",
195 ];
196 let mut extras = Map::new();
197 for (k, v) in &obj {
198 if !known.contains(&k.as_str()) {
199 extras.insert(k.clone(), v.clone());
200 }
201 }
202
203 let version_hash = Some(compute_body_hash(body));
204
205 let profile = AgentProfile {
206 system_prompt: body.to_string(),
207 model,
208 effort,
209 tools,
210 description: description.clone(),
211 extras: if extras.is_empty() {
212 Value::Null
213 } else {
214 Value::Object(extras)
215 },
216 version_hash,
217 worker_binding,
218 };
219
220 Ok(AgentDef {
221 name,
222 kind,
223 spec: Value::Null,
224 profile: Some(profile),
225 meta: None,
226 })
227}
228
229pub fn compute_body_hash(body: &str) -> String {
238 blake3::hash(body.as_bytes()).to_hex().to_string()
239}
240
241fn split_frontmatter(text: &str) -> Option<(&str, &str)> {
244 let t = text
245 .strip_prefix("---\n")
246 .or_else(|| text.strip_prefix("---\r\n"))?;
247 let mut search_from = 0;
249 while let Some(idx) = t[search_from..].find("---") {
250 let abs = search_from + idx;
251 if abs == 0 || t.as_bytes()[abs - 1] == b'\n' {
253 let after = &t[abs + 3..];
254 let body = after
255 .strip_prefix("\r\n")
256 .or_else(|| after.strip_prefix('\n'))
257 .unwrap_or(after);
258 return Some((&t[..abs], body));
259 }
260 search_from = abs + 3;
261 }
262 None
263}
264
265fn normalize_tools(v: &Value) -> Vec<String> {
269 if let Some(arr) = v.as_array() {
270 return arr
271 .iter()
272 .filter_map(|x| x.as_str().map(|s| s.trim().to_string()))
273 .filter(|s| !s.is_empty())
274 .collect();
275 }
276 if let Some(s) = v.as_str() {
277 return s
278 .split(',')
279 .map(|s| s.trim().to_string())
280 .filter(|s| !s.is_empty())
281 .collect();
282 }
283 Vec::new()
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 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";
291
292 #[test]
293 fn parses_full_frontmatter() {
294 let def = parse(SAMPLE, "sample", AgentKind::Operator).expect("parse ok");
295 assert_eq!(def.name, "impl-lead");
296 assert!(matches!(def.kind, AgentKind::Operator));
297 let p = def.profile.expect("profile present");
298 assert_eq!(p.model.as_deref(), Some("sonnet"));
299 assert_eq!(p.effort.as_deref(), Some("high"));
300 assert_eq!(p.tools, vec!["Read", "Edit", "Grep"]);
301 assert_eq!(p.description.as_deref(), Some("Implementation worker"));
302 assert!(p
303 .system_prompt
304 .starts_with("You are the implementation lead."));
305 let extras = p.extras.as_object().expect("extras object");
307 assert_eq!(
308 extras.get("permissionMode").and_then(|v| v.as_str()),
309 Some("bypassPermissions")
310 );
311 assert_eq!(extras.get("memory").and_then(|v| v.as_str()), Some("user"));
312 assert_eq!(extras.get("abtest").and_then(|v| v.as_bool()), Some(true));
313 assert_eq!(p.worker_binding, None);
315 assert!(extras.get("worker_binding").is_none());
316 }
317
318 #[test]
319 fn worker_binding_extracted_as_first_class_field() {
320 let t = "---\nname: x\nworker_binding: mse-worker-coder\n---\nbody\n";
321 let def = parse(t, "x", AgentKind::Operator).unwrap();
322 let p = def.profile.expect("profile present");
323 assert_eq!(p.worker_binding.as_deref(), Some("mse-worker-coder"));
324 assert!(matches!(p.extras, Value::Null));
326 }
327
328 #[test]
329 fn worker_binding_absent_is_none_not_extras() {
330 let t = "---\nname: x\nmodel: sonnet\n---\nbody\n";
331 let def = parse(t, "x", AgentKind::Operator).unwrap();
332 let p = def.profile.expect("profile present");
333 assert_eq!(p.worker_binding, None);
334 }
335
336 #[test]
337 fn tools_accepts_yaml_array() {
338 let t = "---\nname: x\ntools:\n - Read\n - Edit\n---\nbody\n";
339 let def = parse(t, "x", AgentKind::Operator).unwrap();
340 assert_eq!(def.profile.unwrap().tools, vec!["Read", "Edit"]);
341 }
342
343 #[test]
344 fn missing_name_errors() {
345 let t = "---\nmodel: sonnet\n---\nbody\n";
346 assert!(matches!(
347 parse(t, "x", AgentKind::Operator),
348 Err(LoadError::MissingName { .. })
349 ));
350 }
351
352 #[test]
353 fn no_frontmatter_errors() {
354 let t = "plain body without frontmatter";
355 assert!(matches!(
356 parse(t, "x", AgentKind::Operator),
357 Err(LoadError::NoFrontmatter { .. })
358 ));
359 }
360
361 #[test]
362 fn body_preserves_markdown() {
363 let t = "---\nname: x\n---\n# Heading\n\nparagraph with `code`.\n";
364 let p = parse(t, "x", AgentKind::Operator).unwrap().profile.unwrap();
365 assert_eq!(p.system_prompt, "# Heading\n\nparagraph with `code`.\n");
366 }
367
368 #[test]
369 fn populates_version_hash_from_body() {
370 let def = parse(SAMPLE, "sample", AgentKind::Operator).unwrap();
371 let p = def.profile.unwrap();
372 let expected = compute_body_hash(&p.system_prompt);
373 assert_eq!(p.version_hash.as_deref(), Some(expected.as_str()));
374 assert_eq!(expected.len(), 64);
376 }
377
378 #[test]
379 fn version_hash_changes_with_body() {
380 let t1 = "---\nname: x\n---\nbody one\n";
381 let t2 = "---\nname: x\n---\nbody two\n";
382 let h1 = parse(t1, "x", AgentKind::Operator)
383 .unwrap()
384 .profile
385 .unwrap()
386 .version_hash;
387 let h2 = parse(t2, "x", AgentKind::Operator)
388 .unwrap()
389 .profile
390 .unwrap()
391 .version_hash;
392 assert!(h1.is_some() && h2.is_some());
393 assert_ne!(h1, h2);
394 }
395
396 #[test]
397 fn version_hash_stable_across_frontmatter_reorder() {
398 let t1 = "---\nname: x\nmodel: sonnet\n---\nsame body\n";
400 let t2 = "---\nmodel: sonnet\nname: x\n---\nsame body\n";
401 let h1 = parse(t1, "x", AgentKind::Operator)
402 .unwrap()
403 .profile
404 .unwrap()
405 .version_hash;
406 let h2 = parse(t2, "x", AgentKind::Operator)
407 .unwrap()
408 .profile
409 .unwrap()
410 .version_hash;
411 assert_eq!(h1, h2);
412 }
413}