1use mlua_swarm_schema::{default_global_agent_kind, AgentKind, Blueprint};
48use serde_json::Value;
49use std::path::{Path, PathBuf};
50use thiserror::Error;
51
52#[derive(Debug, Clone, Default)]
71pub struct ResolveConfig {
72 pub base: PathBuf,
74 pub in_bp_includes: Vec<PathBuf>,
76 pub env_includes: Vec<PathBuf>,
78 pub cli_includes: Vec<PathBuf>,
80 pub config_includes: Vec<PathBuf>,
82 pub bundled_default: Option<PathBuf>,
86 pub require_declared_kind: bool,
96}
97
98impl ResolveConfig {
99 pub fn new(base: impl Into<PathBuf>) -> Self {
102 Self {
103 base: base.into(),
104 ..Default::default()
105 }
106 }
107
108 pub fn with_in_bp_includes(mut self, v: Vec<PathBuf>) -> Self {
111 self.in_bp_includes = v;
112 self
113 }
114
115 pub fn with_env_includes(mut self, v: Vec<PathBuf>) -> Self {
117 self.env_includes = v;
118 self
119 }
120
121 pub fn with_cli_includes(mut self, v: Vec<PathBuf>) -> Self {
123 self.cli_includes = v;
124 self
125 }
126
127 pub fn with_config_includes(mut self, v: Vec<PathBuf>) -> Self {
129 self.config_includes = v;
130 self
131 }
132
133 pub fn with_bundled_default(mut self, p: Option<PathBuf>) -> Self {
136 self.bundled_default = p;
137 self
138 }
139
140 pub fn with_require_declared_kind(mut self, v: bool) -> Self {
143 self.require_declared_kind = v;
144 self
145 }
146
147 pub fn search_paths(&self) -> impl Iterator<Item = PathBuf> + '_ {
152 let base = self.base.clone();
153 std::iter::once(self.base.clone())
154 .chain(self.in_bp_includes.iter().map(move |p| base.join(p)))
155 .chain(self.env_includes.iter().cloned())
156 .chain(self.cli_includes.iter().cloned())
157 .chain(self.config_includes.iter().cloned())
158 .chain(self.bundled_default.iter().cloned())
159 }
160}
161
162pub fn env_blueprint_includes() -> Vec<PathBuf> {
166 std::env::var_os("MSE_BLUEPRINT_INCLUDES")
167 .map(|s| std::env::split_paths(&s).collect())
168 .unwrap_or_default()
169}
170
171pub fn pre_read_in_bp_includes(val: &Value) -> Vec<PathBuf> {
175 val.get("blueprint_ref_includes")
176 .and_then(|v| v.as_array())
177 .map(|arr| {
178 arr.iter()
179 .filter_map(|v| v.as_str())
180 .map(PathBuf::from)
181 .collect()
182 })
183 .unwrap_or_default()
184}
185
186#[derive(Debug, Error)]
189pub enum LoadError {
190 #[error("io: {0}")]
193 Io(#[from] std::io::Error),
194 #[error("json parse: {0}")]
196 Json(#[from] serde_json::Error),
197 #[error("yaml parse: {0}")]
199 Yaml(#[from] serde_yaml::Error),
200 #[error("unsupported extension: {0:?} (expected .json / .yaml / .yml)")]
202 UnknownFormat(Option<String>),
203 #[error("$file ref expansion at {path:?}: {msg}")]
206 FileRef {
207 path: PathBuf,
209 msg: String,
211 },
212 #[error("blueprint shape invalid: {0}")]
214 Shape(String),
215}
216
217pub fn load_blueprint_from_path<P: AsRef<Path>>(path: P) -> Result<Blueprint, LoadError> {
221 let path = path.as_ref();
222 let raw = std::fs::read_to_string(path)?;
223 let ext = path
224 .extension()
225 .and_then(|e| e.to_str())
226 .map(|s| s.to_lowercase());
227 let value: Value = match ext.as_deref() {
228 Some("json") => serde_json::from_str(&raw)?,
229 Some("yaml") | Some("yml") => {
230 let yv: serde_yaml::Value = serde_yaml::from_str(&raw)?;
231 serde_json::to_value(yv)
232 .map_err(|e| LoadError::Shape(format!("yaml→json convert: {e}")))?
233 }
234 other => return Err(LoadError::UnknownFormat(other.map(|s| s.to_string()))),
235 };
236 let base = path
237 .parent()
238 .unwrap_or_else(|| Path::new("."))
239 .to_path_buf();
240 let default_kind = pre_read_default_agent_kind(&value);
249 let resolved = expand_file_refs(value, &base, default_kind)?;
250 let bp: Blueprint = serde_json::from_value(resolved)
251 .map_err(|e| LoadError::Shape(format!("typed parse: {e}")))?;
252 Ok(bp)
253}
254
255pub fn pre_read_default_agent_kind(val: &Value) -> AgentKind {
261 val.get("default_agent_kind")
262 .and_then(|v| serde_json::from_value::<AgentKind>(v.clone()).ok())
263 .unwrap_or_else(default_global_agent_kind)
264}
265
266pub fn resolve_ref_path(rel: &str, cfg: &ResolveConfig) -> Result<PathBuf, LoadError> {
288 let rel_path = Path::new(rel);
289 if rel_path.is_absolute() {
290 return Err(LoadError::FileRef {
291 path: rel_path.to_path_buf(),
292 msg: "absolute path not allowed (must be relative to Blueprint dir)".into(),
293 });
294 }
295 if rel_path
296 .components()
297 .any(|c| matches!(c, std::path::Component::ParentDir))
298 {
299 return Err(LoadError::FileRef {
300 path: rel_path.to_path_buf(),
301 msg: "'..' parent-dir escape not allowed".into(),
302 });
303 }
304 let mut searched: Vec<PathBuf> = Vec::new();
305 for dir in cfg.search_paths() {
306 let candidate = dir.join(rel_path);
307 if candidate.exists() {
308 return Ok(candidate);
309 }
310 searched.push(dir);
311 }
312 let searched_str = searched
313 .iter()
314 .map(|p| p.display().to_string())
315 .collect::<Vec<_>>()
316 .join(", ");
317 Err(LoadError::FileRef {
318 path: rel_path.to_path_buf(),
319 msg: format!("not found in include cascade (searched: {searched_str})"),
320 })
321}
322
323pub fn expand_file_refs_with_config(
334 val: Value,
335 cfg: &ResolveConfig,
336 default_kind: AgentKind,
337) -> Result<Value, LoadError> {
338 match val {
339 Value::Object(map) => {
340 if map.len() == 1 {
342 if let Some(Value::String(rel)) = map.get("$file") {
343 let full = resolve_ref_path(rel, cfg)?;
344 let content =
345 std::fs::read_to_string(&full).map_err(|e| LoadError::FileRef {
346 path: full.clone(),
347 msg: e.to_string(),
348 })?;
349 return Ok(Value::String(content));
350 }
351 }
352 if let Some(Value::String(rel)) = map.get("$agent_md") {
370 let full = resolve_ref_path(rel, cfg)?;
371 if cfg.require_declared_kind && map.get("kind").is_none() {
372 return Err(LoadError::FileRef {
373 path: full,
374 msg: format!(
375 "strict-embed: kind for `$agent_md` = {rel:?} is not declared in the \
376 Blueprint; set top-level `default_agent_kind` or a sibling `kind` \
377 next to the ref (a fully embedded Blueprint carries its kinds \
378 itself instead of taking the registering server's default)"
379 ),
380 });
381 }
382 let resolved_kind = map
385 .get("kind")
386 .and_then(|v| serde_json::from_value::<AgentKind>(v.clone()).ok())
387 .unwrap_or_else(|| default_kind.clone());
388 let def = crate::agent_md::load_file(&full, resolved_kind).map_err(|e| {
389 LoadError::FileRef {
390 path: full.clone(),
391 msg: format!("agent_md parse: {e}"),
392 }
393 })?;
394 let mut def_v = serde_json::to_value(&def).map_err(|e| LoadError::FileRef {
395 path: full.clone(),
396 msg: format!("agent_md serialize: {e}"),
397 })?;
398 if let Value::Object(def_map) = &mut def_v {
399 for (k, v) in map {
400 if k == "$agent_md" {
401 continue;
402 }
403 let expanded = expand_file_refs_with_config(v, cfg, default_kind.clone())?;
406 def_map.insert(k, expanded);
407 }
408 }
409 return Ok(def_v);
410 }
411 let mut new_map = serde_json::Map::with_capacity(map.len());
412 for (k, v) in map {
413 new_map.insert(
414 k,
415 expand_file_refs_with_config(v, cfg, default_kind.clone())?,
416 );
417 }
418 Ok(Value::Object(new_map))
419 }
420 Value::Array(arr) => {
421 let mut new_arr = Vec::with_capacity(arr.len());
422 for v in arr {
423 new_arr.push(expand_file_refs_with_config(v, cfg, default_kind.clone())?);
424 }
425 Ok(Value::Array(new_arr))
426 }
427 other => Ok(other),
428 }
429}
430
431pub fn expand_file_refs(
435 val: Value,
436 base: &Path,
437 default_kind: AgentKind,
438) -> Result<Value, LoadError> {
439 let cfg = ResolveConfig::new(base.to_path_buf());
440 expand_file_refs_with_config(val, &cfg, default_kind)
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446 use serde_json::json;
447 use std::fs;
448 use tempfile::TempDir;
449
450 fn write_md(dir: &Path, rel: &str, content: &str) -> PathBuf {
451 let p = dir.join(rel);
452 if let Some(parent) = p.parent() {
453 fs::create_dir_all(parent).unwrap();
454 }
455 fs::write(&p, content).unwrap();
456 p
457 }
458
459 const AGENT_MD: &str = "---\n\
460name: researcher\n\
461description: focus on XX/YY sites\n\
462model: sonnet\n\
463---\n\
464You are a researcher. Focus on XX/YY sites.\n";
465
466 #[test]
467 fn agent_md_ref_expands_to_typed_agent_def_object() {
468 let dir = TempDir::new().unwrap();
469 write_md(dir.path(), "agents/r.md", AGENT_MD);
470
471 let bp = json!({
472 "agents": [ { "$agent_md": "agents/r.md" } ]
473 });
474 let resolved = expand_file_refs(bp, dir.path(), AgentKind::Operator).expect("expand ok");
475
476 let agent = &resolved["agents"][0];
477 assert!(agent.is_object(), "expanded value is JSON object");
478 assert_eq!(agent["name"], "researcher");
479 assert_eq!(agent["kind"], "operator", "default kind from loader");
480 assert!(
481 agent["profile"]["system_prompt"]
482 .as_str()
483 .unwrap()
484 .contains("You are a researcher"),
485 "profile.system_prompt baked from body, got: {:?}",
486 agent["profile"]
487 );
488 }
489
490 #[test]
491 fn agent_md_ref_rejects_absolute_path() {
492 let dir = TempDir::new().unwrap();
493 let bp = json!({ "$agent_md": "/etc/passwd" });
494 let err = expand_file_refs(bp, dir.path(), AgentKind::Operator).expect_err("abs rejected");
495 assert!(format!("{err}").contains("absolute path"), "got: {err}");
496 }
497
498 #[test]
499 fn agent_md_ref_rejects_parent_dir_escape() {
500 let dir = TempDir::new().unwrap();
501 let bp = json!({ "$agent_md": "../escape.md" });
502 let err = expand_file_refs(bp, dir.path(), AgentKind::Operator).expect_err(".. rejected");
503 assert!(format!("{err}").contains("parent-dir escape"), "got: {err}");
504 }
505
506 #[test]
507 fn agent_md_ref_merges_sibling_keys_as_shallow_override() {
508 let dir = TempDir::new().unwrap();
509 write_md(dir.path(), "agents/r.md", AGENT_MD);
510 let bp = json!({
511 "$agent_md": "agents/r.md",
512 "spec": { "operator_ref": "ws-sid-42" },
513 });
514 let resolved = expand_file_refs(bp, dir.path(), AgentKind::Operator).expect("expand ok");
515 assert_eq!(resolved["name"], "researcher", "name from md preserved");
516 assert_eq!(
517 resolved["spec"]["operator_ref"], "ws-sid-42",
518 "sibling spec overrides md default (= Null)"
519 );
520 assert!(
521 resolved["profile"]["system_prompt"]
522 .as_str()
523 .unwrap()
524 .contains("You are a researcher"),
525 "profile from md preserved"
526 );
527 }
528
529 #[test]
533 fn agent_md_ref_sibling_lints_override_the_frontmatter_map() {
534 let dir = TempDir::new().unwrap();
535 write_md(
536 dir.path(),
537 "agents/r.md",
538 "---\nname: researcher\nlints:\n agent-md-size: allow\n \"category:style\": warn\n---\nYou are a researcher.\n",
539 );
540
541 let from_md = expand_file_refs(
543 json!({ "$agent_md": "agents/r.md" }),
544 dir.path(),
545 AgentKind::Operator,
546 )
547 .expect("expand ok");
548 assert_eq!(
549 from_md["lints"],
550 json!({"agent-md-size": "allow", "category:style": "warn"})
551 );
552
553 let overridden = expand_file_refs(
555 json!({
556 "$agent_md": "agents/r.md",
557 "lints": { "verdict-value-unhandled": "deny" },
558 }),
559 dir.path(),
560 AgentKind::Operator,
561 )
562 .expect("expand ok");
563 assert_eq!(
564 overridden["lints"],
565 json!({"verdict-value-unhandled": "deny"}),
566 "sibling lints replaces the frontmatter map wholesale"
567 );
568 assert_eq!(overridden["name"], "researcher", "name from md preserved");
569 }
570
571 #[test]
572 fn file_ref_still_returns_raw_string_unchanged() {
573 let dir = TempDir::new().unwrap();
574 write_md(dir.path(), "prompts/raw.md", "raw body content");
575 let bp = json!({ "$file": "prompts/raw.md" });
576 let resolved = expand_file_refs(bp, dir.path(), AgentKind::Operator).expect("expand ok");
577 assert_eq!(resolved, json!("raw body content"));
578 }
579
580 #[test]
585 fn cascade_falls_through_tiers() {
586 let base_dir = TempDir::new().unwrap();
589 let cli_dir = TempDir::new().unwrap();
590 let bundled_dir = TempDir::new().unwrap();
591
592 write_md(base_dir.path(), "prompts/x.md", "from-base");
594 write_md(cli_dir.path(), "prompts/x.md", "from-cli");
596 write_md(bundled_dir.path(), "prompts/x.md", "from-bundled");
598
599 let cfg = ResolveConfig::new(base_dir.path().to_path_buf())
600 .with_cli_includes(vec![cli_dir.path().to_path_buf()])
601 .with_bundled_default(Some(bundled_dir.path().to_path_buf()));
602 let bp = json!({ "$file": "prompts/x.md" });
603 let resolved =
604 expand_file_refs_with_config(bp, &cfg, AgentKind::Operator).expect("expand ok");
605 assert_eq!(resolved, json!("from-base"));
606 }
607
608 #[test]
609 fn cascade_reports_all_searched_paths_on_miss() {
610 let base_dir = TempDir::new().unwrap();
613 let cli_a = TempDir::new().unwrap();
614 let cli_b = TempDir::new().unwrap();
615
616 let cfg = ResolveConfig::new(base_dir.path().to_path_buf())
617 .with_cli_includes(vec![cli_a.path().to_path_buf(), cli_b.path().to_path_buf()]);
618 let bp = json!({ "$file": "prompts/missing.md" });
619 let err = expand_file_refs_with_config(bp, &cfg, AgentKind::Operator)
620 .expect_err("miss reports cascade");
621 let msg = format!("{err}");
622 assert!(
623 msg.contains(base_dir.path().to_str().unwrap()),
624 "base dir named: {msg}"
625 );
626 assert!(
627 msg.contains(cli_a.path().to_str().unwrap()),
628 "cli_a dir named: {msg}"
629 );
630 assert!(
631 msg.contains(cli_b.path().to_str().unwrap()),
632 "cli_b dir named: {msg}"
633 );
634 assert!(msg.contains("cascade"), "message flags cascade: {msg}");
635 }
636
637 #[test]
638 fn env_includes_split_multi_paths() {
639 let old = std::env::var_os("MSE_BLUEPRINT_INCLUDES");
643 let sep = if cfg!(windows) { ';' } else { ':' };
644 std::env::set_var(
645 "MSE_BLUEPRINT_INCLUDES",
646 format!("/tmp/aaa{sep}/tmp/bbb{sep}/tmp/ccc"),
647 );
648 let got = env_blueprint_includes();
649 match old {
652 Some(v) => std::env::set_var("MSE_BLUEPRINT_INCLUDES", v),
653 None => std::env::remove_var("MSE_BLUEPRINT_INCLUDES"),
654 }
655 assert_eq!(
656 got,
657 vec![
658 PathBuf::from("/tmp/aaa"),
659 PathBuf::from("/tmp/bbb"),
660 PathBuf::from("/tmp/ccc"),
661 ]
662 );
663 }
664
665 #[test]
666 fn in_bp_includes_reader_returns_empty_when_absent() {
667 let bp = json!({ "id": "no-includes" });
668 assert!(pre_read_in_bp_includes(&bp).is_empty());
669
670 let bp2 = json!({
671 "id": "with-includes",
672 "blueprint_ref_includes": ["ext/agents", "vendor/samples"],
673 });
674 assert_eq!(
675 pre_read_in_bp_includes(&bp2),
676 vec![PathBuf::from("ext/agents"), PathBuf::from("vendor/samples")]
677 );
678 }
679
680 #[test]
681 fn absolute_and_parent_escape_still_rejected_across_cascade() {
682 let base = TempDir::new().unwrap();
686 let extra = TempDir::new().unwrap();
687 let cfg = ResolveConfig::new(base.path().to_path_buf())
688 .with_cli_includes(vec![extra.path().to_path_buf()]);
689
690 let err_abs = expand_file_refs_with_config(
691 json!({ "$file": "/etc/passwd" }),
692 &cfg,
693 AgentKind::Operator,
694 )
695 .expect_err("absolute rejected");
696 assert!(
697 format!("{err_abs}").contains("absolute path"),
698 "got: {err_abs}"
699 );
700
701 let err_parent = expand_file_refs_with_config(
702 json!({ "$file": "../escape.md" }),
703 &cfg,
704 AgentKind::Operator,
705 )
706 .expect_err(".. rejected");
707 assert!(
708 format!("{err_parent}").contains("parent-dir escape"),
709 "got: {err_parent}"
710 );
711 }
712
713 #[test]
714 fn bundled_default_used_only_when_no_other_match() {
715 let base_dir = TempDir::new().unwrap();
718 let bundled_dir = TempDir::new().unwrap();
719 write_md(bundled_dir.path(), "prompts/y.md", "from-bundled");
720
721 let cfg = ResolveConfig::new(base_dir.path().to_path_buf())
722 .with_bundled_default(Some(bundled_dir.path().to_path_buf()));
723 let bp = json!({ "$file": "prompts/y.md" });
724 let resolved =
725 expand_file_refs_with_config(bp, &cfg, AgentKind::Operator).expect("expand ok");
726 assert_eq!(resolved, json!("from-bundled"));
727 }
728}