Skip to main content

mlua_swarm/lua/
agent_md_loader.rs

1//! agent.md frontmatter + body loader — turns agent-profiles
2//! `agents/*.md` files into `AgentDef`s.
3//!
4//! ## Input format
5//!
6//! ```text
7//! ---
8//! name: impl-lead
9//! description: Implementation worker ...
10//! model: sonnet
11//! effort: high
12//! tools: Read, Edit, Write, Grep, Glob
13//! worker_binding: mse-worker-coder
14//! permissionMode: bypassPermissions
15//! memory: user
16//! abtest: true
17//! ---
18//! <Markdown system prompt body>
19//! ```
20//!
21//! ## Output
22//!
23//! A `Vec<AgentDef>` — each entry carries `profile: Some(AgentProfile
24//! { ... })`, `kind` defaults to `AgentKind::Operator`, and `spec` is
25//! `Value::Null`. The backend configuration (`spec`) is injected
26//! separately by the caller — on the Operator-construction path.
27//!
28//! ## Scope
29//!
30//! - Only YAML frontmatter delimited by `---` is accepted. TOML and
31//!   JSON are not supported.
32//! - `tools` accepts both a CSV string (`"Read, Edit"`) and a YAML
33//!   array (`["Read", "Edit"]`).
34//! - `worker_binding` is the Claude Code SubAgent definition name this
35//!   agent binds to at spawn time — first-class (not dumped into
36//!   `extras`) because the compiler and the WS thin path read it
37//!   directly (see `AgentProfile::worker_binding`).
38//! - Any field beyond the known set (`name` / `description` / `model`
39//!   / `effort` / `tools` / `worker_binding`) is dumped into an
40//!   `extras` `Value` — a future-proof carry for C-C-specific fields.
41//! - The body is kept verbatim, from just after the closing `---` to
42//!   the end of the file. Body headings (e.g. `## Input`, `## When
43//!   invoked:`, `## Output format`) are treated as opaque prompt text —
44//!   no structural extraction. Any input contract stated under a body
45//!   heading is a prose convention the worker follows because the body
46//!   is verbatim in its system prompt (matches
47//!   `mse://guides/agent-md-authoring` § "Input is not a section").
48
49use crate::blueprint::{AgentDef, AgentKind, AgentProfile};
50use serde_json::{Map, Value};
51use std::fs;
52use std::path::Path;
53
54/// Errors specific to the agent.md loader.
55#[derive(Debug)]
56pub enum LoadError {
57    /// Reading the file failed (not found, permissions, etc.).
58    Io(std::io::Error),
59    /// The `---` frontmatter delimiter was not found, or the body
60    /// could not be separated.
61    NoFrontmatter {
62        /// Path (or source label) of the offending file.
63        path: String,
64    },
65    /// Frontmatter YAML failed to parse.
66    Yaml {
67        /// Path (or source label) of the offending file.
68        path: String,
69        /// The underlying YAML parse error.
70        source: serde_yaml::Error,
71    },
72    /// Frontmatter has no `name` field, so we cannot determine an
73    /// agent identifier.
74    MissingName {
75        /// Path (or source label) of the offending file.
76        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
103/// Turn a single `agent.md` file into an `AgentDef`.
104///
105/// **`kind` must be provided explicitly by the caller.** The old
106/// hardcoded `Operator` default was structurally wrong: an agent.md
107/// has no knowledge of deployment and should not decide `kind` in the
108/// loader. The caller passes the kind after resolving the cascade —
109/// `Blueprint.default_agent_kind` → the sibling `$agent_md` override
110/// → `CompilerHints.kind_override`. `spec` is produced as
111/// `Value::Null`; the caller overwrites it if needed.
112pub 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
118/// Load every `*.md` under `dir`. Sorted ascending by file name.
119///
120/// Files without frontmatter — explanatory docs that are not agents —
121/// are **skipped**; `NoFrontmatter` is not turned into an error.
122/// Files that have frontmatter but fail to parse or lack `name` do
123/// propagate their errors.
124///
125/// `kind` applies uniformly to every file — the global default for
126/// this directory scope. To differentiate per file, the caller calls
127/// `load_file(path, per_file_kind)` directly.
128pub 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
147/// Turn the text of an agent.md into an `AgentDef`. `pub` so unit
148/// tests can reach it. `kind` must be provided by the caller — same
149/// contract as `load_file`.
150pub 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    // Dump everything outside the known set into `extras` — a
187    // future-proof carry for C-C-specific fields.
188    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        // GH #46 M2: `agent.md` frontmatter parsing for `runner` /
227        // `runner_ref` is not part of this Milestone (schema + resolver
228        // + validation only); the legacy `profile.worker_binding` path
229        // above remains the sole source until a later Milestone wires
230        // frontmatter authoring for the new tier.
231        runner: None,
232        runner_ref: None,
233        // GH #50: `agent.md` frontmatter authoring for `verdict` is not
234        // part of this scope either — Blueprint JSON authors declare it
235        // directly (`agents[N].verdict`) until a later follow-up wires
236        // frontmatter authoring for it too.
237        verdict: None,
238    })
239}
240
241/// Compute the content hash of an agent body (its `system_prompt`).
242///
243/// 32-byte blake3, hex-encoded. This is the same form that populates
244/// `AgentProfile.version_hash`, and the same form recomputed by the
245/// `patch_applier.lua` post-hook when it detects a
246/// `/agents/N/profile/system_prompt` replacement — the
247/// `host.content_hash` primitive is also blake3 — so the Phase 1
248/// hash-consistency guarantee holds.
249pub fn compute_body_hash(body: &str) -> String {
250    blake3::hash(body.as_bytes()).to_hex().to_string()
251}
252
253/// Split `---\n...\n---\n<body>` into `(frontmatter, body)`. Returns
254/// `None` when the delimiter is missing.
255fn split_frontmatter(text: &str) -> Option<(&str, &str)> {
256    let t = text
257        .strip_prefix("---\n")
258        .or_else(|| text.strip_prefix("---\r\n"))?;
259    // Find the next `---` line.
260    let mut search_from = 0;
261    while let Some(idx) = t[search_from..].find("---") {
262        let abs = search_from + idx;
263        // Require line-start.
264        if abs == 0 || t.as_bytes()[abs - 1] == b'\n' {
265            let after = &t[abs + 3..];
266            let body = after
267                .strip_prefix("\r\n")
268                .or_else(|| after.strip_prefix('\n'))
269                .unwrap_or(after);
270            return Some((&t[..abs], body));
271        }
272        search_from = abs + 3;
273    }
274    None
275}
276
277/// Normalise the frontmatter's `tools` field to a `Vec<String>`.
278/// Accepted forms: CSV string (`"Read, Edit"`) or YAML array
279/// (`["Read", "Edit"]`).
280fn normalize_tools(v: &Value) -> Vec<String> {
281    if let Some(arr) = v.as_array() {
282        return arr
283            .iter()
284            .filter_map(|x| x.as_str().map(|s| s.trim().to_string()))
285            .filter(|s| !s.is_empty())
286            .collect();
287    }
288    if let Some(s) = v.as_str() {
289        return s
290            .split(',')
291            .map(|s| s.trim().to_string())
292            .filter(|s| !s.is_empty())
293            .collect();
294    }
295    Vec::new()
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    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";
303
304    #[test]
305    fn parses_full_frontmatter() {
306        let def = parse(SAMPLE, "sample", AgentKind::Operator).expect("parse ok");
307        assert_eq!(def.name, "impl-lead");
308        assert!(matches!(def.kind, AgentKind::Operator));
309        let p = def.profile.expect("profile present");
310        assert_eq!(p.model.as_deref(), Some("sonnet"));
311        assert_eq!(p.effort.as_deref(), Some("high"));
312        assert_eq!(p.tools, vec!["Read", "Edit", "Grep"]);
313        assert_eq!(p.description.as_deref(), Some("Implementation worker"));
314        assert!(p
315            .system_prompt
316            .starts_with("You are the implementation lead."));
317        // extras: permissionMode / memory / abtest
318        let extras = p.extras.as_object().expect("extras object");
319        assert_eq!(
320            extras.get("permissionMode").and_then(|v| v.as_str()),
321            Some("bypassPermissions")
322        );
323        assert_eq!(extras.get("memory").and_then(|v| v.as_str()), Some("user"));
324        assert_eq!(extras.get("abtest").and_then(|v| v.as_bool()), Some(true));
325        // no worker_binding in SAMPLE → None, and not dumped into extras.
326        assert_eq!(p.worker_binding, None);
327        assert!(extras.get("worker_binding").is_none());
328    }
329
330    #[test]
331    fn worker_binding_extracted_as_first_class_field() {
332        let t = "---\nname: x\nworker_binding: mse-worker-coder\n---\nbody\n";
333        let def = parse(t, "x", AgentKind::Operator).unwrap();
334        let p = def.profile.expect("profile present");
335        assert_eq!(p.worker_binding.as_deref(), Some("mse-worker-coder"));
336        // must not leak into extras alongside the first-class field.
337        assert!(matches!(p.extras, Value::Null));
338    }
339
340    #[test]
341    fn worker_binding_absent_is_none_not_extras() {
342        let t = "---\nname: x\nmodel: sonnet\n---\nbody\n";
343        let def = parse(t, "x", AgentKind::Operator).unwrap();
344        let p = def.profile.expect("profile present");
345        assert_eq!(p.worker_binding, None);
346    }
347
348    #[test]
349    fn tools_accepts_yaml_array() {
350        let t = "---\nname: x\ntools:\n  - Read\n  - Edit\n---\nbody\n";
351        let def = parse(t, "x", AgentKind::Operator).unwrap();
352        assert_eq!(def.profile.unwrap().tools, vec!["Read", "Edit"]);
353    }
354
355    #[test]
356    fn missing_name_errors() {
357        let t = "---\nmodel: sonnet\n---\nbody\n";
358        assert!(matches!(
359            parse(t, "x", AgentKind::Operator),
360            Err(LoadError::MissingName { .. })
361        ));
362    }
363
364    #[test]
365    fn no_frontmatter_errors() {
366        let t = "plain body without frontmatter";
367        assert!(matches!(
368            parse(t, "x", AgentKind::Operator),
369            Err(LoadError::NoFrontmatter { .. })
370        ));
371    }
372
373    #[test]
374    fn body_preserves_markdown() {
375        let t = "---\nname: x\n---\n# Heading\n\nparagraph with `code`.\n";
376        let p = parse(t, "x", AgentKind::Operator).unwrap().profile.unwrap();
377        assert_eq!(p.system_prompt, "# Heading\n\nparagraph with `code`.\n");
378    }
379
380    #[test]
381    fn populates_version_hash_from_body() {
382        let def = parse(SAMPLE, "sample", AgentKind::Operator).unwrap();
383        let p = def.profile.unwrap();
384        let expected = compute_body_hash(&p.system_prompt);
385        assert_eq!(p.version_hash.as_deref(), Some(expected.as_str()));
386        // blake3 hex = 64 chars
387        assert_eq!(expected.len(), 64);
388    }
389
390    #[test]
391    fn version_hash_changes_with_body() {
392        let t1 = "---\nname: x\n---\nbody one\n";
393        let t2 = "---\nname: x\n---\nbody two\n";
394        let h1 = parse(t1, "x", AgentKind::Operator)
395            .unwrap()
396            .profile
397            .unwrap()
398            .version_hash;
399        let h2 = parse(t2, "x", AgentKind::Operator)
400            .unwrap()
401            .profile
402            .unwrap()
403            .version_hash;
404        assert!(h1.is_some() && h2.is_some());
405        assert_ne!(h1, h2);
406    }
407
408    #[test]
409    fn version_hash_stable_across_frontmatter_reorder() {
410        // Reordering the frontmatter must not affect the body → hash stays the same.
411        let t1 = "---\nname: x\nmodel: sonnet\n---\nsame body\n";
412        let t2 = "---\nmodel: sonnet\nname: x\n---\nsame body\n";
413        let h1 = parse(t1, "x", AgentKind::Operator)
414            .unwrap()
415            .profile
416            .unwrap()
417            .version_hash;
418        let h2 = parse(t2, "x", AgentKind::Operator)
419            .unwrap()
420            .profile
421            .unwrap()
422            .version_hash;
423        assert_eq!(h1, h2);
424    }
425}