use crate::blueprint::{AgentDef, AgentKind, AgentProfile};
use serde_json::{Map, Value};
use std::fs;
use std::path::Path;
#[derive(Debug)]
pub enum LoadError {
Io(std::io::Error),
NoFrontmatter {
path: String,
},
Yaml {
path: String,
source: serde_yaml::Error,
},
MissingName {
path: String,
},
}
impl std::fmt::Display for LoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LoadError::Io(e) => write!(f, "io error: {e}"),
LoadError::NoFrontmatter { path } => {
write!(f, "no frontmatter delimiter `---` in {path}")
}
LoadError::Yaml { path, source } => write!(f, "yaml parse error in {path}: {source}"),
LoadError::MissingName { path } => {
write!(f, "frontmatter missing required `name` field in {path}")
}
}
}
}
impl std::error::Error for LoadError {}
impl From<std::io::Error> for LoadError {
fn from(e: std::io::Error) -> Self {
LoadError::Io(e)
}
}
pub fn load_file(path: impl AsRef<Path>, kind: AgentKind) -> Result<AgentDef, LoadError> {
let path = path.as_ref();
let text = fs::read_to_string(path)?;
parse(&text, &path.display().to_string(), kind)
}
pub fn load_dir(dir: impl AsRef<Path>, kind: AgentKind) -> Result<Vec<AgentDef>, LoadError> {
let dir = dir.as_ref();
let mut entries: Vec<_> = fs::read_dir(dir)?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("md"))
.collect();
entries.sort();
let mut out = Vec::new();
for p in entries {
match load_file(&p, kind.clone()) {
Ok(def) => out.push(def),
Err(LoadError::NoFrontmatter { .. }) => continue,
Err(e) => return Err(e),
}
}
Ok(out)
}
pub fn parse(text: &str, source_label: &str, kind: AgentKind) -> Result<AgentDef, LoadError> {
let (front, body) = split_frontmatter(text).ok_or_else(|| LoadError::NoFrontmatter {
path: source_label.into(),
})?;
let yaml: Value = serde_yaml::from_str(front).map_err(|e| LoadError::Yaml {
path: source_label.into(),
source: e,
})?;
let obj = yaml.as_object().cloned().unwrap_or_default();
let name = obj
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| LoadError::MissingName {
path: source_label.into(),
})?;
let description = obj
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string());
let model = obj
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let effort = obj
.get("effort")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let tools = obj.get("tools").map(normalize_tools).unwrap_or_default();
let known = ["name", "description", "model", "effort", "tools"];
let mut extras = Map::new();
for (k, v) in &obj {
if !known.contains(&k.as_str()) {
extras.insert(k.clone(), v.clone());
}
}
let version_hash = Some(compute_body_hash(body));
let profile = AgentProfile {
system_prompt: body.to_string(),
model,
effort,
tools,
description: description.clone(),
extras: if extras.is_empty() {
Value::Null
} else {
Value::Object(extras)
},
version_hash,
};
Ok(AgentDef {
name,
kind,
spec: Value::Null,
profile: Some(profile),
meta: None,
})
}
pub fn compute_body_hash(body: &str) -> String {
blake3::hash(body.as_bytes()).to_hex().to_string()
}
fn split_frontmatter(text: &str) -> Option<(&str, &str)> {
let t = text
.strip_prefix("---\n")
.or_else(|| text.strip_prefix("---\r\n"))?;
let mut search_from = 0;
while let Some(idx) = t[search_from..].find("---") {
let abs = search_from + idx;
if abs == 0 || t.as_bytes()[abs - 1] == b'\n' {
let after = &t[abs + 3..];
let body = after
.strip_prefix("\r\n")
.or_else(|| after.strip_prefix('\n'))
.unwrap_or(after);
return Some((&t[..abs], body));
}
search_from = abs + 3;
}
None
}
fn normalize_tools(v: &Value) -> Vec<String> {
if let Some(arr) = v.as_array() {
return arr
.iter()
.filter_map(|x| x.as_str().map(|s| s.trim().to_string()))
.filter(|s| !s.is_empty())
.collect();
}
if let Some(s) = v.as_str() {
return s
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
Vec::new()
}
#[cfg(test)]
mod tests {
use super::*;
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";
#[test]
fn parses_full_frontmatter() {
let def = parse(SAMPLE, "sample", AgentKind::Operator).expect("parse ok");
assert_eq!(def.name, "impl-lead");
assert!(matches!(def.kind, AgentKind::Operator));
let p = def.profile.expect("profile present");
assert_eq!(p.model.as_deref(), Some("sonnet"));
assert_eq!(p.effort.as_deref(), Some("high"));
assert_eq!(p.tools, vec!["Read", "Edit", "Grep"]);
assert_eq!(p.description.as_deref(), Some("Implementation worker"));
assert!(p
.system_prompt
.starts_with("You are the implementation lead."));
let extras = p.extras.as_object().expect("extras object");
assert_eq!(
extras.get("permissionMode").and_then(|v| v.as_str()),
Some("bypassPermissions")
);
assert_eq!(extras.get("memory").and_then(|v| v.as_str()), Some("user"));
assert_eq!(extras.get("abtest").and_then(|v| v.as_bool()), Some(true));
}
#[test]
fn tools_accepts_yaml_array() {
let t = "---\nname: x\ntools:\n - Read\n - Edit\n---\nbody\n";
let def = parse(t, "x", AgentKind::Operator).unwrap();
assert_eq!(def.profile.unwrap().tools, vec!["Read", "Edit"]);
}
#[test]
fn missing_name_errors() {
let t = "---\nmodel: sonnet\n---\nbody\n";
assert!(matches!(
parse(t, "x", AgentKind::Operator),
Err(LoadError::MissingName { .. })
));
}
#[test]
fn no_frontmatter_errors() {
let t = "plain body without frontmatter";
assert!(matches!(
parse(t, "x", AgentKind::Operator),
Err(LoadError::NoFrontmatter { .. })
));
}
#[test]
fn body_preserves_markdown() {
let t = "---\nname: x\n---\n# Heading\n\nparagraph with `code`.\n";
let p = parse(t, "x", AgentKind::Operator).unwrap().profile.unwrap();
assert_eq!(p.system_prompt, "# Heading\n\nparagraph with `code`.\n");
}
#[test]
fn populates_version_hash_from_body() {
let def = parse(SAMPLE, "sample", AgentKind::Operator).unwrap();
let p = def.profile.unwrap();
let expected = compute_body_hash(&p.system_prompt);
assert_eq!(p.version_hash.as_deref(), Some(expected.as_str()));
assert_eq!(expected.len(), 64);
}
#[test]
fn version_hash_changes_with_body() {
let t1 = "---\nname: x\n---\nbody one\n";
let t2 = "---\nname: x\n---\nbody two\n";
let h1 = parse(t1, "x", AgentKind::Operator)
.unwrap()
.profile
.unwrap()
.version_hash;
let h2 = parse(t2, "x", AgentKind::Operator)
.unwrap()
.profile
.unwrap()
.version_hash;
assert!(h1.is_some() && h2.is_some());
assert_ne!(h1, h2);
}
#[test]
fn version_hash_stable_across_frontmatter_reorder() {
let t1 = "---\nname: x\nmodel: sonnet\n---\nsame body\n";
let t2 = "---\nmodel: sonnet\nname: x\n---\nsame body\n";
let h1 = parse(t1, "x", AgentKind::Operator)
.unwrap()
.profile
.unwrap()
.version_hash;
let h2 = parse(t2, "x", AgentKind::Operator)
.unwrap()
.profile
.unwrap()
.version_hash;
assert_eq!(h1, h2);
}
}