1use mlua_swarm_schema::{AgentDef, AgentKind, AgentProfile, LintSetting};
60use serde_json::{Map, Value};
61use std::collections::BTreeMap;
62use std::fs;
63use std::path::Path;
64
65#[derive(Debug)]
67pub enum LoadError {
68 Io(std::io::Error),
70 NoFrontmatter {
73 path: String,
75 },
76 Yaml {
78 path: String,
80 source: serde_yaml::Error,
82 },
83 MissingName {
86 path: String,
88 },
89 Lints {
98 path: String,
100 detail: String,
102 },
103}
104
105impl std::fmt::Display for LoadError {
106 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107 match self {
108 LoadError::Io(e) => write!(f, "io error: {e}"),
109 LoadError::NoFrontmatter { path } => {
110 write!(f, "no frontmatter delimiter `---` in {path}")
111 }
112 LoadError::Yaml { path, source } => write!(f, "yaml parse error in {path}: {source}"),
113 LoadError::MissingName { path } => {
114 write!(f, "frontmatter missing required `name` field in {path}")
115 }
116 LoadError::Lints { path, detail } => {
117 write!(f, "invalid frontmatter `lints` in {path}: {detail}")
118 }
119 }
120 }
121}
122
123impl std::error::Error for LoadError {}
124
125impl From<std::io::Error> for LoadError {
126 fn from(e: std::io::Error) -> Self {
127 LoadError::Io(e)
128 }
129}
130
131pub fn load_file(path: impl AsRef<Path>, kind: AgentKind) -> Result<AgentDef, LoadError> {
141 let path = path.as_ref();
142 let text = fs::read_to_string(path)?;
143 parse(&text, &path.display().to_string(), kind)
144}
145
146pub fn load_dir(dir: impl AsRef<Path>, kind: AgentKind) -> Result<Vec<AgentDef>, LoadError> {
157 let dir = dir.as_ref();
158 let mut entries: Vec<_> = fs::read_dir(dir)?
159 .filter_map(|e| e.ok())
160 .map(|e| e.path())
161 .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("md"))
162 .collect();
163 entries.sort();
164 let mut out = Vec::new();
165 for p in entries {
166 match load_file(&p, kind.clone()) {
167 Ok(def) => out.push(def),
168 Err(LoadError::NoFrontmatter { .. }) => continue,
169 Err(e) => return Err(e),
170 }
171 }
172 Ok(out)
173}
174
175pub fn parse(text: &str, source_label: &str, kind: AgentKind) -> Result<AgentDef, LoadError> {
179 let (front, body) = split_frontmatter(text).ok_or_else(|| LoadError::NoFrontmatter {
180 path: source_label.into(),
181 })?;
182 let yaml: Value = serde_yaml::from_str(front).map_err(|e| LoadError::Yaml {
183 path: source_label.into(),
184 source: e,
185 })?;
186 let obj = yaml.as_object().cloned().unwrap_or_default();
187
188 let name = obj
189 .get("name")
190 .and_then(|v| v.as_str())
191 .map(|s| s.to_string())
192 .ok_or_else(|| LoadError::MissingName {
193 path: source_label.into(),
194 })?;
195
196 let description = obj
197 .get("description")
198 .and_then(|v| v.as_str())
199 .map(|s| s.trim().to_string());
200 let model = obj
201 .get("model")
202 .and_then(|v| v.as_str())
203 .map(|s| s.to_string());
204 let effort = obj
205 .get("effort")
206 .and_then(|v| v.as_str())
207 .map(|s| s.to_string());
208 let tools = obj.get("tools").map(normalize_tools).unwrap_or_default();
209 let worker_binding = obj
210 .get("worker_binding")
211 .and_then(|v| v.as_str())
212 .map(|s| s.to_string());
213 let lints = parse_lints(obj.get("lints"), source_label)?;
214
215 let known = [
218 "name",
219 "description",
220 "model",
221 "effort",
222 "tools",
223 "worker_binding",
224 "lints",
225 ];
226 let mut extras = Map::new();
227 for (k, v) in &obj {
228 if !known.contains(&k.as_str()) {
229 extras.insert(k.clone(), v.clone());
230 }
231 }
232
233 let version_hash = Some(compute_body_hash(body));
234
235 let profile = AgentProfile {
236 system_prompt: body.to_string(),
237 model,
238 effort,
239 tools,
240 description: description.clone(),
241 extras: if extras.is_empty() {
242 Value::Null
243 } else {
244 Value::Object(extras)
245 },
246 version_hash,
247 worker_binding,
248 };
249
250 Ok(AgentDef {
251 name,
252 kind,
253 spec: Value::Null,
254 profile: Some(profile),
255 meta: None,
256 runner: None,
262 runner_ref: None,
263 verdict: None,
268 lints,
269 max_concurrency: None,
273 })
274}
275
276fn parse_lints(
288 value: Option<&Value>,
289 source_label: &str,
290) -> Result<Option<BTreeMap<String, LintSetting>>, LoadError> {
291 let Some(value) = value else {
292 return Ok(None);
293 };
294 let obj = value.as_object().ok_or_else(|| LoadError::Lints {
295 path: source_label.into(),
296 detail: format!("expected a map of lint key to level, got {value}"),
297 })?;
298 let mut out = BTreeMap::new();
299 for (key, level) in obj {
300 let setting =
301 serde_json::from_value::<LintSetting>(level.clone()).map_err(|_| LoadError::Lints {
302 path: source_label.into(),
303 detail: format!(
304 "key `{key}` has level {level}, expected \"allow\", \"warn\" or \"deny\""
305 ),
306 })?;
307 out.insert(key.clone(), setting);
308 }
309 Ok(if out.is_empty() { None } else { Some(out) })
310}
311
312pub fn compute_body_hash(body: &str) -> String {
321 blake3::hash(body.as_bytes()).to_hex().to_string()
322}
323
324fn split_frontmatter(text: &str) -> Option<(&str, &str)> {
327 let t = text
328 .strip_prefix("---\n")
329 .or_else(|| text.strip_prefix("---\r\n"))?;
330 let mut search_from = 0;
332 while let Some(idx) = t[search_from..].find("---") {
333 let abs = search_from + idx;
334 if abs == 0 || t.as_bytes()[abs - 1] == b'\n' {
336 let after = &t[abs + 3..];
337 let body = after
338 .strip_prefix("\r\n")
339 .or_else(|| after.strip_prefix('\n'))
340 .unwrap_or(after);
341 return Some((&t[..abs], body));
342 }
343 search_from = abs + 3;
344 }
345 None
346}
347
348fn normalize_tools(v: &Value) -> Vec<String> {
352 if let Some(arr) = v.as_array() {
353 return arr
354 .iter()
355 .filter_map(|x| x.as_str().map(|s| s.trim().to_string()))
356 .filter(|s| !s.is_empty())
357 .collect();
358 }
359 if let Some(s) = v.as_str() {
360 return s
361 .split(',')
362 .map(|s| s.trim().to_string())
363 .filter(|s| !s.is_empty())
364 .collect();
365 }
366 Vec::new()
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 const SAMPLE: &str = "---\nname: implementer\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";
374
375 #[test]
376 fn parses_full_frontmatter() {
377 let def = parse(SAMPLE, "sample", AgentKind::Operator).expect("parse ok");
378 assert_eq!(def.name, "implementer");
379 assert!(matches!(def.kind, AgentKind::Operator));
380 let p = def.profile.expect("profile present");
381 assert_eq!(p.model.as_deref(), Some("sonnet"));
382 assert_eq!(p.effort.as_deref(), Some("high"));
383 assert_eq!(p.tools, vec!["Read", "Edit", "Grep"]);
384 assert_eq!(p.description.as_deref(), Some("Implementation worker"));
385 assert!(p
386 .system_prompt
387 .starts_with("You are the implementation lead."));
388 let extras = p.extras.as_object().expect("extras object");
390 assert_eq!(
391 extras.get("permissionMode").and_then(|v| v.as_str()),
392 Some("bypassPermissions")
393 );
394 assert_eq!(extras.get("memory").and_then(|v| v.as_str()), Some("user"));
395 assert_eq!(extras.get("abtest").and_then(|v| v.as_bool()), Some(true));
396 assert_eq!(p.worker_binding, None);
398 assert!(extras.get("worker_binding").is_none());
399 }
400
401 #[test]
402 fn worker_binding_extracted_as_first_class_field() {
403 let t = "---\nname: x\nworker_binding: code-worker\n---\nbody\n";
404 let def = parse(t, "x", AgentKind::Operator).unwrap();
405 let p = def.profile.expect("profile present");
406 assert_eq!(p.worker_binding.as_deref(), Some("code-worker"));
407 assert!(matches!(p.extras, Value::Null));
409 }
410
411 #[test]
412 fn worker_binding_absent_is_none_not_extras() {
413 let t = "---\nname: x\nmodel: sonnet\n---\nbody\n";
414 let def = parse(t, "x", AgentKind::Operator).unwrap();
415 let p = def.profile.expect("profile present");
416 assert_eq!(p.worker_binding, None);
417 }
418
419 #[test]
420 fn tools_accepts_yaml_array() {
421 let t = "---\nname: x\ntools:\n - Read\n - Edit\n---\nbody\n";
422 let def = parse(t, "x", AgentKind::Operator).unwrap();
423 assert_eq!(def.profile.unwrap().tools, vec!["Read", "Edit"]);
424 }
425
426 #[test]
427 fn missing_name_errors() {
428 let t = "---\nmodel: sonnet\n---\nbody\n";
429 assert!(matches!(
430 parse(t, "x", AgentKind::Operator),
431 Err(LoadError::MissingName { .. })
432 ));
433 }
434
435 #[test]
436 fn no_frontmatter_errors() {
437 let t = "plain body without frontmatter";
438 assert!(matches!(
439 parse(t, "x", AgentKind::Operator),
440 Err(LoadError::NoFrontmatter { .. })
441 ));
442 }
443
444 #[test]
445 fn body_preserves_markdown() {
446 let t = "---\nname: x\n---\n# Heading\n\nparagraph with `code`.\n";
447 let p = parse(t, "x", AgentKind::Operator).unwrap().profile.unwrap();
448 assert_eq!(p.system_prompt, "# Heading\n\nparagraph with `code`.\n");
449 }
450
451 #[test]
452 fn populates_version_hash_from_body() {
453 let def = parse(SAMPLE, "sample", AgentKind::Operator).unwrap();
454 let p = def.profile.unwrap();
455 let expected = compute_body_hash(&p.system_prompt);
456 assert_eq!(p.version_hash.as_deref(), Some(expected.as_str()));
457 assert_eq!(expected.len(), 64);
459 }
460
461 #[test]
462 fn version_hash_changes_with_body() {
463 let t1 = "---\nname: x\n---\nbody one\n";
464 let t2 = "---\nname: x\n---\nbody two\n";
465 let h1 = parse(t1, "x", AgentKind::Operator)
466 .unwrap()
467 .profile
468 .unwrap()
469 .version_hash;
470 let h2 = parse(t2, "x", AgentKind::Operator)
471 .unwrap()
472 .profile
473 .unwrap()
474 .version_hash;
475 assert!(h1.is_some() && h2.is_some());
476 assert_ne!(h1, h2);
477 }
478
479 #[test]
480 fn lints_frontmatter_populates_agent_def_lints() {
481 let t = "---\nname: researcher\nlints:\n agent-md-size: allow\n \"category:style\": deny\n---\nbody\n";
482 let def = parse(t, "researcher.md", AgentKind::Operator).unwrap();
483 let lints = def.lints.expect("lints present");
484 assert_eq!(lints.get("agent-md-size"), Some(&LintSetting::Allow));
485 assert_eq!(lints.get("category:style"), Some(&LintSetting::Deny));
486 let p = def.profile.expect("profile present");
488 assert!(matches!(p.extras, Value::Null));
489 }
490
491 #[test]
494 fn lints_unknown_key_passes_through() {
495 let t = "---\nname: reviewer\nlints:\n no-such-lint: warn\n---\nbody\n";
496 let def = parse(t, "reviewer.md", AgentKind::Operator).unwrap();
497 let lints = def.lints.expect("lints present");
498 assert_eq!(lints.get("no-such-lint"), Some(&LintSetting::Warn));
499 }
500
501 #[test]
502 fn lints_absent_or_empty_is_none() {
503 let absent = parse(SAMPLE, "sample", AgentKind::Operator).unwrap();
504 assert_eq!(absent.lints, None);
505 let empty = parse(
507 "---\nname: planner\nlints: {}\n---\nbody\n",
508 "planner.md",
509 AgentKind::Operator,
510 )
511 .unwrap();
512 assert_eq!(empty.lints, None);
513 }
514
515 #[test]
516 fn lints_invalid_value_errors_naming_key_and_value() {
517 let t = "---\nname: greeter\nlints:\n agent-md-size: forbid\n---\nbody\n";
518 let err = parse(t, "greeter.md", AgentKind::Operator).expect_err("invalid level rejected");
519 assert!(matches!(err, LoadError::Lints { .. }));
520 let msg = err.to_string();
521 assert!(msg.contains("agent-md-size"), "names the key, got: {msg}");
522 assert!(msg.contains("forbid"), "names the value, got: {msg}");
523 assert!(msg.contains("greeter.md"), "names the file, got: {msg}");
524 }
525
526 #[test]
529 fn lints_value_spelling_is_exact_lowercase() {
530 let t = "---\nname: greeter\nlints:\n all: ALLOW\n---\nbody\n";
531 assert!(matches!(
532 parse(t, "greeter.md", AgentKind::Operator),
533 Err(LoadError::Lints { .. })
534 ));
535 }
536
537 #[test]
538 fn lints_non_map_errors() {
539 let t = "---\nname: greeter\nlints: allow\n---\nbody\n";
540 let err = parse(t, "greeter.md", AgentKind::Operator).expect_err("scalar rejected");
541 assert!(err.to_string().contains("expected a map"), "got: {err}");
542 }
543
544 #[test]
545 fn version_hash_stable_across_frontmatter_reorder() {
546 let t1 = "---\nname: x\nmodel: sonnet\nlints:\n all: allow\n---\nsame body\n";
550 let t2 = "---\nlints:\n all: allow\nmodel: sonnet\nname: x\n---\nsame body\n";
551 let h1 = parse(t1, "x", AgentKind::Operator)
552 .unwrap()
553 .profile
554 .unwrap()
555 .version_hash;
556 let h2 = parse(t2, "x", AgentKind::Operator)
557 .unwrap()
558 .profile
559 .unwrap()
560 .version_hash;
561 assert_eq!(h1, h2);
562 }
563}