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 lints: None,
1088 }
1089 }
1090
1091 fn single_step_bp(
1095 bp_id: &str,
1096 agent_name: &str,
1097 profile: Option<AgentProfile>,
1098 meta: Option<AgentMeta>,
1099 default_agent_ctx: Option<serde_json::Value>,
1100 ) -> Blueprint {
1101 Blueprint {
1102 schema_version: current_schema_version(),
1103 id: bp_id.into(),
1104 flow: serde_json::from_value(json!({
1105 "kind": "step",
1106 "ref": agent_name,
1107 "in": {"op": "path", "at": "$.input"},
1108 "out": {"op": "path", "at": "$.out"},
1109 }))
1110 .expect("flow parse"),
1111 agents: vec![agent_def(agent_name, profile, meta)],
1112 operators: vec![],
1113 metas: vec![],
1114 hints: CompilerHints::default(),
1115 strategy: CompilerStrategy::default(),
1116 metadata: BlueprintMetadata::default(),
1117 spawner_hints: Default::default(),
1118 default_agent_kind: AgentKind::Operator,
1119 default_operator_kind: None,
1120 default_init_ctx: None,
1121 default_agent_ctx,
1122 default_context_policy: None,
1123 projection_placement: None,
1124 audits: vec![],
1125 degradation_policy: None,
1126 runners: vec![],
1127 default_runner: None,
1128 subprocesses: vec![],
1129 check_policy: None,
1130 blueprint_ref_includes: Vec::new(),
1131 }
1132 }
1133
1134 async fn seed(store: &InMemoryBlueprintStore, bp: &Blueprint) {
1135 let bp_id = BlueprintId::new(bp.id.as_str());
1136 let v = blueprint_version(bp).expect("version");
1137 store
1138 .write_new(&bp_id, bp, &[], CommitMetadata::seed(bp_id.clone(), v, 0))
1139 .await
1140 .expect("write_new");
1141 }
1142
1143 fn state_with(store: InMemoryBlueprintStore) -> BlueprintsState {
1144 BlueprintsState {
1145 store: Arc::new(store),
1146 ref_base: None,
1147 ref_includes: Vec::new(),
1148 cli_default_agent_kind: None,
1149 strict_embed: false,
1150 legacy_worker_binding_policy: LegacyWorkerBindingPolicy::Allow,
1151 }
1152 }
1153
1154 #[tokio::test]
1155 async fn full_case_reports_binding_ctx_override_and_system_prompt() {
1156 let profile = AgentProfile {
1157 system_prompt: "Hello {{ name }}, mode={{ mode }}".to_string(),
1158 tools: vec!["Read".to_string(), "Grep".to_string()],
1159 worker_binding: Some("knowledge-worker".to_string()),
1160 ..Default::default()
1161 };
1162 let meta = AgentMeta {
1163 ctx: Some(json!({ "work_dir": "/inline" })),
1164 ..Default::default()
1165 };
1166 let bp = single_step_bp(
1167 "explain-full-bp",
1168 "researcher",
1169 Some(profile),
1170 Some(meta),
1171 Some(json!({ "work_dir": "/bp-global", "extra": "kept" })),
1172 );
1173 let store = InMemoryBlueprintStore::new();
1174 seed(&store, &bp).await;
1175
1176 let resp = explain_agent(
1177 State(state_with(store)),
1178 Path(("explain-full-bp".to_string(), "researcher".to_string())),
1179 )
1180 .await
1181 .expect("explain_agent")
1182 .0;
1183
1184 assert_eq!(resp.blueprint.id, "explain-full-bp");
1185 assert!(!resp.blueprint.version.is_empty());
1186 assert_eq!(resp.agent.name, "researcher");
1187 assert_eq!(resp.agent.kind, AgentKind::RustFn);
1188
1189 let binding = resp.worker_binding.expect("worker_binding present");
1190 assert_eq!(binding.variant, "knowledge-worker");
1191 assert!(resp.binding_note.is_none());
1192
1193 assert_eq!(
1194 resp.declared_tools.tools,
1195 vec!["Read".to_string(), "Grep".to_string()]
1196 );
1197 assert!(resp.declared_tools.informational);
1198
1199 let sp = resp.system_prompt.expect("system_prompt present");
1200 assert_eq!(sp.bytes, "Hello {{ name }}, mode={{ mode }}".len());
1201 assert_eq!(sp.lines, 1);
1202 assert_eq!(
1203 sp.template_variables,
1204 vec!["mode".to_string(), "name".to_string()]
1205 );
1206 assert!(sp.template_syntax_error.is_none());
1207
1208 assert_eq!(resp.effective_ctx.keys["work_dir"].value, json!("/inline"));
1209 assert_eq!(
1210 resp.effective_ctx.keys["work_dir"].winning_tier,
1211 "agent_inline"
1212 );
1213 assert_eq!(resp.effective_ctx.keys["extra"].value, json!("kept"));
1214 assert_eq!(resp.effective_ctx.keys["extra"].winning_tier, "bp_global");
1215
1216 assert_eq!(resp.output.projection_name, "researcher");
1217 assert!(resp.output.naming_warnings.is_empty());
1218 }
1219
1220 #[tokio::test]
1221 async fn agent_without_worker_binding_reports_binding_note() {
1222 let profile = AgentProfile {
1223 tools: vec!["Read".to_string()],
1224 ..Default::default()
1225 };
1226 let bp = single_step_bp("explain-no-binding-bp", "scout", Some(profile), None, None);
1227 let store = InMemoryBlueprintStore::new();
1228 seed(&store, &bp).await;
1229
1230 let resp = explain_agent(
1231 State(state_with(store)),
1232 Path(("explain-no-binding-bp".to_string(), "scout".to_string())),
1233 )
1234 .await
1235 .expect("explain_agent")
1236 .0;
1237
1238 assert!(resp.worker_binding.is_none());
1239 let note = resp.binding_note.expect("binding_note present");
1240 assert!(note.contains("no worker_binding declared"));
1241 assert!(resp.system_prompt.is_none());
1242 }
1243
1244 #[tokio::test]
1245 async fn unknown_agent_name_returns_404_with_available_list() {
1246 let bp = single_step_bp("explain-404-agent-bp", "foo", None, None, None);
1247 let store = InMemoryBlueprintStore::new();
1248 seed(&store, &bp).await;
1249
1250 let err = explain_agent(
1251 State(state_with(store)),
1252 Path((
1253 "explain-404-agent-bp".to_string(),
1254 "no-such-agent".to_string(),
1255 )),
1256 )
1257 .await
1258 .expect_err("expected 404");
1259
1260 assert_eq!(err.0, StatusCode::NOT_FOUND);
1261 let body: serde_json::Value = serde_json::from_str(&err.1).expect("json body");
1262 assert_eq!(body["error"], "agent not found in blueprint");
1263 assert_eq!(body["agent"], "no-such-agent");
1264 assert_eq!(body["available"], json!(["foo"]));
1265 }
1266
1267 #[tokio::test]
1268 async fn unknown_blueprint_id_returns_404_same_as_get_head() {
1269 let store = InMemoryBlueprintStore::new();
1270
1271 let err = explain_agent(
1272 State(state_with(store)),
1273 Path(("no-such-bp".to_string(), "any-agent".to_string())),
1274 )
1275 .await
1276 .expect_err("expected 404");
1277
1278 assert_eq!(err.0, StatusCode::NOT_FOUND);
1279 }
1280
1281 #[tokio::test]
1282 async fn template_syntax_error_is_reported_without_500() {
1283 let profile = AgentProfile {
1284 system_prompt: "hello {{ unclosed".to_string(),
1285 ..Default::default()
1286 };
1287 let bp = single_step_bp(
1288 "explain-syntax-error-bp",
1289 "scout",
1290 Some(profile),
1291 None,
1292 None,
1293 );
1294 let store = InMemoryBlueprintStore::new();
1295 seed(&store, &bp).await;
1296
1297 let resp = explain_agent(
1298 State(state_with(store)),
1299 Path(("explain-syntax-error-bp".to_string(), "scout".to_string())),
1300 )
1301 .await
1302 .expect("explain_agent")
1303 .0;
1304
1305 let sp = resp.system_prompt.expect("system_prompt present");
1306 assert!(sp.template_variables.is_empty());
1307 assert!(sp.template_syntax_error.is_some());
1308 }
1309
1310 #[tokio::test]
1313 async fn runner_resolves_from_legacy_worker_binding_when_nothing_else_declared() {
1314 let profile = AgentProfile {
1315 worker_binding: Some("knowledge-worker".to_string()),
1316 tools: vec!["Read".to_string()],
1317 ..Default::default()
1318 };
1319 let bp = single_step_bp(
1320 "explain-runner-legacy-bp",
1321 "scout",
1322 Some(profile),
1323 None,
1324 None,
1325 );
1326 let store = InMemoryBlueprintStore::new();
1327 seed(&store, &bp).await;
1328
1329 let resp = explain_agent(
1330 State(state_with(store)),
1331 Path(("explain-runner-legacy-bp".to_string(), "scout".to_string())),
1332 )
1333 .await
1334 .expect("explain_agent")
1335 .0;
1336
1337 assert_eq!(
1338 resp.runner.resolved,
1339 Some(mlua_swarm_schema::Runner::WsClaudeCode {
1340 variant: "knowledge-worker".to_string(),
1341 tools: vec!["Read".to_string()],
1342 })
1343 );
1344 assert!(resp.runner.error.is_none());
1345 assert!(resp.runner.warning.is_none());
1346 }
1347
1348 #[tokio::test]
1349 async fn runner_reports_unresolved_runner_ref_as_error_level_finding() {
1350 let mut bp = single_step_bp("explain-runner-unresolved-bp", "scout", None, None, None);
1351 bp.agents[0].runner_ref = Some("no-such-entry".to_string());
1352 let store = InMemoryBlueprintStore::new();
1353 seed(&store, &bp).await;
1354
1355 let resp = explain_agent(
1356 State(state_with(store)),
1357 Path((
1358 "explain-runner-unresolved-bp".to_string(),
1359 "scout".to_string(),
1360 )),
1361 )
1362 .await
1363 .expect("explain_agent")
1364 .0;
1365
1366 assert!(resp.runner.resolved.is_none());
1367 let error = resp.runner.error.expect("error-level finding present");
1368 assert!(
1369 error.contains("no-such-entry"),
1370 "error must name the unresolved runner_ref: {error}"
1371 );
1372 assert!(resp.runner.warning.is_none());
1373 }
1374
1375 #[tokio::test]
1376 async fn runner_reports_backend_kind_mismatch_as_warn_level_finding() {
1377 let mut bp = single_step_bp("explain-runner-mismatch-bp", "scout", None, None, None);
1381 bp.runners = vec![mlua_swarm_schema::RunnerDef {
1382 name: "in-process".to_string(),
1383 runner: mlua_swarm_schema::Runner::AgentBlockInProcess {
1384 tools: vec!["Bash".to_string()],
1385 },
1386 }];
1387 bp.agents[0].runner_ref = Some("in-process".to_string());
1388 let store = InMemoryBlueprintStore::new();
1389 seed(&store, &bp).await;
1390
1391 let resp = explain_agent(
1392 State(state_with(store)),
1393 Path((
1394 "explain-runner-mismatch-bp".to_string(),
1395 "scout".to_string(),
1396 )),
1397 )
1398 .await
1399 .expect("explain_agent")
1400 .0;
1401
1402 assert!(resp.runner.resolved.is_some());
1403 assert!(resp.runner.error.is_none());
1404 let warning = resp.runner.warning.expect("warn-level finding present");
1405 assert!(
1406 warning.contains("AgentBlockInProcess") && warning.contains("RustFn"),
1407 "warning must name both the resolved backend and the mismatched kind: {warning}"
1408 );
1409 }
1410
1411 fn batch_bp() -> Blueprint {
1417 let bound_profile = AgentProfile {
1418 system_prompt: "hello world".to_string(),
1419 tools: vec!["Read".to_string(), "Grep".to_string()],
1420 worker_binding: Some("knowledge-worker".to_string()),
1421 ..Default::default()
1422 };
1423 let bound_meta = AgentMeta {
1424 ctx: Some(json!({ "work_dir": "/inline" })),
1425 ..Default::default()
1426 };
1427 Blueprint {
1428 schema_version: current_schema_version(),
1429 id: "explain-batch-bp".into(),
1430 flow: serde_json::from_value(json!({
1431 "kind": "step",
1432 "ref": "bound_agent",
1433 "in": {"op": "path", "at": "$.input"},
1434 "out": {"op": "path", "at": "$.out"},
1435 }))
1436 .expect("flow parse"),
1437 agents: vec![
1438 agent_def("bound_agent", Some(bound_profile), Some(bound_meta)),
1439 agent_def("unbound_agent", None, None),
1440 agent_def("orphan_agent", None, None),
1441 ],
1442 operators: vec![],
1443 metas: vec![],
1444 hints: CompilerHints::default(),
1445 strategy: CompilerStrategy::default(),
1446 metadata: BlueprintMetadata::default(),
1447 spawner_hints: Default::default(),
1448 default_agent_kind: AgentKind::Operator,
1449 default_operator_kind: None,
1450 default_init_ctx: None,
1451 default_agent_ctx: Some(json!({ "work_dir": "/bp-global", "extra": "kept" })),
1452 default_context_policy: None,
1453 projection_placement: None,
1454 audits: vec![],
1455 degradation_policy: None,
1456 runners: vec![],
1457 default_runner: None,
1458 subprocesses: vec![],
1459 check_policy: None,
1460 blueprint_ref_includes: Vec::new(),
1461 }
1462 }
1463
1464 #[tokio::test]
1465 async fn explain_agents_batch_reports_a_summary_row_per_agent() {
1466 let bp = batch_bp();
1467 let store = InMemoryBlueprintStore::new();
1468 seed(&store, &bp).await;
1469
1470 let resp = explain_agents_batch(
1471 State(state_with(store)),
1472 Path("explain-batch-bp".to_string()),
1473 )
1474 .await
1475 .expect("explain_agents_batch")
1476 .0;
1477
1478 assert_eq!(resp.blueprint.id, "explain-batch-bp");
1479 assert!(!resp.blueprint.version.is_empty());
1480 assert_eq!(resp.agents.len(), 3);
1481
1482 let bound = resp
1483 .agents
1484 .iter()
1485 .find(|a| a.name == "bound_agent")
1486 .expect("bound_agent row");
1487 assert_eq!(bound.kind, format!("{:?}", AgentKind::RustFn));
1488 let binding = bound
1489 .worker_binding
1490 .as_ref()
1491 .expect("worker_binding present");
1492 assert_eq!(binding.variant, "knowledge-worker");
1493 assert_eq!(bound.declared_tools_count, 2);
1494 assert_eq!(bound.system_prompt_bytes, "hello world".len());
1495 assert_eq!(bound.effective_ctx_key_count, 2);
1497 assert_eq!(bound.projection_name, "bound_agent");
1499
1500 let unbound = resp
1501 .agents
1502 .iter()
1503 .find(|a| a.name == "unbound_agent")
1504 .expect("unbound_agent row");
1505 assert!(unbound.worker_binding.is_none());
1506 assert_eq!(unbound.declared_tools_count, 0);
1507 assert_eq!(unbound.system_prompt_bytes, 0);
1508 assert_eq!(unbound.effective_ctx_key_count, 2);
1510 assert_eq!(unbound.projection_name, "unbound_agent");
1512
1513 let orphan = resp
1514 .agents
1515 .iter()
1516 .find(|a| a.name == "orphan_agent")
1517 .expect("orphan_agent row");
1518 assert_eq!(orphan.projection_name, "orphan_agent");
1519 }
1520
1521 #[tokio::test]
1522 async fn explain_agents_batch_zero_agents_returns_empty_list_not_404() {
1523 let bp = Blueprint {
1524 schema_version: current_schema_version(),
1525 id: "explain-batch-empty-bp".into(),
1526 flow: serde_json::from_value(json!({
1527 "kind": "step",
1528 "ref": "unused",
1529 "in": {"op": "path", "at": "$.input"},
1530 "out": {"op": "path", "at": "$.out"},
1531 }))
1532 .expect("flow parse"),
1533 agents: vec![],
1534 operators: vec![],
1535 metas: vec![],
1536 hints: CompilerHints::default(),
1537 strategy: CompilerStrategy::default(),
1538 metadata: BlueprintMetadata::default(),
1539 spawner_hints: Default::default(),
1540 default_agent_kind: AgentKind::Operator,
1541 default_operator_kind: None,
1542 default_init_ctx: None,
1543 default_agent_ctx: None,
1544 default_context_policy: None,
1545 projection_placement: None,
1546 audits: vec![],
1547 degradation_policy: None,
1548 runners: vec![],
1549 default_runner: None,
1550 subprocesses: vec![],
1551 check_policy: None,
1552 blueprint_ref_includes: Vec::new(),
1553 };
1554 let store = InMemoryBlueprintStore::new();
1555 seed(&store, &bp).await;
1556
1557 let resp = explain_agents_batch(
1558 State(state_with(store)),
1559 Path("explain-batch-empty-bp".to_string()),
1560 )
1561 .await
1562 .expect("explain_agents_batch")
1563 .0;
1564
1565 assert!(resp.agents.is_empty());
1566 }
1567
1568 #[tokio::test]
1569 async fn explain_agents_batch_unknown_blueprint_id_returns_404_same_as_get_head() {
1570 let store = InMemoryBlueprintStore::new();
1571
1572 let err = explain_agents_batch(State(state_with(store)), Path("no-such-bp".to_string()))
1573 .await
1574 .expect_err("expected 404");
1575
1576 assert_eq!(err.0, StatusCode::NOT_FOUND);
1577 }
1578
1579 fn state_with_policy(
1582 store: InMemoryBlueprintStore,
1583 legacy_worker_binding_policy: LegacyWorkerBindingPolicy,
1584 ) -> BlueprintsState {
1585 BlueprintsState {
1586 store: Arc::new(store),
1587 ref_base: None,
1588 ref_includes: Vec::new(),
1589 cli_default_agent_kind: None,
1590 strict_embed: false,
1591 legacy_worker_binding_policy,
1592 }
1593 }
1594
1595 fn runner_agent(name: &str, variant: &str, tools: &[&str], model: &str) -> AgentDef {
1600 let profile = AgentProfile {
1601 worker_binding: Some(variant.to_string()),
1602 tools: tools.iter().map(|t| t.to_string()).collect(),
1603 model: Some(model.to_string()),
1604 ..Default::default()
1605 };
1606 agent_def(name, Some(profile), None)
1607 }
1608
1609 fn bp_with_agents(bp_id: &str, agents: Vec<AgentDef>, strict_binding: bool) -> Blueprint {
1610 let first = agents
1611 .first()
1612 .map(|a| a.name.clone())
1613 .unwrap_or_else(|| "unused".to_string());
1614 Blueprint {
1615 schema_version: current_schema_version(),
1616 id: bp_id.into(),
1617 flow: serde_json::from_value(json!({
1618 "kind": "step",
1619 "ref": first,
1620 "in": {"op": "path", "at": "$.input"},
1621 "out": {"op": "path", "at": "$.out"},
1622 }))
1623 .expect("flow parse"),
1624 agents,
1625 operators: vec![],
1626 metas: vec![],
1627 hints: CompilerHints::default(),
1628 strategy: CompilerStrategy {
1629 strict_binding,
1630 ..Default::default()
1631 },
1632 metadata: BlueprintMetadata::default(),
1633 spawner_hints: Default::default(),
1634 default_agent_kind: AgentKind::Operator,
1635 default_operator_kind: None,
1636 default_init_ctx: None,
1637 default_agent_ctx: None,
1638 default_context_policy: None,
1639 projection_placement: None,
1640 audits: vec![],
1641 degradation_policy: None,
1642 runners: vec![],
1643 default_runner: None,
1644 subprocesses: vec![],
1645 check_policy: None,
1646 blueprint_ref_includes: Vec::new(),
1647 }
1648 }
1649
1650 #[tokio::test]
1651 async fn binding_requirements_lists_one_request_per_runner_backed_agent() {
1652 let bp = bp_with_agents(
1653 "binding-reqs-two-runners-bp",
1654 vec![
1655 runner_agent("worker", "knowledge-worker", &["Read", "Grep"], "sonnet"),
1656 runner_agent("reader", "mse-worker-reader", &["Read"], "haiku"),
1657 ],
1658 true,
1659 );
1660 let store = InMemoryBlueprintStore::new();
1661 seed(&store, &bp).await;
1662
1663 let resp = binding_requirements(
1664 State(state_with(store)),
1665 Path("binding-reqs-two-runners-bp".to_string()),
1666 )
1667 .await
1668 .expect("binding_requirements")
1669 .0;
1670
1671 assert_eq!(resp.blueprint_id, "binding-reqs-two-runners-bp");
1672 assert!(resp.strict_binding);
1674 assert_eq!(resp.requirements.len(), 2);
1675
1676 let worker = resp
1677 .requirements
1678 .iter()
1679 .find(|r| r.agent == "worker")
1680 .expect("worker requirement");
1681 assert_eq!(
1682 worker.backend,
1683 mlua_swarm_schema::BindingBackend::WsClaudeCode
1684 );
1685 assert_eq!(worker.launch_variant.as_deref(), Some("knowledge-worker"));
1686 assert_eq!(worker.requested_tools, vec!["Grep", "Read"]);
1687 assert_eq!(worker.requested_model.as_deref(), Some("sonnet"));
1688
1689 let reader = resp
1690 .requirements
1691 .iter()
1692 .find(|r| r.agent == "reader")
1693 .expect("reader requirement");
1694 assert_eq!(
1695 reader.backend,
1696 mlua_swarm_schema::BindingBackend::WsClaudeCode
1697 );
1698 assert_eq!(reader.launch_variant.as_deref(), Some("mse-worker-reader"));
1699 assert_eq!(reader.requested_tools, vec!["Read"]);
1700 assert_eq!(reader.requested_model.as_deref(), Some("haiku"));
1701 }
1702
1703 #[tokio::test]
1704 async fn binding_requirements_empty_when_no_runner_backed_agents() {
1705 let profile = AgentProfile {
1708 tools: vec!["Read".to_string()],
1709 ..Default::default()
1710 };
1711 let bp = single_step_bp(
1712 "binding-reqs-no-runners-bp",
1713 "scout",
1714 Some(profile),
1715 None,
1716 None,
1717 );
1718 let store = InMemoryBlueprintStore::new();
1719 seed(&store, &bp).await;
1720
1721 let resp = binding_requirements(
1722 State(state_with(store)),
1723 Path("binding-reqs-no-runners-bp".to_string()),
1724 )
1725 .await
1726 .expect("binding_requirements")
1727 .0;
1728
1729 assert!(!resp.strict_binding);
1730 assert!(resp.requirements.is_empty());
1731 }
1732
1733 #[tokio::test]
1734 async fn binding_requirements_unknown_blueprint_id_returns_404() {
1735 let store = InMemoryBlueprintStore::new();
1736
1737 let err = binding_requirements(State(state_with(store)), Path("no-such-bp".to_string()))
1738 .await
1739 .expect_err("expected 404");
1740
1741 assert_eq!(err.0, StatusCode::NOT_FOUND);
1742 }
1743
1744 #[tokio::test]
1745 async fn binding_requirements_422_when_legacy_binding_rejected_by_policy() {
1746 let bp = bp_with_agents(
1750 "binding-reqs-legacy-reject-bp",
1751 vec![runner_agent(
1752 "worker",
1753 "knowledge-worker",
1754 &["Read"],
1755 "sonnet",
1756 )],
1757 false,
1758 );
1759 let store = InMemoryBlueprintStore::new();
1760 seed(&store, &bp).await;
1761
1762 let err = binding_requirements(
1763 State(state_with_policy(store, LegacyWorkerBindingPolicy::Reject)),
1764 Path("binding-reqs-legacy-reject-bp".to_string()),
1765 )
1766 .await
1767 .expect_err("expected 422");
1768
1769 assert_eq!(err.0, StatusCode::UNPROCESSABLE_ENTITY);
1770 assert!(
1771 err.1.contains("resolve bound agents"),
1772 "422 body must carry the resolve error: {}",
1773 err.1
1774 );
1775 }
1776}
1777
1778#[cfg(test)]
1784mod seed_strict_embed_tests {
1785 use super::*;
1786 use mlua_swarm::blueprint::store::InMemoryBlueprintStore;
1787 use serde_json::json;
1788 use std::fs;
1789 use tempfile::TempDir;
1790
1791 const AGENT_MD: &str = "---\n\
1794name: writer\n\
1795description: writes\n\
1796model: sonnet\n\
1797---\n\
1798You write.\n";
1799
1800 fn write_md(dir: &std::path::Path, rel: &str, content: &str) -> PathBuf {
1801 let p = dir.join(rel);
1802 if let Some(parent) = p.parent() {
1803 fs::create_dir_all(parent).unwrap();
1804 }
1805 fs::write(&p, content).unwrap();
1806 p
1807 }
1808
1809 fn minimal_bp_body(id: &str) -> serde_json::Value {
1814 json!({
1815 "schema_version": mlua_swarm::blueprint::current_schema_version(),
1816 "id": id,
1817 "flow": { "kind": "step", "ref": "writer",
1818 "in": {"op": "path", "at": "$.input"},
1819 "out": {"op": "path", "at": "$.out"} },
1820 "agents": [
1821 { "name": "writer", "kind": "rust_fn", "spec": { "fn_id": "writer" } }
1822 ],
1823 "operators": [],
1824 "metas": [],
1825 "hints": {},
1826 "strategy": {},
1827 "metadata": {},
1828 "spawner_hints": {},
1829 "default_agent_kind": "operator",
1830 "default_agent_ctx": null,
1831 "audits": [],
1832 "runners": [],
1833 "blueprint_ref_includes": []
1834 })
1835 }
1836
1837 fn state_for_test(
1838 store: InMemoryBlueprintStore,
1839 ref_base: Option<PathBuf>,
1840 strict_embed: bool,
1841 ) -> BlueprintsState {
1842 BlueprintsState {
1843 store: Arc::new(store),
1844 ref_base,
1845 ref_includes: Vec::new(),
1846 cli_default_agent_kind: None,
1847 strict_embed,
1848 legacy_worker_binding_policy: LegacyWorkerBindingPolicy::Allow,
1849 }
1850 }
1851
1852 #[tokio::test]
1854 async fn strict_embed_off_resolves_agent_md_ref_and_seeds() {
1855 let dir = TempDir::new().unwrap();
1856 write_md(dir.path(), "agents/writer.md", AGENT_MD);
1857 let mut body = minimal_bp_body("strict-off-resolvable-bp");
1858 body["agents"] = json!([ { "$agent_md": "agents/writer.md", "kind": "rust_fn" } ]);
1859
1860 let store = InMemoryBlueprintStore::new();
1861 let state = state_for_test(store, Some(dir.path().to_path_buf()), false);
1862
1863 let (status, resp) = seed_blueprint(
1864 State(state),
1865 Path("strict-off-resolvable-bp".to_string()),
1866 Json(body),
1867 )
1868 .await
1869 .expect("seed ok");
1870 assert_eq!(status, StatusCode::CREATED);
1871 assert_eq!(resp.0["seeded"], json!(true));
1872 }
1873
1874 #[tokio::test]
1877 async fn strict_embed_off_unresolvable_ref_returns_400_with_include_cascade_hint() {
1878 let dir = TempDir::new().unwrap();
1879 let mut body = minimal_bp_body("strict-off-unresolvable-bp");
1881 body["agents"] = json!([ { "$agent_md": "agents/missing.md", "kind": "rust_fn" } ]);
1882
1883 let store = InMemoryBlueprintStore::new();
1884 let state = state_for_test(store, Some(dir.path().to_path_buf()), false);
1885
1886 let err = seed_blueprint(
1887 State(state),
1888 Path("strict-off-unresolvable-bp".to_string()),
1889 Json(body),
1890 )
1891 .await
1892 .expect_err("expected 400");
1893 assert_eq!(err.0, StatusCode::BAD_REQUEST);
1894 let msg = err.1;
1895 assert!(
1897 msg.contains("cascade") && msg.contains(dir.path().to_str().unwrap()),
1898 "linker cascade error must name searched dirs: {msg}"
1899 );
1900 assert!(msg.contains("--include"), "hint names CLI flag: {msg}");
1903 assert!(
1904 msg.contains("MSE_BLUEPRINT_INCLUDES"),
1905 "hint names env var: {msg}"
1906 );
1907 assert!(
1908 msg.contains("blueprint_ref_includes"),
1909 "hint names config-file / in-bp key: {msg}"
1910 );
1911 assert!(
1912 msg.contains("mse bp build --strict-embed"),
1913 "hint suggests client-side pre-embed as escape hatch: {msg}"
1914 );
1915 }
1916
1917 #[tokio::test]
1921 async fn strict_embed_on_refuses_body_with_agent_md_ref() {
1922 let mut body = minimal_bp_body("strict-on-refs-present-bp");
1923 body["agents"] = json!([ { "$agent_md": "agents/anything.md", "kind": "rust_fn" } ]);
1924
1925 let store = InMemoryBlueprintStore::new();
1926 let state = state_for_test(store, None, true);
1929
1930 let err = seed_blueprint(
1931 State(state),
1932 Path("strict-on-refs-present-bp".to_string()),
1933 Json(body),
1934 )
1935 .await
1936 .expect_err("expected 400");
1937 assert_eq!(err.0, StatusCode::BAD_REQUEST);
1938 let msg = err.1;
1939 assert!(
1940 msg.starts_with("strict_embed:"),
1941 "verdict tag must namespace the error: {msg}"
1942 );
1943 assert!(
1944 msg.contains("$agent_md=agents/anything.md"),
1945 "message must name every unembedded ref: {msg}"
1946 );
1947 assert!(
1948 msg.contains("mse bp build --strict-embed"),
1949 "message must point at client-side pre-embed: {msg}"
1950 );
1951 }
1952
1953 #[tokio::test]
1956 async fn strict_embed_on_refuses_body_with_file_ref_deep_in_object() {
1957 let mut body = minimal_bp_body("strict-on-file-ref-bp");
1958 body["flow"] = json!({
1961 "kind": "step",
1962 "ref": "writer",
1963 "in": {"op": "lit", "value": { "$file": "prompts/deep.md" } },
1964 "out": {"op": "path", "at": "$.out"}
1965 });
1966
1967 let store = InMemoryBlueprintStore::new();
1968 let state = state_for_test(store, None, true);
1969
1970 let err = seed_blueprint(
1971 State(state),
1972 Path("strict-on-file-ref-bp".to_string()),
1973 Json(body),
1974 )
1975 .await
1976 .expect_err("expected 400");
1977 assert_eq!(err.0, StatusCode::BAD_REQUEST);
1978 assert!(
1979 err.1.contains("$file=prompts/deep.md"),
1980 "walker must find nested `$file` refs: {}",
1981 err.1
1982 );
1983 }
1984
1985 #[tokio::test]
1987 async fn strict_embed_on_accepts_fully_embedded_body() {
1988 let body = minimal_bp_body("strict-on-embedded-bp");
1989
1990 let store = InMemoryBlueprintStore::new();
1991 let state = state_for_test(store, None, true);
1992
1993 let (status, resp) = seed_blueprint(
1994 State(state),
1995 Path("strict-on-embedded-bp".to_string()),
1996 Json(body),
1997 )
1998 .await
1999 .expect("embedded body seeds ok");
2000 assert_eq!(status, StatusCode::CREATED);
2001 assert_eq!(resp.0["seeded"], json!(true));
2002 }
2003
2004 #[test]
2007 fn walker_finds_refs_in_arrays_and_nested_objects() {
2008 let embedded = json!({ "id": "x", "agents": [ { "name": "a", "kind": "rust_fn" } ] });
2009 assert!(collect_unembedded_refs(&embedded).is_none());
2010
2011 let with_refs = json!({
2012 "id": "x",
2013 "agents": [ { "$agent_md": "a.md" } ],
2014 "flow": { "in": { "value": { "$file": "p.md" } } }
2015 });
2016 let refs = collect_unembedded_refs(&with_refs).expect("some refs");
2017 assert!(refs.iter().any(|s| s == "$agent_md=a.md"));
2018 assert!(refs.iter().any(|s| s == "$file=p.md"));
2019 }
2020}