1use axum::{
7 extract::{Path, Query, State},
8 http::StatusCode,
9 routing::{get, post},
10 Json, Router,
11};
12use mlua_swarm::blueprint::loader::pre_read_default_agent_kind;
13use mlua_swarm::blueprint::store::{
14 blueprint_version, BlueprintId, BlueprintStore, CommitMetadata,
15};
16use mlua_swarm::blueprint::{default_global_agent_kind, AgentKind, Blueprint};
17use mlua_swarm::core::explain::{explain_agent_ctx, CtxTier};
18use mlua_swarm::core::step_naming::StepNaming;
19use mlua_swarm::operator::render::template_variables;
20use mlua_swarm_compile::{
21 env_blueprint_includes, expand_file_refs_with_config, pre_read_in_bp_includes, ResolveConfig,
22};
23use mlua_swarm_schema::{resolve_runner, Runner};
24use serde::{Deserialize, Serialize};
25use std::collections::BTreeMap;
26use std::path::PathBuf;
27use std::sync::Arc;
28
29#[derive(Clone)]
35pub struct BlueprintsState {
36 pub store: Arc<dyn BlueprintStore>,
38 pub ref_base: Option<PathBuf>,
40 pub ref_includes: Vec<PathBuf>,
45 pub cli_default_agent_kind: Option<AgentKind>,
47 pub strict_embed: bool,
59}
60
61pub fn build_blueprints_router(store: Arc<dyn BlueprintStore>) -> Router {
66 build_blueprints_router_with_refs(store, None, Vec::new(), None, false)
67}
68
69pub fn build_blueprints_router_with_refs(
79 store: Arc<dyn BlueprintStore>,
80 ref_base: Option<PathBuf>,
81 ref_includes: Vec<PathBuf>,
82 cli_default_agent_kind: Option<AgentKind>,
83 strict_embed: bool,
84) -> Router {
85 let state = BlueprintsState {
86 store,
87 ref_base,
88 ref_includes,
89 cli_default_agent_kind,
90 strict_embed,
91 };
92 Router::new()
93 .route("/v1/blueprints/:id/head", get(get_head))
94 .route("/v1/blueprints/:id/history", get(get_history))
95 .route(
96 "/v1/blueprints/:id/agents/:agent/explain",
97 get(explain_agent),
98 )
99 .route(
100 "/v1/blueprints/:id/agents/explain",
101 get(explain_agents_batch),
102 )
103 .route("/v1/blueprints/:id/unarchive", post(unarchive_blueprint))
104 .route(
105 "/v1/blueprints/:id",
106 post(seed_blueprint).delete(archive_blueprint),
107 )
108 .with_state(state)
109}
110
111async fn archive_blueprint(
123 State(state): State<BlueprintsState>,
124 Path(id): Path<String>,
125) -> Result<StatusCode, (StatusCode, String)> {
126 let bp_id = BlueprintId::new(id.clone());
127 state.store.archive_id(&bp_id).await.map_err(|e| match e {
128 mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)
129 | mlua_swarm::blueprint::store::BlueprintStoreError::IdNotFound(_) => {
130 (StatusCode::NOT_FOUND, format!("archive_id: {e}"))
131 }
132 other => (
133 StatusCode::INTERNAL_SERVER_ERROR,
134 format!("archive_id: {other}"),
135 ),
136 })?;
137 Ok(StatusCode::NO_CONTENT)
138}
139
140async fn unarchive_blueprint(
143 State(state): State<BlueprintsState>,
144 Path(id): Path<String>,
145) -> Result<StatusCode, (StatusCode, String)> {
146 let bp_id = BlueprintId::new(id.clone());
147 state
148 .store
149 .unarchive_id(&bp_id)
150 .await
151 .map_err(|e| match e {
152 mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)
153 | mlua_swarm::blueprint::store::BlueprintStoreError::IdNotFound(_) => {
154 (StatusCode::NOT_FOUND, format!("unarchive_id: {e}"))
155 }
156 other => (
157 StatusCode::INTERNAL_SERVER_ERROR,
158 format!("unarchive_id: {other}"),
159 ),
160 })?;
161 Ok(StatusCode::NO_CONTENT)
162}
163
164fn parse_error_with_schema_hint(e: &serde_json::Error) -> String {
168 format!(
169 "blueprint parse: {e} \
170 (hint: fetch the Blueprint JSON Schema via the MCP adapter bp_schema tool)"
171 )
172}
173
174fn collect_unembedded_refs(val: &serde_json::Value) -> Option<Vec<String>> {
182 let mut acc: Vec<String> = Vec::new();
183 walk_refs(val, &mut acc);
184 if acc.is_empty() {
185 None
186 } else {
187 Some(acc)
188 }
189}
190
191fn walk_refs(val: &serde_json::Value, acc: &mut Vec<String>) {
192 match val {
193 serde_json::Value::Object(map) => {
194 for key in ["$file", "$agent_md"] {
195 if let Some(serde_json::Value::String(rel)) = map.get(key) {
196 acc.push(format!("{key}={rel}"));
197 }
198 }
199 for v in map.values() {
200 walk_refs(v, acc);
201 }
202 }
203 serde_json::Value::Array(arr) => {
204 for v in arr {
205 walk_refs(v, acc);
206 }
207 }
208 _ => {}
209 }
210}
211
212fn ref_expand_error_with_fix_hint(e: &mlua_swarm_compile::LoadError) -> String {
218 format!(
219 "ref expand: {e} \
220 (fix: extend the include cascade — add the containing directory via CLI \
221 `--include <DIR>` on `mse serve`, env `MSE_BLUEPRINT_INCLUDES`, config-file \
222 `blueprint_ref_includes`, or in-bp top-level `blueprint_ref_includes = {{...}}`; \
223 or pre-embed refs client-side via `mse bp build --strict-embed`)"
224 )
225}
226
227async fn seed_blueprint(
250 State(state): State<BlueprintsState>,
251 Path(id): Path<String>,
252 Json(raw_body): Json<serde_json::Value>,
253) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, String)> {
254 if state.strict_embed {
260 if let Some(refs) = collect_unembedded_refs(&raw_body) {
261 return Err((
262 StatusCode::BAD_REQUEST,
263 format!(
264 "strict_embed: raw body carries unembedded refs ({}); \
265 pre-embed client-side via `mse bp build --strict-embed` \
266 and POST the fully-resolved Blueprint JSON",
267 refs.join(", ")
268 ),
269 ));
270 }
271 }
272 let body: Blueprint = if let Some(base) = state.ref_base.as_ref() {
273 let default_kind = match pre_read_default_agent_kind(&raw_body) {
279 kind if raw_body.get("default_agent_kind").is_some() => kind,
281 _ => state
283 .cli_default_agent_kind
284 .clone()
285 .unwrap_or_else(default_global_agent_kind),
286 };
287 let cfg = ResolveConfig::new(base.clone())
294 .with_in_bp_includes(pre_read_in_bp_includes(&raw_body))
295 .with_env_includes(env_blueprint_includes())
296 .with_config_includes(state.ref_includes.clone());
297 let expanded = expand_file_refs_with_config(raw_body, &cfg, default_kind)
298 .map_err(|e| (StatusCode::BAD_REQUEST, ref_expand_error_with_fix_hint(&e)))?;
299 serde_json::from_value(expanded)
300 .map_err(|e| (StatusCode::BAD_REQUEST, parse_error_with_schema_hint(&e)))?
301 } else {
302 serde_json::from_value(raw_body)
303 .map_err(|e| (StatusCode::BAD_REQUEST, parse_error_with_schema_hint(&e)))?
304 };
305 let store = state.store;
306 if id != body.id.as_str() {
307 return Err((
308 StatusCode::BAD_REQUEST,
309 format!("path id={id} != body.id={}", body.id),
310 ));
311 }
312 let bp_id = BlueprintId::new(id.clone());
313 let v = blueprint_version(&body).map_err(|e| {
314 (
315 StatusCode::INTERNAL_SERVER_ERROR,
316 format!("bp version: {e}"),
317 )
318 })?;
319 let prev_head = match store.read_head(&bp_id).await {
320 Ok(traced) => Some(traced),
321 Err(mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)) => None,
322 Err(mlua_swarm::blueprint::store::BlueprintStoreError::Archived(_)) => {
323 return Err((
324 StatusCode::CONFLICT,
325 format!("blueprint {id} is archived; POST /v1/blueprints/{id}/unarchive first"),
326 ));
327 }
328 Err(e) => {
329 return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("read_head: {e}")));
330 }
331 };
332 if let Some(traced) = &prev_head {
333 if traced.trace.version == v {
334 return Ok((
335 StatusCode::OK,
336 Json(serde_json::json!({"id": id, "version": format!("{:?}", v), "seeded": false})),
337 ));
338 }
339 }
340 let parents: Vec<_> = prev_head
341 .as_ref()
342 .map(|t| vec![t.trace.version])
343 .unwrap_or_default();
344 let now_ms = std::time::SystemTime::now()
345 .duration_since(std::time::UNIX_EPOCH)
346 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
347 .as_millis() as i64;
348 let meta = CommitMetadata::seed(bp_id.clone(), v, now_ms);
349 store
350 .write_new(&bp_id, &body, &parents, meta)
351 .await
352 .map_err(|e| match &e {
353 mlua_swarm::blueprint::store::BlueprintStoreError::LockBusy => (
354 StatusCode::TOO_MANY_REQUESTS,
355 format!("blueprint {id} lock busy; retry"),
356 ),
357 mlua_swarm::blueprint::store::BlueprintStoreError::Archived(_) => (
358 StatusCode::CONFLICT,
359 format!("blueprint {id} is archived; POST /v1/blueprints/{id}/unarchive first"),
360 ),
361 _ => (StatusCode::INTERNAL_SERVER_ERROR, format!("write_new: {e}")),
362 })?;
363 Ok((
364 StatusCode::CREATED,
365 Json(serde_json::json!({"id": id, "version": format!("{:?}", v), "seeded": true})),
366 ))
367}
368
369#[derive(Debug, Serialize)]
370struct HeadResponse {
371 id: String,
372 version: String,
373 blueprint: Blueprint,
374}
375
376async fn get_head(
377 State(state): State<BlueprintsState>,
378 Path(id): Path<String>,
379) -> Result<Json<HeadResponse>, (StatusCode, String)> {
380 let store = state.store;
381 let bp_id = BlueprintId::new(id.clone());
382 let traced = store
383 .read_head(&bp_id)
384 .await
385 .map_err(|e| (StatusCode::NOT_FOUND, format!("read_head: {e}")))?;
386 Ok(Json(HeadResponse {
387 id,
388 version: format!("{:?}", traced.trace.version),
389 blueprint: traced.value,
390 }))
391}
392
393#[derive(Debug, Deserialize)]
394struct HistoryQuery {
395 #[serde(default = "default_limit")]
396 limit: usize,
397}
398
399fn default_limit() -> usize {
400 20
401}
402
403#[derive(Debug, Serialize)]
404struct HistoryEntry {
405 hash: String,
407 version_label: Option<String>,
409 rationale: String,
411}
412
413#[derive(Debug, Serialize)]
414struct HistoryResponse {
415 count: usize,
416 entries: Vec<HistoryEntry>,
417}
418
419async fn get_history(
420 State(state): State<BlueprintsState>,
421 Path(id): Path<String>,
422 Query(q): Query<HistoryQuery>,
423) -> Result<Json<HistoryResponse>, (StatusCode, String)> {
424 let store = state.store;
425 let bp_id = BlueprintId::new(id);
426 let versions = store
427 .history(&bp_id, q.limit)
428 .await
429 .map_err(|e| (StatusCode::NOT_FOUND, format!("history: {e}")))?;
430 let mut entries = Vec::with_capacity(versions.len());
431 for v in versions {
432 let traced = store.read_version(&bp_id, v).await.map_err(|e| {
433 (
434 StatusCode::INTERNAL_SERVER_ERROR,
435 format!("read_version: {e}"),
436 )
437 })?;
438 let rationale = store
439 .read_commit_rationale(&bp_id, v)
440 .await
441 .unwrap_or(None)
442 .unwrap_or_default();
443 entries.push(HistoryEntry {
444 hash: format!("{:?}", v),
445 version_label: traced.value.metadata.version_label.clone(),
446 rationale,
447 });
448 }
449 let count = entries.len();
450 Ok(Json(HistoryResponse { count, entries }))
451}
452
453#[derive(Debug, Serialize)]
460struct ExplainBlueprintRef {
461 id: String,
463 version: String,
466}
467
468#[derive(Debug, Serialize)]
471struct ExplainAgentRef {
472 name: String,
474 kind: AgentKind,
476}
477
478#[derive(Debug, Serialize)]
483struct ExplainWorkerBinding {
484 variant: String,
486}
487
488#[derive(Debug, Serialize)]
490struct ExplainDeclaredTools {
491 tools: Vec<String>,
493 informational: bool,
495 note: String,
497}
498
499#[derive(Debug, Serialize)]
502struct ExplainSystemPrompt {
503 bytes: usize,
505 lines: usize,
507 template_variables: Vec<String>,
511 template_syntax_error: Option<String>,
514 note: String,
516}
517
518#[derive(Debug, Serialize)]
520struct ExplainCtxKeyEntry {
521 value: serde_json::Value,
523 winning_tier: String,
526}
527
528#[derive(Debug, Serialize)]
533struct ExplainEffectiveCtx {
534 keys: BTreeMap<String, ExplainCtxKeyEntry>,
536 note: String,
538}
539
540#[derive(Debug, Serialize)]
542struct ExplainOutput {
543 projection_name: String,
547 naming_warnings: Vec<String>,
552 parts_note: String,
555}
556
557#[derive(Debug, Serialize)]
565struct ExplainRunner {
566 resolved: Option<Runner>,
572 error: Option<String>,
576 warning: Option<String>,
582}
583
584fn runner_kind_mismatch_warning(
589 runner: &Runner,
590 kind: &AgentKind,
591 agent_name: &str,
592) -> Option<String> {
593 match (runner, kind) {
594 (Runner::AgentBlockInProcess { .. }, AgentKind::AgentBlock) => None,
595 (Runner::AgentBlockInProcess { .. }, other) => Some(format!(
596 "agent '{agent_name}' resolves to Runner::AgentBlockInProcess but AgentDef.kind = \
597 {other:?} (expected AgentBlock)"
598 )),
599 (Runner::WsClaudeCode { .. }, AgentKind::AgentBlock) => Some(format!(
600 "agent '{agent_name}' resolves to Runner::WsClaudeCode but AgentDef.kind = AgentBlock"
601 )),
602 (Runner::WsClaudeCode { .. }, _) => None,
603 }
604}
605
606#[derive(Debug, Serialize)]
608struct ExplainAgentResponse {
609 blueprint: ExplainBlueprintRef,
611 agent: ExplainAgentRef,
613 worker_binding: Option<ExplainWorkerBinding>,
615 binding_note: Option<String>,
617 runner: ExplainRunner,
619 declared_tools: ExplainDeclaredTools,
621 system_prompt: Option<ExplainSystemPrompt>,
624 effective_ctx: ExplainEffectiveCtx,
626 output: ExplainOutput,
628}
629
630fn ctx_tier_label(tier: CtxTier) -> &'static str {
633 match tier {
634 CtxTier::AgentInline => "agent_inline",
635 CtxTier::MetaRef => "meta_ref",
636 CtxTier::BpGlobal => "bp_global",
637 }
638}
639
640fn explain_system_prompt(template: &str) -> ExplainSystemPrompt {
643 let (variables, template_syntax_error): (Vec<String>, Option<String>) =
644 match template_variables(template) {
645 Ok(vars) => (vars.into_iter().collect(), None),
646 Err(e) => (Vec::new(), Some(e.to_string())),
647 };
648 ExplainSystemPrompt {
649 bytes: template.len(),
650 lines: template.lines().count(),
651 template_variables: variables,
652 template_syntax_error,
653 note: "when the step directive is not a JSON object, only `value` is bound at render \
654 time"
655 .to_string(),
656 }
657}
658
659async fn explain_agent(
674 State(state): State<BlueprintsState>,
675 Path((id, agent)): Path<(String, String)>,
676) -> Result<Json<ExplainAgentResponse>, (StatusCode, String)> {
677 let store = state.store;
678 let bp_id = BlueprintId::new(id.clone());
679 let traced = store
680 .read_head(&bp_id)
681 .await
682 .map_err(|e| (StatusCode::NOT_FOUND, format!("read_head: {e}")))?;
683 let bp = traced.value;
684 let version = format!("{:?}", traced.trace.version);
685
686 let Some(agent_def) = bp.agents.iter().find(|ad| ad.name == agent) else {
687 let available: Vec<&str> = bp.agents.iter().map(|ad| ad.name.as_str()).collect();
688 return Err((
689 StatusCode::NOT_FOUND,
690 serde_json::json!({
691 "error": "agent not found in blueprint",
692 "agent": agent,
693 "available": available,
694 })
695 .to_string(),
696 ));
697 };
698
699 let profile = agent_def.profile.as_ref();
700
701 let (worker_binding, binding_note) = match profile.and_then(|p| p.worker_binding.as_ref()) {
702 Some(variant) => (
703 Some(ExplainWorkerBinding {
704 variant: variant.clone(),
705 }),
706 None,
707 ),
708 None => (
709 None,
710 Some(
711 "no worker_binding declared; WS operator dispatch will fail at compile \
712 (InvalidSpec)"
713 .to_string(),
714 ),
715 ),
716 };
717
718 let declared_tools = ExplainDeclaredTools {
719 tools: profile.map(|p| p.tools.clone()).unwrap_or_default(),
720 informational: true,
721 note: "declared tools do not grant anything; the effective tool surface is the worker \
722 wrapper's frontmatter (see operator.rs WorkerBinding doc)"
723 .to_string(),
724 };
725
726 let runner = match resolve_runner(&bp, agent_def) {
731 Ok(resolved) => {
732 let warning = resolved
733 .as_ref()
734 .and_then(|r| runner_kind_mismatch_warning(r, &agent_def.kind, &agent_def.name));
735 ExplainRunner {
736 resolved,
737 error: None,
738 warning,
739 }
740 }
741 Err(e) => ExplainRunner {
742 resolved: None,
743 error: Some(e.to_string()),
744 warning: None,
745 },
746 };
747
748 let system_prompt = profile
749 .filter(|p| !p.system_prompt.is_empty())
750 .map(|p| explain_system_prompt(&p.system_prompt));
751
752 let ctx_keys = explain_agent_ctx(&bp, &agent).unwrap_or_default();
753 let effective_ctx = ExplainEffectiveCtx {
754 keys: ctx_keys
755 .into_iter()
756 .map(|(k, resolution)| {
757 (
758 k,
759 ExplainCtxKeyEntry {
760 value: resolution.value,
761 winning_tier: ctx_tier_label(resolution.winning_tier).to_string(),
762 },
763 )
764 })
765 .collect(),
766 note: "static tiers only; Run/Task/Step runtime tiers always win over these \
767 (only-if-absent insertion order)"
768 .to_string(),
769 };
770
771 let (projection_name, naming_warnings) = match StepNaming::from_blueprint(&bp) {
772 Ok((naming, _soft_warnings)) => match naming.canonical_of_producer(&agent) {
773 Some(canonical) => (canonical.to_string(), Vec::new()),
774 None => (
775 agent.clone(),
776 vec![format!(
777 "agent '{agent}' does not appear in the blueprint's flow; using the agent \
778 name as a fallback projection name"
779 )],
780 ),
781 },
782 Err(e) => (
783 agent.clone(),
784 vec![format!("StepNaming::from_blueprint failed: {e}")],
785 ),
786 };
787
788 let output = ExplainOutput {
789 projection_name,
790 naming_warnings,
791 parts_note: "if the worker stages named artifact parts, the step OUTPUT changes shape \
792 to {\"out\", \"parts\"}; reference via $.<step>.out"
793 .to_string(),
794 };
795
796 Ok(Json(ExplainAgentResponse {
797 blueprint: ExplainBlueprintRef { id, version },
798 agent: ExplainAgentRef {
799 name: agent_def.name.clone(),
800 kind: agent_def.kind.clone(),
801 },
802 worker_binding,
803 binding_note,
804 runner,
805 declared_tools,
806 system_prompt,
807 effective_ctx,
808 output,
809 }))
810}
811
812#[derive(Debug, Serialize)]
820struct WorkerBindingSummary {
821 variant: String,
823}
824
825#[derive(Debug, Serialize)]
832struct AgentSummary {
833 name: String,
835 kind: String,
838 worker_binding: Option<WorkerBindingSummary>,
842 declared_tools_count: usize,
844 system_prompt_bytes: usize,
847 effective_ctx_key_count: usize,
850 projection_name: String,
856}
857
858#[derive(Debug, Serialize)]
860struct BatchExplainAgentsResponse {
861 blueprint: ExplainBlueprintRef,
863 agents: Vec<AgentSummary>,
865}
866
867async fn explain_agents_batch(
880 State(state): State<BlueprintsState>,
881 Path(id): Path<String>,
882) -> Result<Json<BatchExplainAgentsResponse>, (StatusCode, String)> {
883 let store = state.store;
884 let bp_id = BlueprintId::new(id.clone());
885 let traced = store
886 .read_head(&bp_id)
887 .await
888 .map_err(|e| (StatusCode::NOT_FOUND, format!("read_head: {e}")))?;
889 let bp = traced.value;
890 let version = format!("{:?}", traced.trace.version);
891
892 let naming = StepNaming::from_blueprint(&bp)
897 .ok()
898 .map(|(naming, _)| naming);
899
900 let agents = bp
901 .agents
902 .iter()
903 .map(|agent_def| {
904 let profile = agent_def.profile.as_ref();
905 let worker_binding = profile
906 .and_then(|p| p.worker_binding.as_ref())
907 .map(|variant| WorkerBindingSummary {
908 variant: variant.clone(),
909 });
910 let declared_tools_count = profile.map(|p| p.tools.len()).unwrap_or(0);
911 let system_prompt_bytes = profile.map(|p| p.system_prompt.len()).unwrap_or(0);
912 let effective_ctx_key_count = explain_agent_ctx(&bp, &agent_def.name)
913 .map(|keys| keys.len())
914 .unwrap_or(0);
915 let projection_name = naming
916 .as_ref()
917 .and_then(|naming| naming.canonical_of_producer(&agent_def.name))
918 .map(|canonical| canonical.to_string())
919 .unwrap_or_else(|| agent_def.name.clone());
920 AgentSummary {
921 name: agent_def.name.clone(),
922 kind: format!("{:?}", agent_def.kind),
923 worker_binding,
924 declared_tools_count,
925 system_prompt_bytes,
926 effective_ctx_key_count,
927 projection_name,
928 }
929 })
930 .collect();
931
932 Ok(Json(BatchExplainAgentsResponse {
933 blueprint: ExplainBlueprintRef { id, version },
934 agents,
935 }))
936}
937
938#[cfg(test)]
939mod explain_agent_tests {
940 use super::*;
941 use mlua_swarm::blueprint::store::InMemoryBlueprintStore;
942 use mlua_swarm::blueprint::{
943 current_schema_version, AgentDef, AgentMeta, AgentProfile, BlueprintMetadata,
944 CompilerHints, CompilerStrategy,
945 };
946 use serde_json::json;
947
948 fn agent_def(name: &str, profile: Option<AgentProfile>, meta: Option<AgentMeta>) -> AgentDef {
949 AgentDef {
950 name: name.to_string(),
951 kind: AgentKind::RustFn,
952 spec: json!({ "fn_id": name }),
953 profile,
954 meta,
955 runner: None,
956 runner_ref: None,
957 verdict: None,
958 }
959 }
960
961 fn single_step_bp(
965 bp_id: &str,
966 agent_name: &str,
967 profile: Option<AgentProfile>,
968 meta: Option<AgentMeta>,
969 default_agent_ctx: Option<serde_json::Value>,
970 ) -> Blueprint {
971 Blueprint {
972 schema_version: current_schema_version(),
973 id: bp_id.into(),
974 flow: serde_json::from_value(json!({
975 "kind": "step",
976 "ref": agent_name,
977 "in": {"op": "path", "at": "$.input"},
978 "out": {"op": "path", "at": "$.out"},
979 }))
980 .expect("flow parse"),
981 agents: vec![agent_def(agent_name, profile, meta)],
982 operators: vec![],
983 metas: vec![],
984 hints: CompilerHints::default(),
985 strategy: CompilerStrategy::default(),
986 metadata: BlueprintMetadata::default(),
987 spawner_hints: Default::default(),
988 default_agent_kind: AgentKind::Operator,
989 default_operator_kind: None,
990 default_init_ctx: None,
991 default_agent_ctx,
992 default_context_policy: None,
993 projection_placement: None,
994 audits: vec![],
995 degradation_policy: None,
996 runners: vec![],
997 default_runner: None,
998 check_policy: None,
999 blueprint_ref_includes: Vec::new(),
1000 }
1001 }
1002
1003 async fn seed(store: &InMemoryBlueprintStore, bp: &Blueprint) {
1004 let bp_id = BlueprintId::new(bp.id.as_str());
1005 let v = blueprint_version(bp).expect("version");
1006 store
1007 .write_new(&bp_id, bp, &[], CommitMetadata::seed(bp_id.clone(), v, 0))
1008 .await
1009 .expect("write_new");
1010 }
1011
1012 fn state_with(store: InMemoryBlueprintStore) -> BlueprintsState {
1013 BlueprintsState {
1014 store: Arc::new(store),
1015 ref_base: None,
1016 ref_includes: Vec::new(),
1017 cli_default_agent_kind: None,
1018 strict_embed: false,
1019 }
1020 }
1021
1022 #[tokio::test]
1023 async fn full_case_reports_binding_ctx_override_and_system_prompt() {
1024 let profile = AgentProfile {
1025 system_prompt: "Hello {{ name }}, mode={{ mode }}".to_string(),
1026 tools: vec!["Read".to_string(), "Grep".to_string()],
1027 worker_binding: Some("mse-worker-knowledge".to_string()),
1028 ..Default::default()
1029 };
1030 let meta = AgentMeta {
1031 ctx: Some(json!({ "work_dir": "/inline" })),
1032 ..Default::default()
1033 };
1034 let bp = single_step_bp(
1035 "explain-full-bp",
1036 "researcher",
1037 Some(profile),
1038 Some(meta),
1039 Some(json!({ "work_dir": "/bp-global", "extra": "kept" })),
1040 );
1041 let store = InMemoryBlueprintStore::new();
1042 seed(&store, &bp).await;
1043
1044 let resp = explain_agent(
1045 State(state_with(store)),
1046 Path(("explain-full-bp".to_string(), "researcher".to_string())),
1047 )
1048 .await
1049 .expect("explain_agent")
1050 .0;
1051
1052 assert_eq!(resp.blueprint.id, "explain-full-bp");
1053 assert!(!resp.blueprint.version.is_empty());
1054 assert_eq!(resp.agent.name, "researcher");
1055 assert_eq!(resp.agent.kind, AgentKind::RustFn);
1056
1057 let binding = resp.worker_binding.expect("worker_binding present");
1058 assert_eq!(binding.variant, "mse-worker-knowledge");
1059 assert!(resp.binding_note.is_none());
1060
1061 assert_eq!(
1062 resp.declared_tools.tools,
1063 vec!["Read".to_string(), "Grep".to_string()]
1064 );
1065 assert!(resp.declared_tools.informational);
1066
1067 let sp = resp.system_prompt.expect("system_prompt present");
1068 assert_eq!(sp.bytes, "Hello {{ name }}, mode={{ mode }}".len());
1069 assert_eq!(sp.lines, 1);
1070 assert_eq!(
1071 sp.template_variables,
1072 vec!["mode".to_string(), "name".to_string()]
1073 );
1074 assert!(sp.template_syntax_error.is_none());
1075
1076 assert_eq!(resp.effective_ctx.keys["work_dir"].value, json!("/inline"));
1077 assert_eq!(
1078 resp.effective_ctx.keys["work_dir"].winning_tier,
1079 "agent_inline"
1080 );
1081 assert_eq!(resp.effective_ctx.keys["extra"].value, json!("kept"));
1082 assert_eq!(resp.effective_ctx.keys["extra"].winning_tier, "bp_global");
1083
1084 assert_eq!(resp.output.projection_name, "researcher");
1085 assert!(resp.output.naming_warnings.is_empty());
1086 }
1087
1088 #[tokio::test]
1089 async fn agent_without_worker_binding_reports_binding_note() {
1090 let profile = AgentProfile {
1091 tools: vec!["Read".to_string()],
1092 ..Default::default()
1093 };
1094 let bp = single_step_bp("explain-no-binding-bp", "scout", Some(profile), None, None);
1095 let store = InMemoryBlueprintStore::new();
1096 seed(&store, &bp).await;
1097
1098 let resp = explain_agent(
1099 State(state_with(store)),
1100 Path(("explain-no-binding-bp".to_string(), "scout".to_string())),
1101 )
1102 .await
1103 .expect("explain_agent")
1104 .0;
1105
1106 assert!(resp.worker_binding.is_none());
1107 let note = resp.binding_note.expect("binding_note present");
1108 assert!(note.contains("no worker_binding declared"));
1109 assert!(resp.system_prompt.is_none());
1110 }
1111
1112 #[tokio::test]
1113 async fn unknown_agent_name_returns_404_with_available_list() {
1114 let bp = single_step_bp("explain-404-agent-bp", "foo", None, None, None);
1115 let store = InMemoryBlueprintStore::new();
1116 seed(&store, &bp).await;
1117
1118 let err = explain_agent(
1119 State(state_with(store)),
1120 Path((
1121 "explain-404-agent-bp".to_string(),
1122 "no-such-agent".to_string(),
1123 )),
1124 )
1125 .await
1126 .expect_err("expected 404");
1127
1128 assert_eq!(err.0, StatusCode::NOT_FOUND);
1129 let body: serde_json::Value = serde_json::from_str(&err.1).expect("json body");
1130 assert_eq!(body["error"], "agent not found in blueprint");
1131 assert_eq!(body["agent"], "no-such-agent");
1132 assert_eq!(body["available"], json!(["foo"]));
1133 }
1134
1135 #[tokio::test]
1136 async fn unknown_blueprint_id_returns_404_same_as_get_head() {
1137 let store = InMemoryBlueprintStore::new();
1138
1139 let err = explain_agent(
1140 State(state_with(store)),
1141 Path(("no-such-bp".to_string(), "any-agent".to_string())),
1142 )
1143 .await
1144 .expect_err("expected 404");
1145
1146 assert_eq!(err.0, StatusCode::NOT_FOUND);
1147 }
1148
1149 #[tokio::test]
1150 async fn template_syntax_error_is_reported_without_500() {
1151 let profile = AgentProfile {
1152 system_prompt: "hello {{ unclosed".to_string(),
1153 ..Default::default()
1154 };
1155 let bp = single_step_bp(
1156 "explain-syntax-error-bp",
1157 "scout",
1158 Some(profile),
1159 None,
1160 None,
1161 );
1162 let store = InMemoryBlueprintStore::new();
1163 seed(&store, &bp).await;
1164
1165 let resp = explain_agent(
1166 State(state_with(store)),
1167 Path(("explain-syntax-error-bp".to_string(), "scout".to_string())),
1168 )
1169 .await
1170 .expect("explain_agent")
1171 .0;
1172
1173 let sp = resp.system_prompt.expect("system_prompt present");
1174 assert!(sp.template_variables.is_empty());
1175 assert!(sp.template_syntax_error.is_some());
1176 }
1177
1178 #[tokio::test]
1181 async fn runner_resolves_from_legacy_worker_binding_when_nothing_else_declared() {
1182 let profile = AgentProfile {
1183 worker_binding: Some("mse-worker-knowledge".to_string()),
1184 tools: vec!["Read".to_string()],
1185 ..Default::default()
1186 };
1187 let bp = single_step_bp(
1188 "explain-runner-legacy-bp",
1189 "scout",
1190 Some(profile),
1191 None,
1192 None,
1193 );
1194 let store = InMemoryBlueprintStore::new();
1195 seed(&store, &bp).await;
1196
1197 let resp = explain_agent(
1198 State(state_with(store)),
1199 Path(("explain-runner-legacy-bp".to_string(), "scout".to_string())),
1200 )
1201 .await
1202 .expect("explain_agent")
1203 .0;
1204
1205 assert_eq!(
1206 resp.runner.resolved,
1207 Some(mlua_swarm_schema::Runner::WsClaudeCode {
1208 variant: "mse-worker-knowledge".to_string(),
1209 tools: vec!["Read".to_string()],
1210 })
1211 );
1212 assert!(resp.runner.error.is_none());
1213 assert!(resp.runner.warning.is_none());
1214 }
1215
1216 #[tokio::test]
1217 async fn runner_reports_unresolved_runner_ref_as_error_level_finding() {
1218 let mut bp = single_step_bp("explain-runner-unresolved-bp", "scout", None, None, None);
1219 bp.agents[0].runner_ref = Some("no-such-entry".to_string());
1220 let store = InMemoryBlueprintStore::new();
1221 seed(&store, &bp).await;
1222
1223 let resp = explain_agent(
1224 State(state_with(store)),
1225 Path((
1226 "explain-runner-unresolved-bp".to_string(),
1227 "scout".to_string(),
1228 )),
1229 )
1230 .await
1231 .expect("explain_agent")
1232 .0;
1233
1234 assert!(resp.runner.resolved.is_none());
1235 let error = resp.runner.error.expect("error-level finding present");
1236 assert!(
1237 error.contains("no-such-entry"),
1238 "error must name the unresolved runner_ref: {error}"
1239 );
1240 assert!(resp.runner.warning.is_none());
1241 }
1242
1243 #[tokio::test]
1244 async fn runner_reports_backend_kind_mismatch_as_warn_level_finding() {
1245 let mut bp = single_step_bp("explain-runner-mismatch-bp", "scout", None, None, None);
1249 bp.runners = vec![mlua_swarm_schema::RunnerDef {
1250 name: "in-process".to_string(),
1251 runner: mlua_swarm_schema::Runner::AgentBlockInProcess {
1252 tools: vec!["Bash".to_string()],
1253 },
1254 }];
1255 bp.agents[0].runner_ref = Some("in-process".to_string());
1256 let store = InMemoryBlueprintStore::new();
1257 seed(&store, &bp).await;
1258
1259 let resp = explain_agent(
1260 State(state_with(store)),
1261 Path((
1262 "explain-runner-mismatch-bp".to_string(),
1263 "scout".to_string(),
1264 )),
1265 )
1266 .await
1267 .expect("explain_agent")
1268 .0;
1269
1270 assert!(resp.runner.resolved.is_some());
1271 assert!(resp.runner.error.is_none());
1272 let warning = resp.runner.warning.expect("warn-level finding present");
1273 assert!(
1274 warning.contains("AgentBlockInProcess") && warning.contains("RustFn"),
1275 "warning must name both the resolved backend and the mismatched kind: {warning}"
1276 );
1277 }
1278
1279 fn batch_bp() -> Blueprint {
1285 let bound_profile = AgentProfile {
1286 system_prompt: "hello world".to_string(),
1287 tools: vec!["Read".to_string(), "Grep".to_string()],
1288 worker_binding: Some("mse-worker-knowledge".to_string()),
1289 ..Default::default()
1290 };
1291 let bound_meta = AgentMeta {
1292 ctx: Some(json!({ "work_dir": "/inline" })),
1293 ..Default::default()
1294 };
1295 Blueprint {
1296 schema_version: current_schema_version(),
1297 id: "explain-batch-bp".into(),
1298 flow: serde_json::from_value(json!({
1299 "kind": "step",
1300 "ref": "bound_agent",
1301 "in": {"op": "path", "at": "$.input"},
1302 "out": {"op": "path", "at": "$.out"},
1303 }))
1304 .expect("flow parse"),
1305 agents: vec![
1306 agent_def("bound_agent", Some(bound_profile), Some(bound_meta)),
1307 agent_def("unbound_agent", None, None),
1308 agent_def("orphan_agent", None, None),
1309 ],
1310 operators: vec![],
1311 metas: vec![],
1312 hints: CompilerHints::default(),
1313 strategy: CompilerStrategy::default(),
1314 metadata: BlueprintMetadata::default(),
1315 spawner_hints: Default::default(),
1316 default_agent_kind: AgentKind::Operator,
1317 default_operator_kind: None,
1318 default_init_ctx: None,
1319 default_agent_ctx: Some(json!({ "work_dir": "/bp-global", "extra": "kept" })),
1320 default_context_policy: None,
1321 projection_placement: None,
1322 audits: vec![],
1323 degradation_policy: None,
1324 runners: vec![],
1325 default_runner: None,
1326 check_policy: None,
1327 blueprint_ref_includes: Vec::new(),
1328 }
1329 }
1330
1331 #[tokio::test]
1332 async fn explain_agents_batch_reports_a_summary_row_per_agent() {
1333 let bp = batch_bp();
1334 let store = InMemoryBlueprintStore::new();
1335 seed(&store, &bp).await;
1336
1337 let resp = explain_agents_batch(
1338 State(state_with(store)),
1339 Path("explain-batch-bp".to_string()),
1340 )
1341 .await
1342 .expect("explain_agents_batch")
1343 .0;
1344
1345 assert_eq!(resp.blueprint.id, "explain-batch-bp");
1346 assert!(!resp.blueprint.version.is_empty());
1347 assert_eq!(resp.agents.len(), 3);
1348
1349 let bound = resp
1350 .agents
1351 .iter()
1352 .find(|a| a.name == "bound_agent")
1353 .expect("bound_agent row");
1354 assert_eq!(bound.kind, format!("{:?}", AgentKind::RustFn));
1355 let binding = bound
1356 .worker_binding
1357 .as_ref()
1358 .expect("worker_binding present");
1359 assert_eq!(binding.variant, "mse-worker-knowledge");
1360 assert_eq!(bound.declared_tools_count, 2);
1361 assert_eq!(bound.system_prompt_bytes, "hello world".len());
1362 assert_eq!(bound.effective_ctx_key_count, 2);
1364 assert_eq!(bound.projection_name, "bound_agent");
1366
1367 let unbound = resp
1368 .agents
1369 .iter()
1370 .find(|a| a.name == "unbound_agent")
1371 .expect("unbound_agent row");
1372 assert!(unbound.worker_binding.is_none());
1373 assert_eq!(unbound.declared_tools_count, 0);
1374 assert_eq!(unbound.system_prompt_bytes, 0);
1375 assert_eq!(unbound.effective_ctx_key_count, 2);
1377 assert_eq!(unbound.projection_name, "unbound_agent");
1379
1380 let orphan = resp
1381 .agents
1382 .iter()
1383 .find(|a| a.name == "orphan_agent")
1384 .expect("orphan_agent row");
1385 assert_eq!(orphan.projection_name, "orphan_agent");
1386 }
1387
1388 #[tokio::test]
1389 async fn explain_agents_batch_zero_agents_returns_empty_list_not_404() {
1390 let bp = Blueprint {
1391 schema_version: current_schema_version(),
1392 id: "explain-batch-empty-bp".into(),
1393 flow: serde_json::from_value(json!({
1394 "kind": "step",
1395 "ref": "unused",
1396 "in": {"op": "path", "at": "$.input"},
1397 "out": {"op": "path", "at": "$.out"},
1398 }))
1399 .expect("flow parse"),
1400 agents: vec![],
1401 operators: vec![],
1402 metas: vec![],
1403 hints: CompilerHints::default(),
1404 strategy: CompilerStrategy::default(),
1405 metadata: BlueprintMetadata::default(),
1406 spawner_hints: Default::default(),
1407 default_agent_kind: AgentKind::Operator,
1408 default_operator_kind: None,
1409 default_init_ctx: None,
1410 default_agent_ctx: None,
1411 default_context_policy: None,
1412 projection_placement: None,
1413 audits: vec![],
1414 degradation_policy: None,
1415 runners: vec![],
1416 default_runner: None,
1417 check_policy: None,
1418 blueprint_ref_includes: Vec::new(),
1419 };
1420 let store = InMemoryBlueprintStore::new();
1421 seed(&store, &bp).await;
1422
1423 let resp = explain_agents_batch(
1424 State(state_with(store)),
1425 Path("explain-batch-empty-bp".to_string()),
1426 )
1427 .await
1428 .expect("explain_agents_batch")
1429 .0;
1430
1431 assert!(resp.agents.is_empty());
1432 }
1433
1434 #[tokio::test]
1435 async fn explain_agents_batch_unknown_blueprint_id_returns_404_same_as_get_head() {
1436 let store = InMemoryBlueprintStore::new();
1437
1438 let err = explain_agents_batch(State(state_with(store)), Path("no-such-bp".to_string()))
1439 .await
1440 .expect_err("expected 404");
1441
1442 assert_eq!(err.0, StatusCode::NOT_FOUND);
1443 }
1444}
1445
1446#[cfg(test)]
1452mod seed_strict_embed_tests {
1453 use super::*;
1454 use mlua_swarm::blueprint::store::InMemoryBlueprintStore;
1455 use serde_json::json;
1456 use std::fs;
1457 use tempfile::TempDir;
1458
1459 const AGENT_MD: &str = "---\n\
1462name: writer\n\
1463description: writes\n\
1464model: sonnet\n\
1465---\n\
1466You write.\n";
1467
1468 fn write_md(dir: &std::path::Path, rel: &str, content: &str) -> PathBuf {
1469 let p = dir.join(rel);
1470 if let Some(parent) = p.parent() {
1471 fs::create_dir_all(parent).unwrap();
1472 }
1473 fs::write(&p, content).unwrap();
1474 p
1475 }
1476
1477 fn minimal_bp_body(id: &str) -> serde_json::Value {
1482 json!({
1483 "schema_version": mlua_swarm::blueprint::current_schema_version(),
1484 "id": id,
1485 "flow": { "kind": "step", "ref": "writer",
1486 "in": {"op": "path", "at": "$.input"},
1487 "out": {"op": "path", "at": "$.out"} },
1488 "agents": [
1489 { "name": "writer", "kind": "rust_fn", "spec": { "fn_id": "writer" } }
1490 ],
1491 "operators": [],
1492 "metas": [],
1493 "hints": {},
1494 "strategy": {},
1495 "metadata": {},
1496 "spawner_hints": {},
1497 "default_agent_kind": "operator",
1498 "default_agent_ctx": null,
1499 "audits": [],
1500 "runners": [],
1501 "blueprint_ref_includes": []
1502 })
1503 }
1504
1505 fn state_for_test(
1506 store: InMemoryBlueprintStore,
1507 ref_base: Option<PathBuf>,
1508 strict_embed: bool,
1509 ) -> BlueprintsState {
1510 BlueprintsState {
1511 store: Arc::new(store),
1512 ref_base,
1513 ref_includes: Vec::new(),
1514 cli_default_agent_kind: None,
1515 strict_embed,
1516 }
1517 }
1518
1519 #[tokio::test]
1521 async fn strict_embed_off_resolves_agent_md_ref_and_seeds() {
1522 let dir = TempDir::new().unwrap();
1523 write_md(dir.path(), "agents/writer.md", AGENT_MD);
1524 let mut body = minimal_bp_body("strict-off-resolvable-bp");
1525 body["agents"] = json!([ { "$agent_md": "agents/writer.md", "kind": "rust_fn" } ]);
1526
1527 let store = InMemoryBlueprintStore::new();
1528 let state = state_for_test(store, Some(dir.path().to_path_buf()), false);
1529
1530 let (status, resp) = seed_blueprint(
1531 State(state),
1532 Path("strict-off-resolvable-bp".to_string()),
1533 Json(body),
1534 )
1535 .await
1536 .expect("seed ok");
1537 assert_eq!(status, StatusCode::CREATED);
1538 assert_eq!(resp.0["seeded"], json!(true));
1539 }
1540
1541 #[tokio::test]
1544 async fn strict_embed_off_unresolvable_ref_returns_400_with_include_cascade_hint() {
1545 let dir = TempDir::new().unwrap();
1546 let mut body = minimal_bp_body("strict-off-unresolvable-bp");
1548 body["agents"] = json!([ { "$agent_md": "agents/missing.md", "kind": "rust_fn" } ]);
1549
1550 let store = InMemoryBlueprintStore::new();
1551 let state = state_for_test(store, Some(dir.path().to_path_buf()), false);
1552
1553 let err = seed_blueprint(
1554 State(state),
1555 Path("strict-off-unresolvable-bp".to_string()),
1556 Json(body),
1557 )
1558 .await
1559 .expect_err("expected 400");
1560 assert_eq!(err.0, StatusCode::BAD_REQUEST);
1561 let msg = err.1;
1562 assert!(
1564 msg.contains("cascade") && msg.contains(dir.path().to_str().unwrap()),
1565 "linker cascade error must name searched dirs: {msg}"
1566 );
1567 assert!(msg.contains("--include"), "hint names CLI flag: {msg}");
1570 assert!(
1571 msg.contains("MSE_BLUEPRINT_INCLUDES"),
1572 "hint names env var: {msg}"
1573 );
1574 assert!(
1575 msg.contains("blueprint_ref_includes"),
1576 "hint names config-file / in-bp key: {msg}"
1577 );
1578 assert!(
1579 msg.contains("mse bp build --strict-embed"),
1580 "hint suggests client-side pre-embed as escape hatch: {msg}"
1581 );
1582 }
1583
1584 #[tokio::test]
1588 async fn strict_embed_on_refuses_body_with_agent_md_ref() {
1589 let mut body = minimal_bp_body("strict-on-refs-present-bp");
1590 body["agents"] = json!([ { "$agent_md": "agents/anything.md", "kind": "rust_fn" } ]);
1591
1592 let store = InMemoryBlueprintStore::new();
1593 let state = state_for_test(store, None, true);
1596
1597 let err = seed_blueprint(
1598 State(state),
1599 Path("strict-on-refs-present-bp".to_string()),
1600 Json(body),
1601 )
1602 .await
1603 .expect_err("expected 400");
1604 assert_eq!(err.0, StatusCode::BAD_REQUEST);
1605 let msg = err.1;
1606 assert!(
1607 msg.starts_with("strict_embed:"),
1608 "verdict tag must namespace the error: {msg}"
1609 );
1610 assert!(
1611 msg.contains("$agent_md=agents/anything.md"),
1612 "message must name every unembedded ref: {msg}"
1613 );
1614 assert!(
1615 msg.contains("mse bp build --strict-embed"),
1616 "message must point at client-side pre-embed: {msg}"
1617 );
1618 }
1619
1620 #[tokio::test]
1623 async fn strict_embed_on_refuses_body_with_file_ref_deep_in_object() {
1624 let mut body = minimal_bp_body("strict-on-file-ref-bp");
1625 body["flow"] = json!({
1628 "kind": "step",
1629 "ref": "writer",
1630 "in": {"op": "lit", "value": { "$file": "prompts/deep.md" } },
1631 "out": {"op": "path", "at": "$.out"}
1632 });
1633
1634 let store = InMemoryBlueprintStore::new();
1635 let state = state_for_test(store, None, true);
1636
1637 let err = seed_blueprint(
1638 State(state),
1639 Path("strict-on-file-ref-bp".to_string()),
1640 Json(body),
1641 )
1642 .await
1643 .expect_err("expected 400");
1644 assert_eq!(err.0, StatusCode::BAD_REQUEST);
1645 assert!(
1646 err.1.contains("$file=prompts/deep.md"),
1647 "walker must find nested `$file` refs: {}",
1648 err.1
1649 );
1650 }
1651
1652 #[tokio::test]
1654 async fn strict_embed_on_accepts_fully_embedded_body() {
1655 let body = minimal_bp_body("strict-on-embedded-bp");
1656
1657 let store = InMemoryBlueprintStore::new();
1658 let state = state_for_test(store, None, true);
1659
1660 let (status, resp) = seed_blueprint(
1661 State(state),
1662 Path("strict-on-embedded-bp".to_string()),
1663 Json(body),
1664 )
1665 .await
1666 .expect("embedded body seeds ok");
1667 assert_eq!(status, StatusCode::CREATED);
1668 assert_eq!(resp.0["seeded"], json!(true));
1669 }
1670
1671 #[test]
1674 fn walker_finds_refs_in_arrays_and_nested_objects() {
1675 let embedded = json!({ "id": "x", "agents": [ { "name": "a", "kind": "rust_fn" } ] });
1676 assert!(collect_unembedded_refs(&embedded).is_none());
1677
1678 let with_refs = json!({
1679 "id": "x",
1680 "agents": [ { "$agent_md": "a.md" } ],
1681 "flow": { "in": { "value": { "$file": "p.md" } } }
1682 });
1683 let refs = collect_unembedded_refs(&with_refs).expect("some refs");
1684 assert!(refs.iter().any(|s| s == "$agent_md=a.md"));
1685 assert!(refs.iter().any(|s| s == "$file=p.md"));
1686 }
1687}