1use axum::{
9 extract::{Path, Query, State},
10 http::StatusCode,
11 routing::{get, post},
12 Json, Router,
13};
14use mlua_swarm::blueprint::loader::pre_read_default_agent_kind;
15use mlua_swarm::blueprint::store::{
16 blueprint_version, BlueprintId, BlueprintStore, CommitMetadata,
17};
18use mlua_swarm::blueprint::{default_global_agent_kind, AgentKind, Blueprint};
19use mlua_swarm::core::explain::{explain_agent_ctx, CtxTier};
20use mlua_swarm::core::step_naming::StepNaming;
21use mlua_swarm::operator::render::template_variables;
22use mlua_swarm::{binding_requests, LegacyWorkerBindingPolicy};
23use mlua_swarm_compile::{
24 env_blueprint_includes, expand_file_refs_with_config, pre_read_in_bp_includes, ResolveConfig,
25};
26use mlua_swarm_schema::{
27 resolve_bound_agents, resolve_bound_agents_strict, resolve_runner, BindRequest, BindingDigest,
28 Runner, RunnerResolutionSource,
29};
30use serde::{Deserialize, Serialize};
31use std::collections::BTreeMap;
32use std::path::PathBuf;
33use std::sync::Arc;
34
35#[derive(Clone)]
41pub struct BlueprintsState {
42 pub store: Arc<dyn BlueprintStore>,
44 pub ref_base: Option<PathBuf>,
46 pub ref_includes: Vec<PathBuf>,
51 pub cli_default_agent_kind: Option<AgentKind>,
53 pub strict_embed: bool,
70 pub legacy_worker_binding_policy: LegacyWorkerBindingPolicy,
77}
78
79pub fn build_blueprints_router(store: Arc<dyn BlueprintStore>) -> Router {
84 build_blueprints_router_with_refs(
85 store,
86 None,
87 Vec::new(),
88 None,
89 false,
90 LegacyWorkerBindingPolicy::Allow,
91 )
92}
93
94pub fn build_blueprints_router_with_refs(
108 store: Arc<dyn BlueprintStore>,
109 ref_base: Option<PathBuf>,
110 ref_includes: Vec<PathBuf>,
111 cli_default_agent_kind: Option<AgentKind>,
112 strict_embed: bool,
113 legacy_worker_binding_policy: LegacyWorkerBindingPolicy,
114) -> Router {
115 let state = BlueprintsState {
116 store,
117 ref_base,
118 ref_includes,
119 cli_default_agent_kind,
120 strict_embed,
121 legacy_worker_binding_policy,
122 };
123 Router::new()
124 .route("/v1/blueprints/:id/head", get(get_head))
125 .route("/v1/blueprints/:id/history", get(get_history))
126 .route(
127 "/v1/blueprints/:id/binding-requirements",
128 get(binding_requirements),
129 )
130 .route(
131 "/v1/blueprints/:id/agents/:agent/explain",
132 get(explain_agent),
133 )
134 .route(
135 "/v1/blueprints/:id/agents/explain",
136 get(explain_agents_batch),
137 )
138 .route("/v1/blueprints/:id/unarchive", post(unarchive_blueprint))
139 .route(
140 "/v1/blueprints/:id",
141 post(seed_blueprint).delete(archive_blueprint),
142 )
143 .with_state(state)
144}
145
146async fn archive_blueprint(
158 State(state): State<BlueprintsState>,
159 Path(id): Path<String>,
160) -> Result<StatusCode, (StatusCode, String)> {
161 let bp_id = BlueprintId::new(id.clone());
162 state.store.archive_id(&bp_id).await.map_err(|e| match e {
163 mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)
164 | mlua_swarm::blueprint::store::BlueprintStoreError::IdNotFound(_) => {
165 (StatusCode::NOT_FOUND, format!("archive_id: {e}"))
166 }
167 other => (
168 StatusCode::INTERNAL_SERVER_ERROR,
169 format!("archive_id: {other}"),
170 ),
171 })?;
172 Ok(StatusCode::NO_CONTENT)
173}
174
175async fn unarchive_blueprint(
178 State(state): State<BlueprintsState>,
179 Path(id): Path<String>,
180) -> Result<StatusCode, (StatusCode, String)> {
181 let bp_id = BlueprintId::new(id.clone());
182 state
183 .store
184 .unarchive_id(&bp_id)
185 .await
186 .map_err(|e| match e {
187 mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)
188 | mlua_swarm::blueprint::store::BlueprintStoreError::IdNotFound(_) => {
189 (StatusCode::NOT_FOUND, format!("unarchive_id: {e}"))
190 }
191 other => (
192 StatusCode::INTERNAL_SERVER_ERROR,
193 format!("unarchive_id: {other}"),
194 ),
195 })?;
196 Ok(StatusCode::NO_CONTENT)
197}
198
199fn parse_error_with_schema_hint(e: &serde_json::Error) -> String {
203 format!(
204 "blueprint parse: {e} \
205 (hint: fetch the Blueprint JSON Schema via the MCP adapter bp_schema tool)"
206 )
207}
208
209fn collect_unembedded_refs(val: &serde_json::Value) -> Option<Vec<String>> {
217 let mut acc: Vec<String> = Vec::new();
218 walk_refs(val, &mut acc);
219 if acc.is_empty() {
220 None
221 } else {
222 Some(acc)
223 }
224}
225
226fn walk_refs(val: &serde_json::Value, acc: &mut Vec<String>) {
227 match val {
228 serde_json::Value::Object(map) => {
229 for key in ["$file", "$agent_md"] {
230 if let Some(serde_json::Value::String(rel)) = map.get(key) {
231 acc.push(format!("{key}={rel}"));
232 }
233 }
234 for v in map.values() {
235 walk_refs(v, acc);
236 }
237 }
238 serde_json::Value::Array(arr) => {
239 for v in arr {
240 walk_refs(v, acc);
241 }
242 }
243 _ => {}
244 }
245}
246
247fn ref_expand_error_with_fix_hint(e: &mlua_swarm_compile::LoadError) -> String {
253 format!(
254 "ref expand: {e} \
255 (fix: extend the include cascade — add the containing directory via CLI \
256 `--include <DIR>` on `mse serve`, env `MSE_BLUEPRINT_INCLUDES`, config-file \
257 `blueprint_ref_includes`, or in-bp top-level `blueprint_ref_includes = {{...}}`; \
258 or pre-embed refs client-side via `mse bp build --strict-embed`)"
259 )
260}
261
262async fn seed_blueprint(
285 State(state): State<BlueprintsState>,
286 Path(id): Path<String>,
287 Json(raw_body): Json<serde_json::Value>,
288) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, String)> {
289 if state.strict_embed {
295 if let Some(refs) = collect_unembedded_refs(&raw_body) {
296 return Err((
297 StatusCode::BAD_REQUEST,
298 format!(
299 "strict_embed: raw body carries unembedded refs ({}); \
300 pre-embed client-side via `mse bp build --strict-embed` \
301 and POST the fully-resolved Blueprint JSON",
302 refs.join(", ")
303 ),
304 ));
305 }
306 }
307 let body: Blueprint = if let Some(base) = state.ref_base.as_ref() {
308 let default_kind = match pre_read_default_agent_kind(&raw_body) {
314 kind if raw_body.get("default_agent_kind").is_some() => kind,
316 _ => state
318 .cli_default_agent_kind
319 .clone()
320 .unwrap_or_else(default_global_agent_kind),
321 };
322 let cfg = ResolveConfig::new(base.clone())
329 .with_in_bp_includes(pre_read_in_bp_includes(&raw_body))
330 .with_env_includes(env_blueprint_includes())
331 .with_config_includes(state.ref_includes.clone());
332 let expanded = expand_file_refs_with_config(raw_body, &cfg, default_kind)
333 .map_err(|e| (StatusCode::BAD_REQUEST, ref_expand_error_with_fix_hint(&e)))?;
334 serde_json::from_value(expanded)
335 .map_err(|e| (StatusCode::BAD_REQUEST, parse_error_with_schema_hint(&e)))?
336 } else {
337 serde_json::from_value(raw_body)
338 .map_err(|e| (StatusCode::BAD_REQUEST, parse_error_with_schema_hint(&e)))?
339 };
340 let store = state.store;
341 if id != body.id.as_str() {
342 return Err((
343 StatusCode::BAD_REQUEST,
344 format!("path id={id} != body.id={}", body.id),
345 ));
346 }
347 let bp_id = BlueprintId::new(id.clone());
348 let v = blueprint_version(&body).map_err(|e| {
349 (
350 StatusCode::INTERNAL_SERVER_ERROR,
351 format!("bp version: {e}"),
352 )
353 })?;
354 let prev_head = match store.read_head(&bp_id).await {
355 Ok(traced) => Some(traced),
356 Err(mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)) => None,
357 Err(mlua_swarm::blueprint::store::BlueprintStoreError::Archived(_)) => {
358 return Err((
359 StatusCode::CONFLICT,
360 format!("blueprint {id} is archived; POST /v1/blueprints/{id}/unarchive first"),
361 ));
362 }
363 Err(e) => {
364 return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("read_head: {e}")));
365 }
366 };
367 if let Some(traced) = &prev_head {
368 if traced.trace.version == v {
369 return Ok((
370 StatusCode::OK,
371 Json(serde_json::json!({"id": id, "version": format!("{:?}", v), "seeded": false})),
372 ));
373 }
374 }
375 let parents: Vec<_> = prev_head
376 .as_ref()
377 .map(|t| vec![t.trace.version])
378 .unwrap_or_default();
379 let now_ms = std::time::SystemTime::now()
380 .duration_since(std::time::UNIX_EPOCH)
381 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
382 .as_millis() as i64;
383 let meta = CommitMetadata::seed(bp_id.clone(), v, now_ms);
384 store
385 .write_new(&bp_id, &body, &parents, meta)
386 .await
387 .map_err(|e| match &e {
388 mlua_swarm::blueprint::store::BlueprintStoreError::LockBusy => (
389 StatusCode::TOO_MANY_REQUESTS,
390 format!("blueprint {id} lock busy; retry"),
391 ),
392 mlua_swarm::blueprint::store::BlueprintStoreError::Archived(_) => (
393 StatusCode::CONFLICT,
394 format!("blueprint {id} is archived; POST /v1/blueprints/{id}/unarchive first"),
395 ),
396 _ => (StatusCode::INTERNAL_SERVER_ERROR, format!("write_new: {e}")),
397 })?;
398 Ok((
399 StatusCode::CREATED,
400 Json(serde_json::json!({"id": id, "version": format!("{:?}", v), "seeded": true})),
401 ))
402}
403
404#[derive(Debug, Serialize)]
405struct HeadResponse {
406 id: String,
407 version: String,
408 blueprint: Blueprint,
409}
410
411async fn get_head(
412 State(state): State<BlueprintsState>,
413 Path(id): Path<String>,
414) -> Result<Json<HeadResponse>, (StatusCode, String)> {
415 let store = state.store;
416 let bp_id = BlueprintId::new(id.clone());
417 let traced = store
418 .read_head(&bp_id)
419 .await
420 .map_err(|e| (StatusCode::NOT_FOUND, format!("read_head: {e}")))?;
421 Ok(Json(HeadResponse {
422 id,
423 version: format!("{:?}", traced.trace.version),
424 blueprint: traced.value,
425 }))
426}
427
428#[derive(Debug, Deserialize)]
429struct HistoryQuery {
430 #[serde(default = "default_limit")]
431 limit: usize,
432}
433
434fn default_limit() -> usize {
435 20
436}
437
438#[derive(Debug, Serialize)]
439struct HistoryEntry {
440 hash: String,
442 version_label: Option<String>,
444 rationale: String,
446}
447
448#[derive(Debug, Serialize)]
449struct HistoryResponse {
450 count: usize,
451 entries: Vec<HistoryEntry>,
452}
453
454async fn get_history(
455 State(state): State<BlueprintsState>,
456 Path(id): Path<String>,
457 Query(q): Query<HistoryQuery>,
458) -> Result<Json<HistoryResponse>, (StatusCode, String)> {
459 let store = state.store;
460 let bp_id = BlueprintId::new(id);
461 let versions = store
462 .history(&bp_id, q.limit)
463 .await
464 .map_err(|e| (StatusCode::NOT_FOUND, format!("history: {e}")))?;
465 let mut entries = Vec::with_capacity(versions.len());
466 for v in versions {
467 let traced = store.read_version(&bp_id, v).await.map_err(|e| {
468 (
469 StatusCode::INTERNAL_SERVER_ERROR,
470 format!("read_version: {e}"),
471 )
472 })?;
473 let rationale = store
474 .read_commit_rationale(&bp_id, v)
475 .await
476 .unwrap_or(None)
477 .unwrap_or_default();
478 entries.push(HistoryEntry {
479 hash: format!("{:?}", v),
480 version_label: traced.value.metadata.version_label.clone(),
481 rationale,
482 });
483 }
484 let count = entries.len();
485 Ok(Json(HistoryResponse { count, entries }))
486}
487
488#[derive(Debug, Serialize, schemars::JsonSchema)]
501pub struct BindingRequirementsResponse {
502 pub blueprint_id: String,
504 pub strict_binding: bool,
507 pub requirements: Vec<BindRequest>,
510}
511
512async fn binding_requirements(
529 State(state): State<BlueprintsState>,
530 Path(id): Path<String>,
531) -> Result<Json<BindingRequirementsResponse>, (StatusCode, String)> {
532 let store = state.store;
533 let bp_id = BlueprintId::new(id.clone());
534 let traced = store
535 .read_head(&bp_id)
536 .await
537 .map_err(|e| (StatusCode::NOT_FOUND, format!("read_head: {e}")))?;
538 let bp = traced.value;
539
540 let bound = match state.legacy_worker_binding_policy {
544 LegacyWorkerBindingPolicy::Allow => resolve_bound_agents(&bp),
545 LegacyWorkerBindingPolicy::Reject => resolve_bound_agents_strict(&bp),
546 }
547 .map_err(|e| {
548 (
549 StatusCode::UNPROCESSABLE_ENTITY,
550 format!("resolve bound agents: {e}"),
551 )
552 })?;
553
554 Ok(Json(BindingRequirementsResponse {
555 blueprint_id: id,
556 strict_binding: bp.strategy.strict_binding,
557 requirements: binding_requests(&bound),
558 }))
559}
560
561#[derive(Debug, Serialize)]
568struct ExplainBlueprintRef {
569 id: String,
571 version: String,
574}
575
576#[derive(Debug, Serialize)]
579struct ExplainAgentRef {
580 name: String,
582 kind: AgentKind,
584}
585
586#[derive(Debug, Serialize)]
591struct ExplainWorkerBinding {
592 variant: String,
594}
595
596#[derive(Debug, Serialize)]
598struct ExplainDeclaredTools {
599 tools: Vec<String>,
601 informational: bool,
603 note: String,
605}
606
607#[derive(Debug, Serialize)]
610struct ExplainSystemPrompt {
611 bytes: usize,
613 lines: usize,
615 template_variables: Vec<String>,
619 template_syntax_error: Option<String>,
622 note: String,
624}
625
626#[derive(Debug, Serialize)]
628struct ExplainCtxKeyEntry {
629 value: serde_json::Value,
631 winning_tier: String,
634}
635
636#[derive(Debug, Serialize)]
641struct ExplainEffectiveCtx {
642 keys: BTreeMap<String, ExplainCtxKeyEntry>,
644 note: String,
646}
647
648#[derive(Debug, Serialize)]
650struct ExplainOutput {
651 projection_name: String,
655 naming_warnings: Vec<String>,
660 parts_note: String,
663}
664
665#[derive(Debug, Serialize)]
673struct ExplainRunner {
674 resolved: Option<Runner>,
680 error: Option<String>,
684 warning: Option<String>,
690 source: Option<RunnerResolutionSource>,
692 binding_digest: Option<BindingDigest>,
694}
695
696fn runner_kind_mismatch_warning(
701 runner: &Runner,
702 kind: &AgentKind,
703 agent_name: &str,
704) -> Option<String> {
705 match (runner, kind) {
706 (Runner::AgentBlockInProcess { .. }, AgentKind::AgentBlock) => None,
707 (Runner::AgentBlockInProcess { .. }, other) => Some(format!(
708 "agent '{agent_name}' resolves to Runner::AgentBlockInProcess but AgentDef.kind = \
709 {other:?} (expected AgentBlock)"
710 )),
711 (Runner::WsOperator { .. }, AgentKind::AgentBlock) => Some(format!(
712 "agent '{agent_name}' resolves to Runner::WsOperator but AgentDef.kind = AgentBlock"
713 )),
714 (Runner::WsOperator { .. }, _) => None,
715 (Runner::WsClaudeCode { .. }, AgentKind::AgentBlock) => Some(format!(
716 "agent '{agent_name}' resolves to Runner::WsClaudeCode but AgentDef.kind = AgentBlock"
717 )),
718 (Runner::WsClaudeCode { .. }, _) => None,
719 (Runner::Subprocess { .. }, AgentKind::Subprocess) => None,
721 (Runner::Subprocess { .. }, other) => Some(format!(
722 "agent '{agent_name}' resolves to Runner::Subprocess but AgentDef.kind = {other:?} \
723 (expected Subprocess)"
724 )),
725 }
726}
727
728#[derive(Debug, Serialize)]
730struct ExplainAgentResponse {
731 blueprint: ExplainBlueprintRef,
733 agent: ExplainAgentRef,
735 worker_binding: Option<ExplainWorkerBinding>,
737 binding_note: Option<String>,
739 runner: ExplainRunner,
741 declared_tools: ExplainDeclaredTools,
743 system_prompt: Option<ExplainSystemPrompt>,
746 effective_ctx: ExplainEffectiveCtx,
748 output: ExplainOutput,
750}
751
752fn ctx_tier_label(tier: CtxTier) -> &'static str {
755 match tier {
756 CtxTier::AgentInline => "agent_inline",
757 CtxTier::MetaRef => "meta_ref",
758 CtxTier::BpGlobal => "bp_global",
759 }
760}
761
762fn explain_system_prompt(template: &str) -> ExplainSystemPrompt {
765 let (variables, template_syntax_error): (Vec<String>, Option<String>) =
766 match template_variables(template) {
767 Ok(vars) => (vars.into_iter().collect(), None),
768 Err(e) => (Vec::new(), Some(e.to_string())),
769 };
770 ExplainSystemPrompt {
771 bytes: template.len(),
772 lines: template.lines().count(),
773 template_variables: variables,
774 template_syntax_error,
775 note: "when the step directive is not a JSON object, only `value` is bound at render \
776 time"
777 .to_string(),
778 }
779}
780
781async fn explain_agent(
796 State(state): State<BlueprintsState>,
797 Path((id, agent)): Path<(String, String)>,
798) -> Result<Json<ExplainAgentResponse>, (StatusCode, String)> {
799 let store = state.store;
800 let bp_id = BlueprintId::new(id.clone());
801 let traced = store
802 .read_head(&bp_id)
803 .await
804 .map_err(|e| (StatusCode::NOT_FOUND, format!("read_head: {e}")))?;
805 let bp = traced.value;
806 let version = format!("{:?}", traced.trace.version);
807
808 let Some(agent_def) = bp.agents.iter().find(|ad| ad.name == agent) else {
809 let available: Vec<&str> = bp.agents.iter().map(|ad| ad.name.as_str()).collect();
810 return Err((
811 StatusCode::NOT_FOUND,
812 serde_json::json!({
813 "error": "agent not found in blueprint",
814 "agent": agent,
815 "available": available,
816 })
817 .to_string(),
818 ));
819 };
820
821 let profile = agent_def.profile.as_ref();
822
823 let (worker_binding, binding_note) = match profile.and_then(|p| p.worker_binding.as_ref()) {
824 Some(variant) => (
825 Some(ExplainWorkerBinding {
826 variant: variant.clone(),
827 }),
828 None,
829 ),
830 None => (
831 None,
832 Some(
833 "no worker_binding declared; WS operator dispatch will fail at compile \
834 (InvalidSpec)"
835 .to_string(),
836 ),
837 ),
838 };
839
840 let declared_tools = ExplainDeclaredTools {
841 tools: profile.map(|p| p.tools.clone()).unwrap_or_default(),
842 informational: true,
843 note: "declared tools do not grant anything; the effective tool surface is the worker \
844 wrapper's frontmatter (see operator.rs WorkerBinding doc)"
845 .to_string(),
846 };
847
848 let bound = resolve_bound_agents(&bp)
853 .ok()
854 .and_then(|all| all.into_iter().find(|b| b.agent.name == agent_def.name));
855 let runner = match resolve_runner(&bp, agent_def) {
856 Ok(resolved) => {
857 let warning = resolved
858 .as_ref()
859 .and_then(|r| runner_kind_mismatch_warning(r, &agent_def.kind, &agent_def.name));
860 ExplainRunner {
861 resolved,
862 error: None,
863 warning,
864 source: bound.as_ref().map(|b| b.runner_source),
865 binding_digest: bound.as_ref().map(|b| b.binding_digest.clone()),
866 }
867 }
868 Err(e) => ExplainRunner {
869 resolved: None,
870 error: Some(e.to_string()),
871 warning: None,
872 source: None,
873 binding_digest: None,
874 },
875 };
876
877 let system_prompt = profile
878 .filter(|p| !p.system_prompt.is_empty())
879 .map(|p| explain_system_prompt(&p.system_prompt));
880
881 let ctx_keys = explain_agent_ctx(&bp, &agent).unwrap_or_default();
882 let effective_ctx = ExplainEffectiveCtx {
883 keys: ctx_keys
884 .into_iter()
885 .map(|(k, resolution)| {
886 (
887 k,
888 ExplainCtxKeyEntry {
889 value: resolution.value,
890 winning_tier: ctx_tier_label(resolution.winning_tier).to_string(),
891 },
892 )
893 })
894 .collect(),
895 note: "static tiers only; Run/Task/Step runtime tiers always win over these \
896 (only-if-absent insertion order)"
897 .to_string(),
898 };
899
900 let (projection_name, naming_warnings) = match StepNaming::from_blueprint(&bp) {
901 Ok((naming, _soft_warnings)) => match naming.canonical_of_producer(&agent) {
902 Some(canonical) => (canonical.to_string(), Vec::new()),
903 None => (
904 agent.clone(),
905 vec![format!(
906 "agent '{agent}' does not appear in the blueprint's flow; using the agent \
907 name as a fallback projection name"
908 )],
909 ),
910 },
911 Err(e) => (
912 agent.clone(),
913 vec![format!("StepNaming::from_blueprint failed: {e}")],
914 ),
915 };
916
917 let output = ExplainOutput {
918 projection_name,
919 naming_warnings,
920 parts_note: "if the worker stages named artifact parts, the step OUTPUT changes shape \
921 to {\"out\", \"parts\"}; reference via $.<step>.out"
922 .to_string(),
923 };
924
925 Ok(Json(ExplainAgentResponse {
926 blueprint: ExplainBlueprintRef { id, version },
927 agent: ExplainAgentRef {
928 name: agent_def.name.clone(),
929 kind: agent_def.kind.clone(),
930 },
931 worker_binding,
932 binding_note,
933 runner,
934 declared_tools,
935 system_prompt,
936 effective_ctx,
937 output,
938 }))
939}
940
941#[derive(Debug, Serialize)]
949struct WorkerBindingSummary {
950 variant: String,
952}
953
954#[derive(Debug, Serialize)]
961struct AgentSummary {
962 name: String,
964 kind: String,
967 worker_binding: Option<WorkerBindingSummary>,
971 declared_tools_count: usize,
973 system_prompt_bytes: usize,
976 effective_ctx_key_count: usize,
979 projection_name: String,
985}
986
987#[derive(Debug, Serialize)]
989struct BatchExplainAgentsResponse {
990 blueprint: ExplainBlueprintRef,
992 agents: Vec<AgentSummary>,
994}
995
996async fn explain_agents_batch(
1009 State(state): State<BlueprintsState>,
1010 Path(id): Path<String>,
1011) -> Result<Json<BatchExplainAgentsResponse>, (StatusCode, String)> {
1012 let store = state.store;
1013 let bp_id = BlueprintId::new(id.clone());
1014 let traced = store
1015 .read_head(&bp_id)
1016 .await
1017 .map_err(|e| (StatusCode::NOT_FOUND, format!("read_head: {e}")))?;
1018 let bp = traced.value;
1019 let version = format!("{:?}", traced.trace.version);
1020
1021 let naming = StepNaming::from_blueprint(&bp)
1026 .ok()
1027 .map(|(naming, _)| naming);
1028
1029 let agents = bp
1030 .agents
1031 .iter()
1032 .map(|agent_def| {
1033 let profile = agent_def.profile.as_ref();
1034 let worker_binding = profile
1035 .and_then(|p| p.worker_binding.as_ref())
1036 .map(|variant| WorkerBindingSummary {
1037 variant: variant.clone(),
1038 });
1039 let declared_tools_count = profile.map(|p| p.tools.len()).unwrap_or(0);
1040 let system_prompt_bytes = profile.map(|p| p.system_prompt.len()).unwrap_or(0);
1041 let effective_ctx_key_count = explain_agent_ctx(&bp, &agent_def.name)
1042 .map(|keys| keys.len())
1043 .unwrap_or(0);
1044 let projection_name = naming
1045 .as_ref()
1046 .and_then(|naming| naming.canonical_of_producer(&agent_def.name))
1047 .map(|canonical| canonical.to_string())
1048 .unwrap_or_else(|| agent_def.name.clone());
1049 AgentSummary {
1050 name: agent_def.name.clone(),
1051 kind: format!("{:?}", agent_def.kind),
1052 worker_binding,
1053 declared_tools_count,
1054 system_prompt_bytes,
1055 effective_ctx_key_count,
1056 projection_name,
1057 }
1058 })
1059 .collect();
1060
1061 Ok(Json(BatchExplainAgentsResponse {
1062 blueprint: ExplainBlueprintRef { id, version },
1063 agents,
1064 }))
1065}
1066
1067#[cfg(test)]
1068mod explain_agent_tests {
1069 use super::*;
1070 use mlua_swarm::blueprint::store::InMemoryBlueprintStore;
1071 use mlua_swarm::blueprint::{
1072 current_schema_version, AgentDef, AgentMeta, AgentProfile, BlueprintMetadata,
1073 CompilerHints, CompilerStrategy,
1074 };
1075 use serde_json::json;
1076
1077 fn agent_def(name: &str, profile: Option<AgentProfile>, meta: Option<AgentMeta>) -> AgentDef {
1078 AgentDef {
1079 name: name.to_string(),
1080 kind: AgentKind::RustFn,
1081 spec: json!({ "fn_id": name }),
1082 profile,
1083 meta,
1084 runner: None,
1085 runner_ref: None,
1086 verdict: None,
1087 }
1088 }
1089
1090 fn single_step_bp(
1094 bp_id: &str,
1095 agent_name: &str,
1096 profile: Option<AgentProfile>,
1097 meta: Option<AgentMeta>,
1098 default_agent_ctx: Option<serde_json::Value>,
1099 ) -> Blueprint {
1100 Blueprint {
1101 schema_version: current_schema_version(),
1102 id: bp_id.into(),
1103 flow: serde_json::from_value(json!({
1104 "kind": "step",
1105 "ref": agent_name,
1106 "in": {"op": "path", "at": "$.input"},
1107 "out": {"op": "path", "at": "$.out"},
1108 }))
1109 .expect("flow parse"),
1110 agents: vec![agent_def(agent_name, profile, meta)],
1111 operators: vec![],
1112 metas: vec![],
1113 hints: CompilerHints::default(),
1114 strategy: CompilerStrategy::default(),
1115 metadata: BlueprintMetadata::default(),
1116 spawner_hints: Default::default(),
1117 default_agent_kind: AgentKind::Operator,
1118 default_operator_kind: None,
1119 default_init_ctx: None,
1120 default_agent_ctx,
1121 default_context_policy: None,
1122 projection_placement: None,
1123 audits: vec![],
1124 degradation_policy: None,
1125 runners: vec![],
1126 default_runner: None,
1127 subprocesses: vec![],
1128 check_policy: None,
1129 blueprint_ref_includes: Vec::new(),
1130 }
1131 }
1132
1133 async fn seed(store: &InMemoryBlueprintStore, bp: &Blueprint) {
1134 let bp_id = BlueprintId::new(bp.id.as_str());
1135 let v = blueprint_version(bp).expect("version");
1136 store
1137 .write_new(&bp_id, bp, &[], CommitMetadata::seed(bp_id.clone(), v, 0))
1138 .await
1139 .expect("write_new");
1140 }
1141
1142 fn state_with(store: InMemoryBlueprintStore) -> BlueprintsState {
1143 BlueprintsState {
1144 store: Arc::new(store),
1145 ref_base: None,
1146 ref_includes: Vec::new(),
1147 cli_default_agent_kind: None,
1148 strict_embed: false,
1149 legacy_worker_binding_policy: LegacyWorkerBindingPolicy::Allow,
1150 }
1151 }
1152
1153 #[tokio::test]
1154 async fn full_case_reports_binding_ctx_override_and_system_prompt() {
1155 let profile = AgentProfile {
1156 system_prompt: "Hello {{ name }}, mode={{ mode }}".to_string(),
1157 tools: vec!["Read".to_string(), "Grep".to_string()],
1158 worker_binding: Some("knowledge-worker".to_string()),
1159 ..Default::default()
1160 };
1161 let meta = AgentMeta {
1162 ctx: Some(json!({ "work_dir": "/inline" })),
1163 ..Default::default()
1164 };
1165 let bp = single_step_bp(
1166 "explain-full-bp",
1167 "researcher",
1168 Some(profile),
1169 Some(meta),
1170 Some(json!({ "work_dir": "/bp-global", "extra": "kept" })),
1171 );
1172 let store = InMemoryBlueprintStore::new();
1173 seed(&store, &bp).await;
1174
1175 let resp = explain_agent(
1176 State(state_with(store)),
1177 Path(("explain-full-bp".to_string(), "researcher".to_string())),
1178 )
1179 .await
1180 .expect("explain_agent")
1181 .0;
1182
1183 assert_eq!(resp.blueprint.id, "explain-full-bp");
1184 assert!(!resp.blueprint.version.is_empty());
1185 assert_eq!(resp.agent.name, "researcher");
1186 assert_eq!(resp.agent.kind, AgentKind::RustFn);
1187
1188 let binding = resp.worker_binding.expect("worker_binding present");
1189 assert_eq!(binding.variant, "knowledge-worker");
1190 assert!(resp.binding_note.is_none());
1191
1192 assert_eq!(
1193 resp.declared_tools.tools,
1194 vec!["Read".to_string(), "Grep".to_string()]
1195 );
1196 assert!(resp.declared_tools.informational);
1197
1198 let sp = resp.system_prompt.expect("system_prompt present");
1199 assert_eq!(sp.bytes, "Hello {{ name }}, mode={{ mode }}".len());
1200 assert_eq!(sp.lines, 1);
1201 assert_eq!(
1202 sp.template_variables,
1203 vec!["mode".to_string(), "name".to_string()]
1204 );
1205 assert!(sp.template_syntax_error.is_none());
1206
1207 assert_eq!(resp.effective_ctx.keys["work_dir"].value, json!("/inline"));
1208 assert_eq!(
1209 resp.effective_ctx.keys["work_dir"].winning_tier,
1210 "agent_inline"
1211 );
1212 assert_eq!(resp.effective_ctx.keys["extra"].value, json!("kept"));
1213 assert_eq!(resp.effective_ctx.keys["extra"].winning_tier, "bp_global");
1214
1215 assert_eq!(resp.output.projection_name, "researcher");
1216 assert!(resp.output.naming_warnings.is_empty());
1217 }
1218
1219 #[tokio::test]
1220 async fn agent_without_worker_binding_reports_binding_note() {
1221 let profile = AgentProfile {
1222 tools: vec!["Read".to_string()],
1223 ..Default::default()
1224 };
1225 let bp = single_step_bp("explain-no-binding-bp", "scout", Some(profile), None, None);
1226 let store = InMemoryBlueprintStore::new();
1227 seed(&store, &bp).await;
1228
1229 let resp = explain_agent(
1230 State(state_with(store)),
1231 Path(("explain-no-binding-bp".to_string(), "scout".to_string())),
1232 )
1233 .await
1234 .expect("explain_agent")
1235 .0;
1236
1237 assert!(resp.worker_binding.is_none());
1238 let note = resp.binding_note.expect("binding_note present");
1239 assert!(note.contains("no worker_binding declared"));
1240 assert!(resp.system_prompt.is_none());
1241 }
1242
1243 #[tokio::test]
1244 async fn unknown_agent_name_returns_404_with_available_list() {
1245 let bp = single_step_bp("explain-404-agent-bp", "foo", None, None, None);
1246 let store = InMemoryBlueprintStore::new();
1247 seed(&store, &bp).await;
1248
1249 let err = explain_agent(
1250 State(state_with(store)),
1251 Path((
1252 "explain-404-agent-bp".to_string(),
1253 "no-such-agent".to_string(),
1254 )),
1255 )
1256 .await
1257 .expect_err("expected 404");
1258
1259 assert_eq!(err.0, StatusCode::NOT_FOUND);
1260 let body: serde_json::Value = serde_json::from_str(&err.1).expect("json body");
1261 assert_eq!(body["error"], "agent not found in blueprint");
1262 assert_eq!(body["agent"], "no-such-agent");
1263 assert_eq!(body["available"], json!(["foo"]));
1264 }
1265
1266 #[tokio::test]
1267 async fn unknown_blueprint_id_returns_404_same_as_get_head() {
1268 let store = InMemoryBlueprintStore::new();
1269
1270 let err = explain_agent(
1271 State(state_with(store)),
1272 Path(("no-such-bp".to_string(), "any-agent".to_string())),
1273 )
1274 .await
1275 .expect_err("expected 404");
1276
1277 assert_eq!(err.0, StatusCode::NOT_FOUND);
1278 }
1279
1280 #[tokio::test]
1281 async fn template_syntax_error_is_reported_without_500() {
1282 let profile = AgentProfile {
1283 system_prompt: "hello {{ unclosed".to_string(),
1284 ..Default::default()
1285 };
1286 let bp = single_step_bp(
1287 "explain-syntax-error-bp",
1288 "scout",
1289 Some(profile),
1290 None,
1291 None,
1292 );
1293 let store = InMemoryBlueprintStore::new();
1294 seed(&store, &bp).await;
1295
1296 let resp = explain_agent(
1297 State(state_with(store)),
1298 Path(("explain-syntax-error-bp".to_string(), "scout".to_string())),
1299 )
1300 .await
1301 .expect("explain_agent")
1302 .0;
1303
1304 let sp = resp.system_prompt.expect("system_prompt present");
1305 assert!(sp.template_variables.is_empty());
1306 assert!(sp.template_syntax_error.is_some());
1307 }
1308
1309 #[tokio::test]
1312 async fn runner_resolves_from_legacy_worker_binding_when_nothing_else_declared() {
1313 let profile = AgentProfile {
1314 worker_binding: Some("knowledge-worker".to_string()),
1315 tools: vec!["Read".to_string()],
1316 ..Default::default()
1317 };
1318 let bp = single_step_bp(
1319 "explain-runner-legacy-bp",
1320 "scout",
1321 Some(profile),
1322 None,
1323 None,
1324 );
1325 let store = InMemoryBlueprintStore::new();
1326 seed(&store, &bp).await;
1327
1328 let resp = explain_agent(
1329 State(state_with(store)),
1330 Path(("explain-runner-legacy-bp".to_string(), "scout".to_string())),
1331 )
1332 .await
1333 .expect("explain_agent")
1334 .0;
1335
1336 assert_eq!(
1337 resp.runner.resolved,
1338 Some(mlua_swarm_schema::Runner::WsClaudeCode {
1339 variant: "knowledge-worker".to_string(),
1340 tools: vec!["Read".to_string()],
1341 })
1342 );
1343 assert!(resp.runner.error.is_none());
1344 assert!(resp.runner.warning.is_none());
1345 }
1346
1347 #[tokio::test]
1348 async fn runner_reports_unresolved_runner_ref_as_error_level_finding() {
1349 let mut bp = single_step_bp("explain-runner-unresolved-bp", "scout", None, None, None);
1350 bp.agents[0].runner_ref = Some("no-such-entry".to_string());
1351 let store = InMemoryBlueprintStore::new();
1352 seed(&store, &bp).await;
1353
1354 let resp = explain_agent(
1355 State(state_with(store)),
1356 Path((
1357 "explain-runner-unresolved-bp".to_string(),
1358 "scout".to_string(),
1359 )),
1360 )
1361 .await
1362 .expect("explain_agent")
1363 .0;
1364
1365 assert!(resp.runner.resolved.is_none());
1366 let error = resp.runner.error.expect("error-level finding present");
1367 assert!(
1368 error.contains("no-such-entry"),
1369 "error must name the unresolved runner_ref: {error}"
1370 );
1371 assert!(resp.runner.warning.is_none());
1372 }
1373
1374 #[tokio::test]
1375 async fn runner_reports_backend_kind_mismatch_as_warn_level_finding() {
1376 let mut bp = single_step_bp("explain-runner-mismatch-bp", "scout", None, None, None);
1380 bp.runners = vec![mlua_swarm_schema::RunnerDef {
1381 name: "in-process".to_string(),
1382 runner: mlua_swarm_schema::Runner::AgentBlockInProcess {
1383 tools: vec!["Bash".to_string()],
1384 },
1385 }];
1386 bp.agents[0].runner_ref = Some("in-process".to_string());
1387 let store = InMemoryBlueprintStore::new();
1388 seed(&store, &bp).await;
1389
1390 let resp = explain_agent(
1391 State(state_with(store)),
1392 Path((
1393 "explain-runner-mismatch-bp".to_string(),
1394 "scout".to_string(),
1395 )),
1396 )
1397 .await
1398 .expect("explain_agent")
1399 .0;
1400
1401 assert!(resp.runner.resolved.is_some());
1402 assert!(resp.runner.error.is_none());
1403 let warning = resp.runner.warning.expect("warn-level finding present");
1404 assert!(
1405 warning.contains("AgentBlockInProcess") && warning.contains("RustFn"),
1406 "warning must name both the resolved backend and the mismatched kind: {warning}"
1407 );
1408 }
1409
1410 fn batch_bp() -> Blueprint {
1416 let bound_profile = AgentProfile {
1417 system_prompt: "hello world".to_string(),
1418 tools: vec!["Read".to_string(), "Grep".to_string()],
1419 worker_binding: Some("knowledge-worker".to_string()),
1420 ..Default::default()
1421 };
1422 let bound_meta = AgentMeta {
1423 ctx: Some(json!({ "work_dir": "/inline" })),
1424 ..Default::default()
1425 };
1426 Blueprint {
1427 schema_version: current_schema_version(),
1428 id: "explain-batch-bp".into(),
1429 flow: serde_json::from_value(json!({
1430 "kind": "step",
1431 "ref": "bound_agent",
1432 "in": {"op": "path", "at": "$.input"},
1433 "out": {"op": "path", "at": "$.out"},
1434 }))
1435 .expect("flow parse"),
1436 agents: vec![
1437 agent_def("bound_agent", Some(bound_profile), Some(bound_meta)),
1438 agent_def("unbound_agent", None, None),
1439 agent_def("orphan_agent", None, None),
1440 ],
1441 operators: vec![],
1442 metas: vec![],
1443 hints: CompilerHints::default(),
1444 strategy: CompilerStrategy::default(),
1445 metadata: BlueprintMetadata::default(),
1446 spawner_hints: Default::default(),
1447 default_agent_kind: AgentKind::Operator,
1448 default_operator_kind: None,
1449 default_init_ctx: None,
1450 default_agent_ctx: Some(json!({ "work_dir": "/bp-global", "extra": "kept" })),
1451 default_context_policy: None,
1452 projection_placement: None,
1453 audits: vec![],
1454 degradation_policy: None,
1455 runners: vec![],
1456 default_runner: None,
1457 subprocesses: vec![],
1458 check_policy: None,
1459 blueprint_ref_includes: Vec::new(),
1460 }
1461 }
1462
1463 #[tokio::test]
1464 async fn explain_agents_batch_reports_a_summary_row_per_agent() {
1465 let bp = batch_bp();
1466 let store = InMemoryBlueprintStore::new();
1467 seed(&store, &bp).await;
1468
1469 let resp = explain_agents_batch(
1470 State(state_with(store)),
1471 Path("explain-batch-bp".to_string()),
1472 )
1473 .await
1474 .expect("explain_agents_batch")
1475 .0;
1476
1477 assert_eq!(resp.blueprint.id, "explain-batch-bp");
1478 assert!(!resp.blueprint.version.is_empty());
1479 assert_eq!(resp.agents.len(), 3);
1480
1481 let bound = resp
1482 .agents
1483 .iter()
1484 .find(|a| a.name == "bound_agent")
1485 .expect("bound_agent row");
1486 assert_eq!(bound.kind, format!("{:?}", AgentKind::RustFn));
1487 let binding = bound
1488 .worker_binding
1489 .as_ref()
1490 .expect("worker_binding present");
1491 assert_eq!(binding.variant, "knowledge-worker");
1492 assert_eq!(bound.declared_tools_count, 2);
1493 assert_eq!(bound.system_prompt_bytes, "hello world".len());
1494 assert_eq!(bound.effective_ctx_key_count, 2);
1496 assert_eq!(bound.projection_name, "bound_agent");
1498
1499 let unbound = resp
1500 .agents
1501 .iter()
1502 .find(|a| a.name == "unbound_agent")
1503 .expect("unbound_agent row");
1504 assert!(unbound.worker_binding.is_none());
1505 assert_eq!(unbound.declared_tools_count, 0);
1506 assert_eq!(unbound.system_prompt_bytes, 0);
1507 assert_eq!(unbound.effective_ctx_key_count, 2);
1509 assert_eq!(unbound.projection_name, "unbound_agent");
1511
1512 let orphan = resp
1513 .agents
1514 .iter()
1515 .find(|a| a.name == "orphan_agent")
1516 .expect("orphan_agent row");
1517 assert_eq!(orphan.projection_name, "orphan_agent");
1518 }
1519
1520 #[tokio::test]
1521 async fn explain_agents_batch_zero_agents_returns_empty_list_not_404() {
1522 let bp = Blueprint {
1523 schema_version: current_schema_version(),
1524 id: "explain-batch-empty-bp".into(),
1525 flow: serde_json::from_value(json!({
1526 "kind": "step",
1527 "ref": "unused",
1528 "in": {"op": "path", "at": "$.input"},
1529 "out": {"op": "path", "at": "$.out"},
1530 }))
1531 .expect("flow parse"),
1532 agents: vec![],
1533 operators: vec![],
1534 metas: vec![],
1535 hints: CompilerHints::default(),
1536 strategy: CompilerStrategy::default(),
1537 metadata: BlueprintMetadata::default(),
1538 spawner_hints: Default::default(),
1539 default_agent_kind: AgentKind::Operator,
1540 default_operator_kind: None,
1541 default_init_ctx: None,
1542 default_agent_ctx: None,
1543 default_context_policy: None,
1544 projection_placement: None,
1545 audits: vec![],
1546 degradation_policy: None,
1547 runners: vec![],
1548 default_runner: None,
1549 subprocesses: vec![],
1550 check_policy: None,
1551 blueprint_ref_includes: Vec::new(),
1552 };
1553 let store = InMemoryBlueprintStore::new();
1554 seed(&store, &bp).await;
1555
1556 let resp = explain_agents_batch(
1557 State(state_with(store)),
1558 Path("explain-batch-empty-bp".to_string()),
1559 )
1560 .await
1561 .expect("explain_agents_batch")
1562 .0;
1563
1564 assert!(resp.agents.is_empty());
1565 }
1566
1567 #[tokio::test]
1568 async fn explain_agents_batch_unknown_blueprint_id_returns_404_same_as_get_head() {
1569 let store = InMemoryBlueprintStore::new();
1570
1571 let err = explain_agents_batch(State(state_with(store)), Path("no-such-bp".to_string()))
1572 .await
1573 .expect_err("expected 404");
1574
1575 assert_eq!(err.0, StatusCode::NOT_FOUND);
1576 }
1577
1578 fn state_with_policy(
1581 store: InMemoryBlueprintStore,
1582 legacy_worker_binding_policy: LegacyWorkerBindingPolicy,
1583 ) -> BlueprintsState {
1584 BlueprintsState {
1585 store: Arc::new(store),
1586 ref_base: None,
1587 ref_includes: Vec::new(),
1588 cli_default_agent_kind: None,
1589 strict_embed: false,
1590 legacy_worker_binding_policy,
1591 }
1592 }
1593
1594 fn runner_agent(name: &str, variant: &str, tools: &[&str], model: &str) -> AgentDef {
1599 let profile = AgentProfile {
1600 worker_binding: Some(variant.to_string()),
1601 tools: tools.iter().map(|t| t.to_string()).collect(),
1602 model: Some(model.to_string()),
1603 ..Default::default()
1604 };
1605 agent_def(name, Some(profile), None)
1606 }
1607
1608 fn bp_with_agents(bp_id: &str, agents: Vec<AgentDef>, strict_binding: bool) -> Blueprint {
1609 let first = agents
1610 .first()
1611 .map(|a| a.name.clone())
1612 .unwrap_or_else(|| "unused".to_string());
1613 Blueprint {
1614 schema_version: current_schema_version(),
1615 id: bp_id.into(),
1616 flow: serde_json::from_value(json!({
1617 "kind": "step",
1618 "ref": first,
1619 "in": {"op": "path", "at": "$.input"},
1620 "out": {"op": "path", "at": "$.out"},
1621 }))
1622 .expect("flow parse"),
1623 agents,
1624 operators: vec![],
1625 metas: vec![],
1626 hints: CompilerHints::default(),
1627 strategy: CompilerStrategy {
1628 strict_binding,
1629 ..Default::default()
1630 },
1631 metadata: BlueprintMetadata::default(),
1632 spawner_hints: Default::default(),
1633 default_agent_kind: AgentKind::Operator,
1634 default_operator_kind: None,
1635 default_init_ctx: None,
1636 default_agent_ctx: None,
1637 default_context_policy: None,
1638 projection_placement: None,
1639 audits: vec![],
1640 degradation_policy: None,
1641 runners: vec![],
1642 default_runner: None,
1643 subprocesses: vec![],
1644 check_policy: None,
1645 blueprint_ref_includes: Vec::new(),
1646 }
1647 }
1648
1649 #[tokio::test]
1650 async fn binding_requirements_lists_one_request_per_runner_backed_agent() {
1651 let bp = bp_with_agents(
1652 "binding-reqs-two-runners-bp",
1653 vec![
1654 runner_agent("worker", "knowledge-worker", &["Read", "Grep"], "sonnet"),
1655 runner_agent("reader", "mse-worker-reader", &["Read"], "haiku"),
1656 ],
1657 true,
1658 );
1659 let store = InMemoryBlueprintStore::new();
1660 seed(&store, &bp).await;
1661
1662 let resp = binding_requirements(
1663 State(state_with(store)),
1664 Path("binding-reqs-two-runners-bp".to_string()),
1665 )
1666 .await
1667 .expect("binding_requirements")
1668 .0;
1669
1670 assert_eq!(resp.blueprint_id, "binding-reqs-two-runners-bp");
1671 assert!(resp.strict_binding);
1673 assert_eq!(resp.requirements.len(), 2);
1674
1675 let worker = resp
1676 .requirements
1677 .iter()
1678 .find(|r| r.agent == "worker")
1679 .expect("worker requirement");
1680 assert_eq!(
1681 worker.backend,
1682 mlua_swarm_schema::BindingBackend::WsClaudeCode
1683 );
1684 assert_eq!(worker.launch_variant.as_deref(), Some("knowledge-worker"));
1685 assert_eq!(worker.requested_tools, vec!["Grep", "Read"]);
1686 assert_eq!(worker.requested_model.as_deref(), Some("sonnet"));
1687
1688 let reader = resp
1689 .requirements
1690 .iter()
1691 .find(|r| r.agent == "reader")
1692 .expect("reader requirement");
1693 assert_eq!(
1694 reader.backend,
1695 mlua_swarm_schema::BindingBackend::WsClaudeCode
1696 );
1697 assert_eq!(reader.launch_variant.as_deref(), Some("mse-worker-reader"));
1698 assert_eq!(reader.requested_tools, vec!["Read"]);
1699 assert_eq!(reader.requested_model.as_deref(), Some("haiku"));
1700 }
1701
1702 #[tokio::test]
1703 async fn binding_requirements_empty_when_no_runner_backed_agents() {
1704 let profile = AgentProfile {
1707 tools: vec!["Read".to_string()],
1708 ..Default::default()
1709 };
1710 let bp = single_step_bp(
1711 "binding-reqs-no-runners-bp",
1712 "scout",
1713 Some(profile),
1714 None,
1715 None,
1716 );
1717 let store = InMemoryBlueprintStore::new();
1718 seed(&store, &bp).await;
1719
1720 let resp = binding_requirements(
1721 State(state_with(store)),
1722 Path("binding-reqs-no-runners-bp".to_string()),
1723 )
1724 .await
1725 .expect("binding_requirements")
1726 .0;
1727
1728 assert!(!resp.strict_binding);
1729 assert!(resp.requirements.is_empty());
1730 }
1731
1732 #[tokio::test]
1733 async fn binding_requirements_unknown_blueprint_id_returns_404() {
1734 let store = InMemoryBlueprintStore::new();
1735
1736 let err = binding_requirements(State(state_with(store)), Path("no-such-bp".to_string()))
1737 .await
1738 .expect_err("expected 404");
1739
1740 assert_eq!(err.0, StatusCode::NOT_FOUND);
1741 }
1742
1743 #[tokio::test]
1744 async fn binding_requirements_422_when_legacy_binding_rejected_by_policy() {
1745 let bp = bp_with_agents(
1749 "binding-reqs-legacy-reject-bp",
1750 vec![runner_agent(
1751 "worker",
1752 "knowledge-worker",
1753 &["Read"],
1754 "sonnet",
1755 )],
1756 false,
1757 );
1758 let store = InMemoryBlueprintStore::new();
1759 seed(&store, &bp).await;
1760
1761 let err = binding_requirements(
1762 State(state_with_policy(store, LegacyWorkerBindingPolicy::Reject)),
1763 Path("binding-reqs-legacy-reject-bp".to_string()),
1764 )
1765 .await
1766 .expect_err("expected 422");
1767
1768 assert_eq!(err.0, StatusCode::UNPROCESSABLE_ENTITY);
1769 assert!(
1770 err.1.contains("resolve bound agents"),
1771 "422 body must carry the resolve error: {}",
1772 err.1
1773 );
1774 }
1775}
1776
1777#[cfg(test)]
1783mod seed_strict_embed_tests {
1784 use super::*;
1785 use mlua_swarm::blueprint::store::InMemoryBlueprintStore;
1786 use serde_json::json;
1787 use std::fs;
1788 use tempfile::TempDir;
1789
1790 const AGENT_MD: &str = "---\n\
1793name: writer\n\
1794description: writes\n\
1795model: sonnet\n\
1796---\n\
1797You write.\n";
1798
1799 fn write_md(dir: &std::path::Path, rel: &str, content: &str) -> PathBuf {
1800 let p = dir.join(rel);
1801 if let Some(parent) = p.parent() {
1802 fs::create_dir_all(parent).unwrap();
1803 }
1804 fs::write(&p, content).unwrap();
1805 p
1806 }
1807
1808 fn minimal_bp_body(id: &str) -> serde_json::Value {
1813 json!({
1814 "schema_version": mlua_swarm::blueprint::current_schema_version(),
1815 "id": id,
1816 "flow": { "kind": "step", "ref": "writer",
1817 "in": {"op": "path", "at": "$.input"},
1818 "out": {"op": "path", "at": "$.out"} },
1819 "agents": [
1820 { "name": "writer", "kind": "rust_fn", "spec": { "fn_id": "writer" } }
1821 ],
1822 "operators": [],
1823 "metas": [],
1824 "hints": {},
1825 "strategy": {},
1826 "metadata": {},
1827 "spawner_hints": {},
1828 "default_agent_kind": "operator",
1829 "default_agent_ctx": null,
1830 "audits": [],
1831 "runners": [],
1832 "blueprint_ref_includes": []
1833 })
1834 }
1835
1836 fn state_for_test(
1837 store: InMemoryBlueprintStore,
1838 ref_base: Option<PathBuf>,
1839 strict_embed: bool,
1840 ) -> BlueprintsState {
1841 BlueprintsState {
1842 store: Arc::new(store),
1843 ref_base,
1844 ref_includes: Vec::new(),
1845 cli_default_agent_kind: None,
1846 strict_embed,
1847 legacy_worker_binding_policy: LegacyWorkerBindingPolicy::Allow,
1848 }
1849 }
1850
1851 #[tokio::test]
1853 async fn strict_embed_off_resolves_agent_md_ref_and_seeds() {
1854 let dir = TempDir::new().unwrap();
1855 write_md(dir.path(), "agents/writer.md", AGENT_MD);
1856 let mut body = minimal_bp_body("strict-off-resolvable-bp");
1857 body["agents"] = json!([ { "$agent_md": "agents/writer.md", "kind": "rust_fn" } ]);
1858
1859 let store = InMemoryBlueprintStore::new();
1860 let state = state_for_test(store, Some(dir.path().to_path_buf()), false);
1861
1862 let (status, resp) = seed_blueprint(
1863 State(state),
1864 Path("strict-off-resolvable-bp".to_string()),
1865 Json(body),
1866 )
1867 .await
1868 .expect("seed ok");
1869 assert_eq!(status, StatusCode::CREATED);
1870 assert_eq!(resp.0["seeded"], json!(true));
1871 }
1872
1873 #[tokio::test]
1876 async fn strict_embed_off_unresolvable_ref_returns_400_with_include_cascade_hint() {
1877 let dir = TempDir::new().unwrap();
1878 let mut body = minimal_bp_body("strict-off-unresolvable-bp");
1880 body["agents"] = json!([ { "$agent_md": "agents/missing.md", "kind": "rust_fn" } ]);
1881
1882 let store = InMemoryBlueprintStore::new();
1883 let state = state_for_test(store, Some(dir.path().to_path_buf()), false);
1884
1885 let err = seed_blueprint(
1886 State(state),
1887 Path("strict-off-unresolvable-bp".to_string()),
1888 Json(body),
1889 )
1890 .await
1891 .expect_err("expected 400");
1892 assert_eq!(err.0, StatusCode::BAD_REQUEST);
1893 let msg = err.1;
1894 assert!(
1896 msg.contains("cascade") && msg.contains(dir.path().to_str().unwrap()),
1897 "linker cascade error must name searched dirs: {msg}"
1898 );
1899 assert!(msg.contains("--include"), "hint names CLI flag: {msg}");
1902 assert!(
1903 msg.contains("MSE_BLUEPRINT_INCLUDES"),
1904 "hint names env var: {msg}"
1905 );
1906 assert!(
1907 msg.contains("blueprint_ref_includes"),
1908 "hint names config-file / in-bp key: {msg}"
1909 );
1910 assert!(
1911 msg.contains("mse bp build --strict-embed"),
1912 "hint suggests client-side pre-embed as escape hatch: {msg}"
1913 );
1914 }
1915
1916 #[tokio::test]
1920 async fn strict_embed_on_refuses_body_with_agent_md_ref() {
1921 let mut body = minimal_bp_body("strict-on-refs-present-bp");
1922 body["agents"] = json!([ { "$agent_md": "agents/anything.md", "kind": "rust_fn" } ]);
1923
1924 let store = InMemoryBlueprintStore::new();
1925 let state = state_for_test(store, None, true);
1928
1929 let err = seed_blueprint(
1930 State(state),
1931 Path("strict-on-refs-present-bp".to_string()),
1932 Json(body),
1933 )
1934 .await
1935 .expect_err("expected 400");
1936 assert_eq!(err.0, StatusCode::BAD_REQUEST);
1937 let msg = err.1;
1938 assert!(
1939 msg.starts_with("strict_embed:"),
1940 "verdict tag must namespace the error: {msg}"
1941 );
1942 assert!(
1943 msg.contains("$agent_md=agents/anything.md"),
1944 "message must name every unembedded ref: {msg}"
1945 );
1946 assert!(
1947 msg.contains("mse bp build --strict-embed"),
1948 "message must point at client-side pre-embed: {msg}"
1949 );
1950 }
1951
1952 #[tokio::test]
1955 async fn strict_embed_on_refuses_body_with_file_ref_deep_in_object() {
1956 let mut body = minimal_bp_body("strict-on-file-ref-bp");
1957 body["flow"] = json!({
1960 "kind": "step",
1961 "ref": "writer",
1962 "in": {"op": "lit", "value": { "$file": "prompts/deep.md" } },
1963 "out": {"op": "path", "at": "$.out"}
1964 });
1965
1966 let store = InMemoryBlueprintStore::new();
1967 let state = state_for_test(store, None, true);
1968
1969 let err = seed_blueprint(
1970 State(state),
1971 Path("strict-on-file-ref-bp".to_string()),
1972 Json(body),
1973 )
1974 .await
1975 .expect_err("expected 400");
1976 assert_eq!(err.0, StatusCode::BAD_REQUEST);
1977 assert!(
1978 err.1.contains("$file=prompts/deep.md"),
1979 "walker must find nested `$file` refs: {}",
1980 err.1
1981 );
1982 }
1983
1984 #[tokio::test]
1986 async fn strict_embed_on_accepts_fully_embedded_body() {
1987 let body = minimal_bp_body("strict-on-embedded-bp");
1988
1989 let store = InMemoryBlueprintStore::new();
1990 let state = state_for_test(store, None, true);
1991
1992 let (status, resp) = seed_blueprint(
1993 State(state),
1994 Path("strict-on-embedded-bp".to_string()),
1995 Json(body),
1996 )
1997 .await
1998 .expect("embedded body seeds ok");
1999 assert_eq!(status, StatusCode::CREATED);
2000 assert_eq!(resp.0["seeded"], json!(true));
2001 }
2002
2003 #[test]
2006 fn walker_finds_refs_in_arrays_and_nested_objects() {
2007 let embedded = json!({ "id": "x", "agents": [ { "name": "a", "kind": "rust_fn" } ] });
2008 assert!(collect_unembedded_refs(&embedded).is_none());
2009
2010 let with_refs = json!({
2011 "id": "x",
2012 "agents": [ { "$agent_md": "a.md" } ],
2013 "flow": { "in": { "value": { "$file": "p.md" } } }
2014 });
2015 let refs = collect_unembedded_refs(&with_refs).expect("some refs");
2016 assert!(refs.iter().any(|s| s == "$agent_md=a.md"));
2017 assert!(refs.iter().any(|s| s == "$file=p.md"));
2018 }
2019}