Skip to main content

agentd/config/
directives.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! **Colon-fence directives** in operator-authored text — the
3//! `:::type{attrs}` … `:::` container syntax (the MyST / remark-directive /
4//! ChatGPT subset), so an instruction can CARRY the machinery it describes:
5//!
6//! ```text
7//! You triage the queue. Escalate anything risky.
8//!
9//! :::workflow
10//! name: triage
11//! steps:
12//!   wake: { kind: subscribe, server: queue, uri: "queue://inbox" }
13//!   act:  { kind: agent, depends_on: [wake], instruction: "triage it" }
14//!   done: { kind: finish, depends_on: [act] }
15//! :::
16//! ```
17//!
18//! Design decisions, in order of load-bearing-ness:
19//!
20//! - **Directives are a property of the SURFACE, not the text.** This module
21//!   only parses; the config layer runs it over operator-authored instruction
22//!   text (inline, `--instruction-file`, a config file). Conversation text is
23//!   never parsed — executing definitions out of untrusted input would be
24//!   prompt injection as a feature.
25//! - **Blocks are sugar over existing pipelines, never a parallel mechanism.**
26//!   A `:::workflow` body joins `workflows:` exactly as an inline entry —
27//!   same vars folding, validation, hashing, pinning, reload diffing and
28//!   retirement. A `:::skill` joins the skills catalogue like a discovered
29//!   one. Nothing here can diverge from the real thing, because it IS the
30//!   real thing.
31//! - **Unknown names fail closed.** `:::worfklow` silently becoming prose is
32//!   a trap; the known set is enumerated in the error. Text that legitimately
33//!   needs a literal `:::` at column 0 can indent it.
34//! - The grammar is the small end of MyST: `:::name{key=value key="v v"}` on
35//!   one line, body verbatim, closed by a line of at least as many colons.
36//!   Nest by giving the OUTER fence more colons. No roles, no `:key:` option
37//!   lines — those are documentation-system surface, not config surface.
38
39use serde_json::Value;
40use std::collections::BTreeMap;
41
42/// One parsed block.
43#[derive(Debug, Clone, PartialEq)]
44pub struct Directive {
45    pub name: String,
46    pub attrs: BTreeMap<String, String>,
47    pub body: String,
48    /// 1-based line of the opening fence, for error messages.
49    pub line: usize,
50}
51
52/// What instruction-surface extraction produces.
53#[derive(Debug, Default, PartialEq)]
54pub struct Extraction {
55    /// The text with directive machinery removed: `workflow`/`skill` blocks
56    /// replaced by a one-line note (so prose and machinery cannot
57    /// double-speak), `context`/`example` bodies kept, delimited with tags a
58    /// model reads well.
59    pub cleaned: String,
60    /// `:::workflow` bodies, parsed to documents ready for `workflows:`.
61    pub workflows: Vec<Value>,
62    /// `:::skill{name}` bodies for the catalogue.
63    pub skills: Vec<InlineSkill>,
64    /// The config fragment the document's `:::config` / `:::mcp` /
65    /// `:::stream` / `:::tools` blocks assemble — a v2 document subtree that
66    /// merges UNDER the explicit config (an explicit key always wins), so a
67    /// single instruction file can define the whole agent while a config
68    /// file, env, or flag can still override any of it.
69    pub config: Value,
70}
71
72/// A skill defined inline — the catalogue entry plus its body, no MCP server
73/// involved.
74#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
75pub struct InlineSkill {
76    pub name: String,
77    #[serde(default)]
78    pub description: String,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub when_to_use: Option<String>,
81    pub body: String,
82}
83
84/// The names this surface understands. Fail-closed: anything else at a fence
85/// is an error naming this set.
86const KNOWN: &[&str] = &[
87    "workflow", "skill", "context", "example", "config", "mcp", "stream", "tools",
88];
89
90/// Parse every top-level directive out of `text`. Returns the directives and
91/// the text segments between them, or every problem found.
92pub fn parse(text: &str) -> Result<(Vec<Segment>, Vec<Directive>), Vec<String>> {
93    let mut errs = Vec::new();
94    let mut segments = Vec::new();
95    let mut directives = Vec::new();
96    let mut plain = String::new();
97    let lines: Vec<&str> = text.split('\n').collect();
98    let mut i = 0;
99    while i < lines.len() {
100        let line = lines[i];
101        if let Some((fence_len, name, attr_src)) = open_fence(line) {
102            if !KNOWN.contains(&name.as_str()) {
103                errs.push(format!(
104                    "line {}: unknown directive :::{name} (known: {})",
105                    i + 1,
106                    KNOWN.join(", ")
107                ));
108            }
109            let attrs = match parse_attrs(&attr_src) {
110                Ok(a) => a,
111                Err(e) => {
112                    errs.push(format!("line {}: :::{name}: {e}", i + 1));
113                    BTreeMap::new()
114                }
115            };
116            // Find the closing fence: a line of >= fence_len colons, nothing else.
117            let open_line = i + 1;
118            let mut body = String::new();
119            let mut closed = false;
120            i += 1;
121            while i < lines.len() {
122                let l = lines[i];
123                let t = l.trim_end();
124                if t.len() >= fence_len && t.chars().all(|c| c == ':') {
125                    closed = true;
126                    break;
127                }
128                if !body.is_empty() {
129                    body.push('\n');
130                }
131                body.push_str(l);
132                i += 1;
133            }
134            if !closed {
135                errs.push(format!(
136                    "line {open_line}: :::{name} is never closed (expected a line of {fence_len}+ colons)"
137                ));
138            }
139            if !plain.is_empty() {
140                segments.push(Segment::Text(std::mem::take(&mut plain)));
141            }
142            segments.push(Segment::Directive(directives.len()));
143            directives.push(Directive {
144                name,
145                attrs,
146                body,
147                line: open_line,
148            });
149            i += 1; // past the close
150        } else {
151            if !plain.is_empty() {
152                plain.push('\n');
153            }
154            plain.push_str(line);
155            i += 1;
156        }
157    }
158    if !plain.is_empty() {
159        segments.push(Segment::Text(plain));
160    }
161    if errs.is_empty() {
162        Ok((segments, directives))
163    } else {
164        Err(errs)
165    }
166}
167
168/// A run of plain text, or the index of a directive between runs.
169#[derive(Debug, PartialEq)]
170pub enum Segment {
171    Text(String),
172    Directive(usize),
173}
174
175/// `:::name{...}` at column 0 → `(fence length, name, attr source)`.
176fn open_fence(line: &str) -> Option<(usize, String, String)> {
177    let t = line.trim_end();
178    let colons = t.chars().take_while(|c| *c == ':').count();
179    if colons < 3 {
180        return None;
181    }
182    let rest = &t[colons..];
183    if rest.is_empty() {
184        return None; // a bare fence line opens nothing (it can only close)
185    }
186    let name: String = rest
187        .chars()
188        .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_' || *c == '.')
189        .collect();
190    if name.is_empty() {
191        return None;
192    }
193    let after = &rest[name.len()..];
194    let attrs = after.trim();
195    if !attrs.is_empty() && !(attrs.starts_with('{') && attrs.ends_with('}')) {
196        return None; // `::: three colons then prose` is prose, not a fence
197    }
198    let attr_src = attrs
199        .strip_prefix('{')
200        .and_then(|a| a.strip_suffix('}'))
201        .unwrap_or("")
202        .to_string();
203    Some((colons, name, attr_src))
204}
205
206/// `key=value key="quoted value" flag` → map (`flag` → `"true"`).
207fn parse_attrs(src: &str) -> Result<BTreeMap<String, String>, String> {
208    let mut out = BTreeMap::new();
209    let mut chars = src.chars().peekable();
210    loop {
211        while chars.peek().is_some_and(|c| c.is_whitespace()) {
212            chars.next();
213        }
214        let Some(&c0) = chars.peek() else { break };
215        if !(c0.is_ascii_alphanumeric() || c0 == '_' || c0 == '-') {
216            return Err(format!("unexpected {c0:?} in attributes"));
217        }
218        let mut key = String::new();
219        while chars
220            .peek()
221            .is_some_and(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-' || *c == '.')
222        {
223            key.push(chars.next().unwrap());
224        }
225        if chars.peek() == Some(&'=') {
226            chars.next();
227            let mut val = String::new();
228            if chars.peek() == Some(&'"') {
229                chars.next();
230                let mut closed = false;
231                while let Some(c) = chars.next() {
232                    match c {
233                        '"' => {
234                            closed = true;
235                            break;
236                        }
237                        '\\' => {
238                            if let Some(e) = chars.next() {
239                                val.push(e);
240                            }
241                        }
242                        _ => val.push(c),
243                    }
244                }
245                if !closed {
246                    return Err(format!("unterminated quote in {key}=\"…"));
247                }
248            } else {
249                while chars.peek().is_some_and(|c| !c.is_whitespace()) {
250                    val.push(chars.next().unwrap());
251                }
252            }
253            out.insert(key, val);
254        } else {
255            out.insert(key, "true".to_string());
256        }
257    }
258    Ok(out)
259}
260
261/// The fragment as a mutable map, created on first use (it stays `Null` for
262/// documents that carry no config-defining blocks).
263fn frag(config: &mut Value) -> &mut serde_json::Map<String, Value> {
264    if !config.is_object() {
265        *config = Value::Object(Default::default());
266    }
267    config.as_object_mut().expect("just ensured")
268}
269
270/// A bare attribute value, typed the way YAML would read it — so
271/// `{name=fs timeout=30s aauth=true}` behaves like the equivalent body keys.
272fn attr_value(s: &str) -> Value {
273    match s {
274        "true" => Value::Bool(true),
275        "false" => Value::Bool(false),
276        _ => s
277            .parse::<i64>()
278            .map(Into::into)
279            .unwrap_or_else(|_| Value::String(s.to_string())),
280    }
281}
282
283/// Deep-merge `add` into `into`, LATER-WINS at every leaf (used between
284/// directives in one document: the second `:::config` overrides the first,
285/// like a later config file).
286fn merge_over(into: &mut serde_json::Map<String, Value>, add: serde_json::Map<String, Value>) {
287    for (k, v) in add {
288        match (into.get_mut(&k), v) {
289            (Some(Value::Object(dst)), Value::Object(src)) => merge_over(dst, src),
290            (Some(slot), v) => *slot = v,
291            (None, v) => {
292                into.insert(k, v);
293            }
294        }
295    }
296}
297
298/// Deep-merge `frag` into `doc`, DOC-WINS at every leaf — the fragment fills
299/// what the explicit config left unsaid and never overrides what it said.
300/// Lists are leaves (no splicing), with one deliberate exception:
301/// `mcp.servers` entries APPEND when no explicit server has the same name —
302/// declaring a server in the instruction must not require the config file to
303/// have none.
304pub fn merge_missing(
305    doc: &mut serde_json::Map<String, Value>,
306    frag: serde_json::Map<String, Value>,
307    at_mcp: bool,
308) {
309    for (k, v) in frag {
310        match (doc.get_mut(&k), v) {
311            (Some(Value::Array(have)), Value::Array(add)) if at_mcp && k == "servers" => {
312                let names: Vec<String> = have
313                    .iter()
314                    .filter_map(|s| s.get("name").and_then(Value::as_str))
315                    .map(str::to_string)
316                    .collect();
317                for entry in add {
318                    let dup = entry
319                        .get("name")
320                        .and_then(Value::as_str)
321                        .is_some_and(|n| names.iter().any(|h| h == n));
322                    if !dup {
323                        have.push(entry);
324                    }
325                }
326            }
327            (Some(Value::Object(dst)), Value::Object(src)) => {
328                merge_missing(dst, src, k == "mcp");
329            }
330            (Some(_), _) => {}
331            (None, v) => {
332                doc.insert(k, v);
333            }
334        }
335    }
336}
337
338/// Run extraction over an instruction-surface text: parse, interpret the
339/// known blocks, rebuild the text a model should see.
340pub fn extract(text: &str) -> Result<Extraction, Vec<String>> {
341    // The cheap gate: most instructions carry no fences at all.
342    if !text.lines().any(|l| l.starts_with(":::")) {
343        return Ok(Extraction {
344            cleaned: text.to_string(),
345            ..Default::default()
346        });
347    }
348    let (segments, directives) = parse(text)?;
349    let mut errs = Vec::new();
350    let mut out = Extraction::default();
351    for seg in &segments {
352        match seg {
353            Segment::Text(t) => out.cleaned.push_str(t),
354            Segment::Directive(ix) => {
355                let d = &directives[*ix];
356                match d.name.as_str() {
357                    "workflow" => match crate::config::yaml::parse(&d.body) {
358                        Ok(mut doc) => {
359                            if let Some(n) = d.attrs.get("name")
360                                && let Some(o) = doc.as_object_mut()
361                            {
362                                o.insert("name".into(), Value::String(n.clone()));
363                            }
364                            if let Some(armed) = d.attrs.get("armed")
365                                && let Some(o) = doc.as_object_mut()
366                            {
367                                o.insert("armed".into(), Value::Bool(armed == "true"));
368                            }
369                            let name = doc
370                                .get("name")
371                                .and_then(Value::as_str)
372                                .unwrap_or("?")
373                                .to_string();
374                            out.cleaned.push_str(&format!(
375                                "[workflow \"{name}\" is loaded and runs autonomously]"
376                            ));
377                            out.workflows.push(doc);
378                        }
379                        Err(e) => errs.push(format!(
380                            "line {}: :::workflow body is not valid YAML: {e}",
381                            d.line
382                        )),
383                    },
384                    "skill" => {
385                        let Some(name) = d.attrs.get("name").cloned() else {
386                            errs.push(format!(
387                                "line {}: :::skill needs a name ({{name=…}})",
388                                d.line
389                            ));
390                            continue;
391                        };
392                        out.cleaned.push_str(&format!(
393                            "[skill \"{name}\" is available — reference it as @skill:{name}]"
394                        ));
395                        out.skills.push(InlineSkill {
396                            name,
397                            description: d.attrs.get("description").cloned().unwrap_or_default(),
398                            when_to_use: d.attrs.get("when").cloned(),
399                            body: d.body.clone(),
400                        });
401                    }
402                    // Config-defining blocks: each folds into ONE fragment that
403                    // the config layer merges UNDER the explicit document — so
404                    // an instruction file alone can define the whole agent, and
405                    // an explicit config key / env / flag still wins.
406                    "config" => match crate::config::yaml::parse(&d.body) {
407                        Ok(Value::Object(m)) => {
408                            merge_over(frag(&mut out.config), m);
409                            out.cleaned.push_str("[runtime configuration is applied]");
410                        }
411                        Ok(_) => errs.push(format!(
412                            "line {}: :::config body must be a YAML mapping of config sections",
413                            d.line
414                        )),
415                        Err(e) => errs.push(format!(
416                            "line {}: :::config body is not valid YAML: {e}",
417                            d.line
418                        )),
419                    },
420                    "mcp" => {
421                        let body = if d.body.trim().is_empty() {
422                            Ok(Value::Object(serde_json::Map::new()))
423                        } else {
424                            crate::config::yaml::parse(&d.body)
425                        };
426                        match body {
427                            Ok(Value::Object(mut m)) => {
428                                for (k, v) in &d.attrs {
429                                    m.insert(k.clone(), attr_value(v));
430                                }
431                                let Some(name) =
432                                    m.get("name").and_then(Value::as_str).map(str::to_string)
433                                else {
434                                    errs.push(format!(
435                                        "line {}: :::mcp needs a name ({{name=…}} or `name:` in the body)",
436                                        d.line
437                                    ));
438                                    continue;
439                                };
440                                out.cleaned.push_str(&format!(
441                                    "[mcp server \"{name}\" is connected; its tools are available]"
442                                ));
443                                let servers = frag(&mut out.config)
444                                    .entry("mcp")
445                                    .or_insert_with(|| Value::Object(Default::default()));
446                                if let Some(o) = servers.as_object_mut() {
447                                    o.entry("servers")
448                                        .or_insert_with(|| Value::Array(Vec::new()))
449                                        .as_array_mut()
450                                        .expect("just made")
451                                        .push(Value::Object(m));
452                                }
453                            }
454                            Ok(_) => errs.push(format!(
455                                "line {}: :::mcp body must be a YAML mapping (the mcp.servers entry)",
456                                d.line
457                            )),
458                            Err(e) => errs.push(format!(
459                                "line {}: :::mcp body is not valid YAML: {e}",
460                                d.line
461                            )),
462                        }
463                    }
464                    "stream" => {
465                        let Some(name) = d.attrs.get("name").cloned() else {
466                            errs.push(format!(
467                                "line {}: :::stream needs a name ({{name=…}})",
468                                d.line
469                            ));
470                            continue;
471                        };
472                        let body = if d.body.trim().is_empty() {
473                            Ok(Value::Object(serde_json::Map::new()))
474                        } else {
475                            crate::config::yaml::parse(&d.body)
476                        };
477                        match body {
478                            Ok(v @ Value::Object(_)) => {
479                                out.cleaned
480                                    .push_str(&format!("[event stream \"{name}\" is declared]"));
481                                let streams = frag(&mut out.config)
482                                    .entry("streams")
483                                    .or_insert_with(|| Value::Object(Default::default()));
484                                if let Some(o) = streams.as_object_mut() {
485                                    o.insert(name, v);
486                                }
487                            }
488                            Ok(_) => errs.push(format!(
489                                "line {}: :::stream body must be a YAML mapping (retention: …)",
490                                d.line
491                            )),
492                            Err(e) => errs.push(format!(
493                                "line {}: :::stream body is not valid YAML: {e}",
494                                d.line
495                            )),
496                        }
497                    }
498                    "tools" => match crate::config::yaml::parse(&d.body) {
499                        Ok(Value::Object(m)) => {
500                            out.cleaned.push_str("[tool policy is applied]");
501                            let tools = frag(&mut out.config)
502                                .entry("tools")
503                                .or_insert_with(|| Value::Object(Default::default()));
504                            if let Some(o) = tools.as_object_mut() {
505                                merge_over(o, m);
506                            }
507                        }
508                        Ok(_) => errs.push(format!(
509                            "line {}: :::tools body must be a YAML mapping (disabled/overrides)",
510                            d.line
511                        )),
512                        Err(e) => errs.push(format!(
513                            "line {}: :::tools body is not valid YAML: {e}",
514                            d.line
515                        )),
516                    },
517                    // Model-facing: the fence goes, the body stays, delimited
518                    // with tags a model reads unambiguously.
519                    "context" | "example" => {
520                        let tag = if d.name == "context" {
521                            "reference"
522                        } else {
523                            "example"
524                        };
525                        match d.attrs.get("title") {
526                            Some(t) => out
527                                .cleaned
528                                .push_str(&format!("<{tag} title=\"{t}\">\n{}\n</{tag}>", d.body)),
529                            None => out
530                                .cleaned
531                                .push_str(&format!("<{tag}>\n{}\n</{tag}>", d.body)),
532                        }
533                    }
534                    _ => unreachable!("parse() rejects unknown names"),
535                }
536            }
537        }
538    }
539    if errs.is_empty() { Ok(out) } else { Err(errs) }
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    #[test]
547    fn plain_text_passes_through_untouched() {
548        let t = "just prose\nwith lines\nand a ::: mid-sentence is fine";
549        let e = extract(t).unwrap();
550        assert_eq!(e.cleaned, t);
551        assert!(e.workflows.is_empty() && e.skills.is_empty());
552    }
553
554    #[test]
555    fn a_workflow_block_becomes_a_document_and_a_note() {
556        let t = "Do the thing.\n\n:::workflow{armed=true}\nname: w\nsteps:\n  s: {kind: once}\n  f: {kind: finish, depends_on: [s]}\n:::\n\nBe nice.";
557        let e = extract(t).unwrap();
558        assert_eq!(e.workflows.len(), 1);
559        assert_eq!(e.workflows[0]["name"], "w");
560        assert_eq!(e.workflows[0]["armed"], true);
561        assert!(e.cleaned.contains("[workflow \"w\" is loaded"));
562        assert!(!e.cleaned.contains(":::"), "{}", e.cleaned);
563        assert!(e.cleaned.starts_with("Do the thing.") && e.cleaned.ends_with("Be nice."));
564    }
565
566    #[test]
567    fn name_attr_overrides_the_body_name() {
568        let t = ":::workflow{name=renamed}\nname: original\nsteps: {}\n:::";
569        let e = extract(t).unwrap();
570        assert_eq!(e.workflows[0]["name"], "renamed");
571    }
572
573    #[test]
574    fn a_skill_block_joins_the_catalogue_with_a_reference_note() {
575        let t = ":::skill{name=review description=\"how we review\" when=\"reviewing PRs\"}\nAlways check the tests first.\n:::";
576        let e = extract(t).unwrap();
577        assert_eq!(e.skills.len(), 1);
578        assert_eq!(e.skills[0].name, "review");
579        assert_eq!(e.skills[0].description, "how we review");
580        assert_eq!(e.skills[0].when_to_use.as_deref(), Some("reviewing PRs"));
581        assert_eq!(e.skills[0].body, "Always check the tests first.");
582        assert!(e.cleaned.contains("@skill:review"));
583    }
584
585    #[test]
586    fn context_and_example_keep_their_bodies_in_tags() {
587        let t = ":::context{title=\"API notes\"}\nrate limit is 10/s\n:::\n:::example\nQ: hi\nA: hello\n:::";
588        let e = extract(t).unwrap();
589        assert!(
590            e.cleaned
591                .contains("<reference title=\"API notes\">\nrate limit is 10/s\n</reference>")
592        );
593        assert!(e.cleaned.contains("<example>\nQ: hi\nA: hello\n</example>"));
594    }
595
596    #[test]
597    fn unknown_names_and_unclosed_fences_fail_closed_with_lines() {
598        let e = extract(":::worfklow\nx: 1\n:::").unwrap_err();
599        assert!(
600            e[0].contains("line 1") && e[0].contains("unknown directive"),
601            "{e:?}"
602        );
603        assert!(e[0].contains("workflow, skill, context, example"), "{e:?}");
604        let e = extract("intro\n:::workflow\nname: w").unwrap_err();
605        assert!(e.iter().any(|m| m.contains("never closed")), "{e:?}");
606        let e = extract(":::workflow\n[not yaml\n:::").unwrap_err();
607        assert!(e.iter().any(|m| m.contains("not valid YAML")), "{e:?}");
608    }
609
610    #[test]
611    fn longer_outer_fences_nest_literal_inner_ones() {
612        let t = "::::context\ninner literal:\n:::workflow\nnot parsed\n:::\ndone\n::::";
613        let e = extract(t).unwrap();
614        assert!(e.workflows.is_empty(), "inner fence is body text");
615        assert!(e.cleaned.contains(":::workflow"), "{}", e.cleaned);
616    }
617
618    #[test]
619    fn attributes_parse_quotes_escapes_and_flags() {
620        let a = parse_attrs(r#"name=x title="a \"b\" c" armed flag-2=7"#).unwrap();
621        assert_eq!(a["name"], "x");
622        assert_eq!(a["title"], "a \"b\" c");
623        assert_eq!(a["armed"], "true");
624        assert_eq!(a["flag-2"], "7");
625        assert!(parse_attrs("name=\"unterminated").is_err());
626    }
627
628    #[test]
629    fn config_blocks_fold_into_one_fragment_later_wins() {
630        let t = ":::config\nlimits: {max_runs: 5}\nstore: {kind: memory}\n:::\n\
631                 prose between\n\
632                 :::config\nlimits: {max_runs: 9}\n:::\n";
633        let e = extract(t).unwrap();
634        assert_eq!(e.config["limits"]["max_runs"], 9, "later block wins");
635        assert_eq!(e.config["store"]["kind"], "memory");
636        assert!(e.cleaned.contains("[runtime configuration is applied]"));
637        assert!(
638            !e.cleaned.contains("max_runs"),
639            "machinery never reaches the model"
640        );
641    }
642
643    #[test]
644    fn mcp_stream_and_tools_blocks_build_the_fragment() {
645        let t = ":::mcp{name=fs timeout=30s}\nendpoint: \"https://fs.internal/mcp\"\nallow: [\"read_*\", \"list_*\"]\nexclude: [\"read_secrets\"]\n:::\n\
646                 :::stream{name=orders}\nretention: {max_events: 50}\n:::\n\
647                 :::stream{name=alerts}\n:::\n\
648                 :::tools\ndisabled: [\"exec\"]\n:::\n";
649        let e = extract(t).unwrap();
650        let srv = &e.config["mcp"]["servers"][0];
651        assert_eq!(srv["name"], "fs");
652        assert_eq!(srv["timeout"], "30s", "attrs merge over the body");
653        assert_eq!(srv["allow"][0], "read_*");
654        assert_eq!(srv["exclude"][0], "read_secrets");
655        assert_eq!(e.config["streams"]["orders"]["retention"]["max_events"], 50);
656        assert!(
657            e.config["streams"]["alerts"].is_object(),
658            "empty body = defaults"
659        );
660        assert_eq!(e.config["tools"]["disabled"][0], "exec");
661        assert!(e.cleaned.contains("mcp server \"fs\""), "{}", e.cleaned);
662        assert!(e.cleaned.contains("stream \"orders\""), "{}", e.cleaned);
663    }
664
665    #[test]
666    fn the_fragment_merges_under_the_explicit_doc() {
667        let mut doc = serde_json::json!({
668            "limits": {"max_runs": 3},
669            "mcp": {"servers": [{"name": "fs", "endpoint": "https://real"}]}
670        });
671        let frag = serde_json::json!({
672            "limits": {"max_runs": 9, "step_timeout": "10s"},
673            "mcp": {"servers": [
674                {"name": "fs", "endpoint": "https://SHADOW"},
675                {"name": "gh", "endpoint": "https://gh"}
676            ]},
677            "streams": {"orders": {}}
678        });
679        merge_missing(
680            doc.as_object_mut().unwrap(),
681            frag.as_object().unwrap().clone(),
682            false,
683        );
684        assert_eq!(doc["limits"]["max_runs"], 3, "explicit config wins");
685        assert_eq!(doc["limits"]["step_timeout"], "10s", "fragment fills gaps");
686        let servers = doc["mcp"]["servers"].as_array().unwrap();
687        assert_eq!(servers.len(), 2, "new server appends; same-name does not");
688        assert_eq!(
689            servers[0]["endpoint"], "https://real",
690            "no shadowing by name"
691        );
692        assert_eq!(servers[1]["name"], "gh");
693        assert!(doc["streams"]["orders"].is_object());
694    }
695
696    #[test]
697    fn a_nameless_mcp_or_stream_block_fails_closed() {
698        assert!(extract(":::mcp\nendpoint: \"https://x\"\n:::\n").is_err());
699        assert!(extract(":::stream\nretention: {}\n:::\n").is_err());
700        assert!(extract(":::config\n- a list\n:::\n").is_err());
701    }
702}