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