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}
87
88impl ResolveConfig {
89 pub fn new(base: impl Into<PathBuf>) -> Self {
92 Self {
93 base: base.into(),
94 ..Default::default()
95 }
96 }
97
98 pub fn with_in_bp_includes(mut self, v: Vec<PathBuf>) -> Self {
101 self.in_bp_includes = v;
102 self
103 }
104
105 pub fn with_env_includes(mut self, v: Vec<PathBuf>) -> Self {
107 self.env_includes = v;
108 self
109 }
110
111 pub fn with_cli_includes(mut self, v: Vec<PathBuf>) -> Self {
113 self.cli_includes = v;
114 self
115 }
116
117 pub fn with_config_includes(mut self, v: Vec<PathBuf>) -> Self {
119 self.config_includes = v;
120 self
121 }
122
123 pub fn with_bundled_default(mut self, p: Option<PathBuf>) -> Self {
126 self.bundled_default = p;
127 self
128 }
129
130 pub fn search_paths(&self) -> impl Iterator<Item = PathBuf> + '_ {
135 let base = self.base.clone();
136 std::iter::once(self.base.clone())
137 .chain(self.in_bp_includes.iter().map(move |p| base.join(p)))
138 .chain(self.env_includes.iter().cloned())
139 .chain(self.cli_includes.iter().cloned())
140 .chain(self.config_includes.iter().cloned())
141 .chain(self.bundled_default.iter().cloned())
142 }
143}
144
145pub fn env_blueprint_includes() -> Vec<PathBuf> {
149 std::env::var_os("MSE_BLUEPRINT_INCLUDES")
150 .map(|s| std::env::split_paths(&s).collect())
151 .unwrap_or_default()
152}
153
154pub fn pre_read_in_bp_includes(val: &Value) -> Vec<PathBuf> {
158 val.get("blueprint_ref_includes")
159 .and_then(|v| v.as_array())
160 .map(|arr| {
161 arr.iter()
162 .filter_map(|v| v.as_str())
163 .map(PathBuf::from)
164 .collect()
165 })
166 .unwrap_or_default()
167}
168
169#[derive(Debug, Error)]
172pub enum LoadError {
173 #[error("io: {0}")]
176 Io(#[from] std::io::Error),
177 #[error("json parse: {0}")]
179 Json(#[from] serde_json::Error),
180 #[error("yaml parse: {0}")]
182 Yaml(#[from] serde_yaml::Error),
183 #[error("unsupported extension: {0:?} (expected .json / .yaml / .yml)")]
185 UnknownFormat(Option<String>),
186 #[error("$file ref expansion at {path:?}: {msg}")]
189 FileRef {
190 path: PathBuf,
192 msg: String,
194 },
195 #[error("blueprint shape invalid: {0}")]
197 Shape(String),
198}
199
200pub fn load_blueprint_from_path<P: AsRef<Path>>(path: P) -> Result<Blueprint, LoadError> {
204 let path = path.as_ref();
205 let raw = std::fs::read_to_string(path)?;
206 let ext = path
207 .extension()
208 .and_then(|e| e.to_str())
209 .map(|s| s.to_lowercase());
210 let value: Value = match ext.as_deref() {
211 Some("json") => serde_json::from_str(&raw)?,
212 Some("yaml") | Some("yml") => {
213 let yv: serde_yaml::Value = serde_yaml::from_str(&raw)?;
214 serde_json::to_value(yv)
215 .map_err(|e| LoadError::Shape(format!("yaml→json convert: {e}")))?
216 }
217 other => return Err(LoadError::UnknownFormat(other.map(|s| s.to_string()))),
218 };
219 let base = path
220 .parent()
221 .unwrap_or_else(|| Path::new("."))
222 .to_path_buf();
223 let default_kind = pre_read_default_agent_kind(&value);
232 let resolved = expand_file_refs(value, &base, default_kind)?;
233 let bp: Blueprint = serde_json::from_value(resolved)
234 .map_err(|e| LoadError::Shape(format!("typed parse: {e}")))?;
235 Ok(bp)
236}
237
238pub fn pre_read_default_agent_kind(val: &Value) -> AgentKind {
244 val.get("default_agent_kind")
245 .and_then(|v| serde_json::from_value::<AgentKind>(v.clone()).ok())
246 .unwrap_or_else(default_global_agent_kind)
247}
248
249fn resolve_ref_path(rel: &str, cfg: &ResolveConfig) -> Result<PathBuf, LoadError> {
265 let rel_path = Path::new(rel);
266 if rel_path.is_absolute() {
267 return Err(LoadError::FileRef {
268 path: rel_path.to_path_buf(),
269 msg: "absolute path not allowed (must be relative to Blueprint dir)".into(),
270 });
271 }
272 if rel_path
273 .components()
274 .any(|c| matches!(c, std::path::Component::ParentDir))
275 {
276 return Err(LoadError::FileRef {
277 path: rel_path.to_path_buf(),
278 msg: "'..' parent-dir escape not allowed".into(),
279 });
280 }
281 let mut searched: Vec<PathBuf> = Vec::new();
282 for dir in cfg.search_paths() {
283 let candidate = dir.join(rel_path);
284 if candidate.exists() {
285 return Ok(candidate);
286 }
287 searched.push(dir);
288 }
289 let searched_str = searched
290 .iter()
291 .map(|p| p.display().to_string())
292 .collect::<Vec<_>>()
293 .join(", ");
294 Err(LoadError::FileRef {
295 path: rel_path.to_path_buf(),
296 msg: format!("not found in include cascade (searched: {searched_str})"),
297 })
298}
299
300pub fn expand_file_refs_with_config(
311 val: Value,
312 cfg: &ResolveConfig,
313 default_kind: AgentKind,
314) -> Result<Value, LoadError> {
315 match val {
316 Value::Object(map) => {
317 if map.len() == 1 {
319 if let Some(Value::String(rel)) = map.get("$file") {
320 let full = resolve_ref_path(rel, cfg)?;
321 let content =
322 std::fs::read_to_string(&full).map_err(|e| LoadError::FileRef {
323 path: full.clone(),
324 msg: e.to_string(),
325 })?;
326 return Ok(Value::String(content));
327 }
328 }
329 if let Some(Value::String(rel)) = map.get("$agent_md") {
347 let full = resolve_ref_path(rel, cfg)?;
348 let resolved_kind = map
351 .get("kind")
352 .and_then(|v| serde_json::from_value::<AgentKind>(v.clone()).ok())
353 .unwrap_or_else(|| default_kind.clone());
354 let def = crate::agent_md::load_file(&full, resolved_kind).map_err(|e| {
355 LoadError::FileRef {
356 path: full.clone(),
357 msg: format!("agent_md parse: {e}"),
358 }
359 })?;
360 let mut def_v = serde_json::to_value(&def).map_err(|e| LoadError::FileRef {
361 path: full.clone(),
362 msg: format!("agent_md serialize: {e}"),
363 })?;
364 if let Value::Object(def_map) = &mut def_v {
365 for (k, v) in map {
366 if k == "$agent_md" {
367 continue;
368 }
369 let expanded = expand_file_refs_with_config(v, cfg, default_kind.clone())?;
372 def_map.insert(k, expanded);
373 }
374 }
375 return Ok(def_v);
376 }
377 let mut new_map = serde_json::Map::with_capacity(map.len());
378 for (k, v) in map {
379 new_map.insert(
380 k,
381 expand_file_refs_with_config(v, cfg, default_kind.clone())?,
382 );
383 }
384 Ok(Value::Object(new_map))
385 }
386 Value::Array(arr) => {
387 let mut new_arr = Vec::with_capacity(arr.len());
388 for v in arr {
389 new_arr.push(expand_file_refs_with_config(v, cfg, default_kind.clone())?);
390 }
391 Ok(Value::Array(new_arr))
392 }
393 other => Ok(other),
394 }
395}
396
397pub fn expand_file_refs(
401 val: Value,
402 base: &Path,
403 default_kind: AgentKind,
404) -> Result<Value, LoadError> {
405 let cfg = ResolveConfig::new(base.to_path_buf());
406 expand_file_refs_with_config(val, &cfg, default_kind)
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use serde_json::json;
413 use std::fs;
414 use tempfile::TempDir;
415
416 fn write_md(dir: &Path, rel: &str, content: &str) -> PathBuf {
417 let p = dir.join(rel);
418 if let Some(parent) = p.parent() {
419 fs::create_dir_all(parent).unwrap();
420 }
421 fs::write(&p, content).unwrap();
422 p
423 }
424
425 const AGENT_MD: &str = "---\n\
426name: researcher\n\
427description: focus on XX/YY sites\n\
428model: sonnet\n\
429---\n\
430You are a researcher. Focus on XX/YY sites.\n";
431
432 #[test]
433 fn agent_md_ref_expands_to_typed_agent_def_object() {
434 let dir = TempDir::new().unwrap();
435 write_md(dir.path(), "agents/r.md", AGENT_MD);
436
437 let bp = json!({
438 "agents": [ { "$agent_md": "agents/r.md" } ]
439 });
440 let resolved = expand_file_refs(bp, dir.path(), AgentKind::Operator).expect("expand ok");
441
442 let agent = &resolved["agents"][0];
443 assert!(agent.is_object(), "expanded value is JSON object");
444 assert_eq!(agent["name"], "researcher");
445 assert_eq!(agent["kind"], "operator", "default kind from loader");
446 assert!(
447 agent["profile"]["system_prompt"]
448 .as_str()
449 .unwrap()
450 .contains("You are a researcher"),
451 "profile.system_prompt baked from body, got: {:?}",
452 agent["profile"]
453 );
454 }
455
456 #[test]
457 fn agent_md_ref_rejects_absolute_path() {
458 let dir = TempDir::new().unwrap();
459 let bp = json!({ "$agent_md": "/etc/passwd" });
460 let err = expand_file_refs(bp, dir.path(), AgentKind::Operator).expect_err("abs rejected");
461 assert!(format!("{err}").contains("absolute path"), "got: {err}");
462 }
463
464 #[test]
465 fn agent_md_ref_rejects_parent_dir_escape() {
466 let dir = TempDir::new().unwrap();
467 let bp = json!({ "$agent_md": "../escape.md" });
468 let err = expand_file_refs(bp, dir.path(), AgentKind::Operator).expect_err(".. rejected");
469 assert!(format!("{err}").contains("parent-dir escape"), "got: {err}");
470 }
471
472 #[test]
473 fn agent_md_ref_merges_sibling_keys_as_shallow_override() {
474 let dir = TempDir::new().unwrap();
475 write_md(dir.path(), "agents/r.md", AGENT_MD);
476 let bp = json!({
477 "$agent_md": "agents/r.md",
478 "spec": { "operator_ref": "ws-sid-42" },
479 });
480 let resolved = expand_file_refs(bp, dir.path(), AgentKind::Operator).expect("expand ok");
481 assert_eq!(resolved["name"], "researcher", "name from md preserved");
482 assert_eq!(
483 resolved["spec"]["operator_ref"], "ws-sid-42",
484 "sibling spec overrides md default (= Null)"
485 );
486 assert!(
487 resolved["profile"]["system_prompt"]
488 .as_str()
489 .unwrap()
490 .contains("You are a researcher"),
491 "profile from md preserved"
492 );
493 }
494
495 #[test]
499 fn agent_md_ref_sibling_lints_override_the_frontmatter_map() {
500 let dir = TempDir::new().unwrap();
501 write_md(
502 dir.path(),
503 "agents/r.md",
504 "---\nname: researcher\nlints:\n agent-md-size: allow\n \"category:style\": warn\n---\nYou are a researcher.\n",
505 );
506
507 let from_md = expand_file_refs(
509 json!({ "$agent_md": "agents/r.md" }),
510 dir.path(),
511 AgentKind::Operator,
512 )
513 .expect("expand ok");
514 assert_eq!(
515 from_md["lints"],
516 json!({"agent-md-size": "allow", "category:style": "warn"})
517 );
518
519 let overridden = expand_file_refs(
521 json!({
522 "$agent_md": "agents/r.md",
523 "lints": { "verdict-value-unhandled": "deny" },
524 }),
525 dir.path(),
526 AgentKind::Operator,
527 )
528 .expect("expand ok");
529 assert_eq!(
530 overridden["lints"],
531 json!({"verdict-value-unhandled": "deny"}),
532 "sibling lints replaces the frontmatter map wholesale"
533 );
534 assert_eq!(overridden["name"], "researcher", "name from md preserved");
535 }
536
537 #[test]
538 fn file_ref_still_returns_raw_string_unchanged() {
539 let dir = TempDir::new().unwrap();
540 write_md(dir.path(), "prompts/raw.md", "raw body content");
541 let bp = json!({ "$file": "prompts/raw.md" });
542 let resolved = expand_file_refs(bp, dir.path(), AgentKind::Operator).expect("expand ok");
543 assert_eq!(resolved, json!("raw body content"));
544 }
545
546 #[test]
551 fn cascade_falls_through_tiers() {
552 let base_dir = TempDir::new().unwrap();
555 let cli_dir = TempDir::new().unwrap();
556 let bundled_dir = TempDir::new().unwrap();
557
558 write_md(base_dir.path(), "prompts/x.md", "from-base");
560 write_md(cli_dir.path(), "prompts/x.md", "from-cli");
562 write_md(bundled_dir.path(), "prompts/x.md", "from-bundled");
564
565 let cfg = ResolveConfig::new(base_dir.path().to_path_buf())
566 .with_cli_includes(vec![cli_dir.path().to_path_buf()])
567 .with_bundled_default(Some(bundled_dir.path().to_path_buf()));
568 let bp = json!({ "$file": "prompts/x.md" });
569 let resolved =
570 expand_file_refs_with_config(bp, &cfg, AgentKind::Operator).expect("expand ok");
571 assert_eq!(resolved, json!("from-base"));
572 }
573
574 #[test]
575 fn cascade_reports_all_searched_paths_on_miss() {
576 let base_dir = TempDir::new().unwrap();
579 let cli_a = TempDir::new().unwrap();
580 let cli_b = TempDir::new().unwrap();
581
582 let cfg = ResolveConfig::new(base_dir.path().to_path_buf())
583 .with_cli_includes(vec![cli_a.path().to_path_buf(), cli_b.path().to_path_buf()]);
584 let bp = json!({ "$file": "prompts/missing.md" });
585 let err = expand_file_refs_with_config(bp, &cfg, AgentKind::Operator)
586 .expect_err("miss reports cascade");
587 let msg = format!("{err}");
588 assert!(
589 msg.contains(base_dir.path().to_str().unwrap()),
590 "base dir named: {msg}"
591 );
592 assert!(
593 msg.contains(cli_a.path().to_str().unwrap()),
594 "cli_a dir named: {msg}"
595 );
596 assert!(
597 msg.contains(cli_b.path().to_str().unwrap()),
598 "cli_b dir named: {msg}"
599 );
600 assert!(msg.contains("cascade"), "message flags cascade: {msg}");
601 }
602
603 #[test]
604 fn env_includes_split_multi_paths() {
605 let old = std::env::var_os("MSE_BLUEPRINT_INCLUDES");
609 let sep = if cfg!(windows) { ';' } else { ':' };
610 std::env::set_var(
611 "MSE_BLUEPRINT_INCLUDES",
612 format!("/tmp/aaa{sep}/tmp/bbb{sep}/tmp/ccc"),
613 );
614 let got = env_blueprint_includes();
615 match old {
618 Some(v) => std::env::set_var("MSE_BLUEPRINT_INCLUDES", v),
619 None => std::env::remove_var("MSE_BLUEPRINT_INCLUDES"),
620 }
621 assert_eq!(
622 got,
623 vec![
624 PathBuf::from("/tmp/aaa"),
625 PathBuf::from("/tmp/bbb"),
626 PathBuf::from("/tmp/ccc"),
627 ]
628 );
629 }
630
631 #[test]
632 fn in_bp_includes_reader_returns_empty_when_absent() {
633 let bp = json!({ "id": "no-includes" });
634 assert!(pre_read_in_bp_includes(&bp).is_empty());
635
636 let bp2 = json!({
637 "id": "with-includes",
638 "blueprint_ref_includes": ["ext/agents", "vendor/samples"],
639 });
640 assert_eq!(
641 pre_read_in_bp_includes(&bp2),
642 vec![PathBuf::from("ext/agents"), PathBuf::from("vendor/samples")]
643 );
644 }
645
646 #[test]
647 fn absolute_and_parent_escape_still_rejected_across_cascade() {
648 let base = TempDir::new().unwrap();
652 let extra = TempDir::new().unwrap();
653 let cfg = ResolveConfig::new(base.path().to_path_buf())
654 .with_cli_includes(vec![extra.path().to_path_buf()]);
655
656 let err_abs = expand_file_refs_with_config(
657 json!({ "$file": "/etc/passwd" }),
658 &cfg,
659 AgentKind::Operator,
660 )
661 .expect_err("absolute rejected");
662 assert!(
663 format!("{err_abs}").contains("absolute path"),
664 "got: {err_abs}"
665 );
666
667 let err_parent = expand_file_refs_with_config(
668 json!({ "$file": "../escape.md" }),
669 &cfg,
670 AgentKind::Operator,
671 )
672 .expect_err(".. rejected");
673 assert!(
674 format!("{err_parent}").contains("parent-dir escape"),
675 "got: {err_parent}"
676 );
677 }
678
679 #[test]
680 fn bundled_default_used_only_when_no_other_match() {
681 let base_dir = TempDir::new().unwrap();
684 let bundled_dir = TempDir::new().unwrap();
685 write_md(bundled_dir.path(), "prompts/y.md", "from-bundled");
686
687 let cfg = ResolveConfig::new(base_dir.path().to_path_buf())
688 .with_bundled_default(Some(bundled_dir.path().to_path_buf()));
689 let bp = json!({ "$file": "prompts/y.md" });
690 let resolved =
691 expand_file_refs_with_config(bp, &cfg, AgentKind::Operator).expect("expand ok");
692 assert_eq!(resolved, json!("from-bundled"));
693 }
694}