Skip to main content

mlua_swarm_compile/
agent_md.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: implementer
9//! description: Implementation worker ...
10//! model: sonnet
11//! effort: high
12//! tools: Read, Edit, Write, Grep, Glob
13//! worker_binding: code-worker
14//! lints:
15//!   agent-md-size: allow
16//! permissionMode: bypassPermissions
17//! memory: user
18//! abtest: true
19//! ---
20//! <Markdown system prompt body>
21//! ```
22//!
23//! ## Output
24//!
25//! A `Vec<AgentDef>` — each entry carries `profile: Some(AgentProfile
26//! { ... })`, `kind` defaults to `AgentKind::Operator`, and `spec` is
27//! `Value::Null`. The backend configuration (`spec`) is injected
28//! separately by the caller — on the Operator-construction path.
29//!
30//! ## Scope
31//!
32//! - Only YAML frontmatter delimited by `---` is accepted. TOML and
33//!   JSON are not supported.
34//! - `tools` accepts both a CSV string (`"Read, Edit"`) and a YAML
35//!   array (`["Read", "Edit"]`).
36//! - `worker_binding` is the Claude Code SubAgent definition name this
37//!   agent binds to at spawn time — first-class (not dumped into
38//!   `extras`) because the compiler and the WS thin path read it
39//!   directly (see `AgentProfile::worker_binding`).
40//! - `lints` is a map of lint key → level (`allow` / `warn` / `deny`)
41//!   populating `AgentDef::lints` — the per-agent layer of the lint
42//!   cascade (see `mlua_swarm_schema::LintSetting`). First-class, not
43//!   dumped into `extras`. An unrecognized *value* is a loud parse
44//!   error ([`LoadError::Lints`]); an unrecognized *key* passes through
45//!   untouched, because keys are validated at consumption time
46//!   (`bp_doctor`'s `unknown-lint-kind` meta-lint).
47//! - Any field beyond the known set (`name` / `description` / `model`
48//!   / `effort` / `tools` / `worker_binding` / `lints`) is dumped into
49//!   an `extras` `Value` — a future-proof carry for C-C-specific
50//!   fields.
51//! - The body is kept verbatim, from just after the closing `---` to
52//!   the end of the file. Body headings (e.g. `## Input`, `## When
53//!   invoked:`, `## Output format`) are treated as opaque prompt text —
54//!   no structural extraction. Any input contract stated under a body
55//!   heading is a prose convention the worker follows because the body
56//!   is verbatim in its system prompt (matches
57//!   `mse://guides/agent-md-authoring` § "Input is not a section").
58
59use 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/// Errors specific to the agent.md loader.
66#[derive(Debug)]
67pub enum LoadError {
68    /// Reading the file failed (not found, permissions, etc.).
69    Io(std::io::Error),
70    /// The `---` frontmatter delimiter was not found, or the body
71    /// could not be separated.
72    NoFrontmatter {
73        /// Path (or source label) of the offending file.
74        path: String,
75    },
76    /// Frontmatter YAML failed to parse.
77    Yaml {
78        /// Path (or source label) of the offending file.
79        path: String,
80        /// The underlying YAML parse error.
81        source: serde_yaml::Error,
82    },
83    /// Frontmatter has no `name` field, so we cannot determine an
84    /// agent identifier.
85    MissingName {
86        /// Path (or source label) of the offending file.
87        path: String,
88    },
89    /// The frontmatter `lints` field is malformed: not a map, or an
90    /// entry whose level is not `allow` / `warn` / `deny`.
91    ///
92    /// Values fail loud (the author controls that spelling
93    /// exhaustively — same rationale as the typed
94    /// `mlua_swarm_schema::LintSetting` on the Blueprint JSON side);
95    /// unrecognized *keys* do not, they are consumption-time material
96    /// for the `unknown-lint-kind` meta-lint.
97    Lints {
98        /// Path (or source label) of the offending file.
99        path: String,
100        /// What was wrong, naming the offending key and value.
101        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
131/// Turn a single `agent.md` file into an `AgentDef`.
132///
133/// **`kind` must be provided explicitly by the caller.** The old
134/// hardcoded `Operator` default was structurally wrong: an agent.md
135/// has no knowledge of deployment and should not decide `kind` in the
136/// loader. The caller passes the kind after resolving the cascade —
137/// `Blueprint.default_agent_kind` → the sibling `$agent_md` override
138/// → `CompilerHints.kind_override`. `spec` is produced as
139/// `Value::Null`; the caller overwrites it if needed.
140pub 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
146/// Load every `*.md` under `dir`. Sorted ascending by file name.
147///
148/// Files without frontmatter — explanatory docs that are not agents —
149/// are **skipped**; `NoFrontmatter` is not turned into an error.
150/// Files that have frontmatter but fail to parse or lack `name` do
151/// propagate their errors.
152///
153/// `kind` applies uniformly to every file — the global default for
154/// this directory scope. To differentiate per file, the caller calls
155/// `load_file(path, per_file_kind)` directly.
156pub 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
175/// Turn the text of an agent.md into an `AgentDef`. `pub` so unit
176/// tests can reach it. `kind` must be provided by the caller — same
177/// contract as `load_file`.
178pub 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    // Dump everything outside the known set into `extras` — a
216    // future-proof carry for C-C-specific fields.
217    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        // GH #46 M2: `agent.md` frontmatter parsing for `runner` /
257        // `runner_ref` is not part of this Milestone (schema + resolver
258        // + validation only); the legacy `profile.worker_binding` path
259        // above remains the sole source until a later Milestone wires
260        // frontmatter authoring for the new tier.
261        runner: None,
262        runner_ref: None,
263        // GH #50: `agent.md` frontmatter authoring for `verdict` is not
264        // part of this scope either — Blueprint JSON authors declare it
265        // directly (`agents[N].verdict`) until a later follow-up wires
266        // frontmatter authoring for it too.
267        verdict: None,
268        lints,
269        // A concurrency limit is a property of how a Blueprint runs the
270        // agent, not of the agent's persona; it is declared on the
271        // Blueprint's agent entry, not in `agent.md`.
272        max_concurrency: None,
273    })
274}
275
276/// Turn the frontmatter `lints` value into `AgentDef::lints`.
277///
278/// Absent **and** present-but-empty both yield `None`: an empty map
279/// declares nothing, and dropping it keeps the wire minimal (the field
280/// is `skip_serializing_if = "Option::is_none"`, so a no-op `lints:`
281/// block never shows up in the serialized `AgentDef`).
282///
283/// Values are typed and fail loud ([`LoadError::Lints`]); keys are
284/// carried through verbatim, including unrecognized ones — key validity
285/// is a consumption-time question (`bp_doctor` reports an unmatched key
286/// as the `unknown-lint-kind` meta-lint rather than refusing to load).
287fn 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
312/// Compute the content hash of an agent body (its `system_prompt`).
313///
314/// 32-byte blake3, hex-encoded. This is the same form that populates
315/// `AgentProfile.version_hash`, and the same form recomputed by the
316/// `patch_applier.lua` post-hook when it detects a
317/// `/agents/N/profile/system_prompt` replacement — the
318/// `host.content_hash` primitive is also blake3 — so the Phase 1
319/// hash-consistency guarantee holds.
320pub fn compute_body_hash(body: &str) -> String {
321    blake3::hash(body.as_bytes()).to_hex().to_string()
322}
323
324/// Split `---\n...\n---\n<body>` into `(frontmatter, body)`. Returns
325/// `None` when the delimiter is missing.
326fn split_frontmatter(text: &str) -> Option<(&str, &str)> {
327    let t = text
328        .strip_prefix("---\n")
329        .or_else(|| text.strip_prefix("---\r\n"))?;
330    // Find the next `---` line.
331    let mut search_from = 0;
332    while let Some(idx) = t[search_from..].find("---") {
333        let abs = search_from + idx;
334        // Require line-start.
335        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
348/// Normalise the frontmatter's `tools` field to a `Vec<String>`.
349/// Accepted forms: CSV string (`"Read, Edit"`) or YAML array
350/// (`["Read", "Edit"]`).
351fn 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        // extras: permissionMode / memory / abtest
389        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        // no worker_binding in SAMPLE → None, and not dumped into extras.
397        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        // must not leak into extras alongside the first-class field.
408        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        // blake3 hex = 64 chars
458        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        // First-class: must not also leak into extras.
487        let p = def.profile.expect("profile present");
488        assert!(matches!(p.extras, Value::Null));
489    }
490
491    /// Keys are consumption-time material: an unrecognized one loads
492    /// fine and becomes `bp_doctor`'s `unknown-lint-kind` meta-lint.
493    #[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        // A `lints: {}` block declares nothing — kept off the wire too.
506        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    /// Uppercase is not the schema's serde spelling either — the wire
527    /// literals are exactly `allow` / `warn` / `deny`.
528    #[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        // Reordering the frontmatter must not affect the body → hash stays the same.
547        // `lints` is parsed out of the frontmatter like every other field
548        // and never reaches the body, so it cannot move the hash either.
549        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}