1use axum::{
7 extract::{Path, Query, State},
8 http::StatusCode,
9 routing::{get, post},
10 Json, Router,
11};
12use mlua_swarm::blueprint::loader::{expand_file_refs, pre_read_default_agent_kind};
13use mlua_swarm::blueprint::store::{
14 blueprint_version, BlueprintId, BlueprintStore, CommitMetadata,
15};
16use mlua_swarm::blueprint::{default_global_agent_kind, AgentKind, Blueprint};
17use mlua_swarm::core::explain::{explain_agent_ctx, CtxTier};
18use mlua_swarm::core::step_naming::StepNaming;
19use mlua_swarm::operator::render::template_variables;
20use mlua_swarm_schema::{resolve_runner, Runner};
21use serde::{Deserialize, Serialize};
22use std::collections::BTreeMap;
23use std::path::PathBuf;
24use std::sync::Arc;
25
26#[derive(Clone)]
32pub struct BlueprintsState {
33 pub store: Arc<dyn BlueprintStore>,
35 pub ref_base: Option<PathBuf>,
37 pub cli_default_agent_kind: Option<AgentKind>,
39}
40
41pub fn build_blueprints_router(store: Arc<dyn BlueprintStore>) -> Router {
43 build_blueprints_router_with_refs(store, None, None)
44}
45
46pub fn build_blueprints_router_with_refs(
56 store: Arc<dyn BlueprintStore>,
57 ref_base: Option<PathBuf>,
58 cli_default_agent_kind: Option<AgentKind>,
59) -> Router {
60 let state = BlueprintsState {
61 store,
62 ref_base,
63 cli_default_agent_kind,
64 };
65 Router::new()
66 .route("/v1/blueprints/:id/head", get(get_head))
67 .route("/v1/blueprints/:id/history", get(get_history))
68 .route(
69 "/v1/blueprints/:id/agents/:agent/explain",
70 get(explain_agent),
71 )
72 .route(
73 "/v1/blueprints/:id/agents/explain",
74 get(explain_agents_batch),
75 )
76 .route("/v1/blueprints/:id/unarchive", post(unarchive_blueprint))
77 .route(
78 "/v1/blueprints/:id",
79 post(seed_blueprint).delete(archive_blueprint),
80 )
81 .with_state(state)
82}
83
84async fn archive_blueprint(
96 State(state): State<BlueprintsState>,
97 Path(id): Path<String>,
98) -> Result<StatusCode, (StatusCode, String)> {
99 let bp_id = BlueprintId::new(id.clone());
100 state.store.archive_id(&bp_id).await.map_err(|e| match e {
101 mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)
102 | mlua_swarm::blueprint::store::BlueprintStoreError::IdNotFound(_) => {
103 (StatusCode::NOT_FOUND, format!("archive_id: {e}"))
104 }
105 other => (
106 StatusCode::INTERNAL_SERVER_ERROR,
107 format!("archive_id: {other}"),
108 ),
109 })?;
110 Ok(StatusCode::NO_CONTENT)
111}
112
113async fn unarchive_blueprint(
116 State(state): State<BlueprintsState>,
117 Path(id): Path<String>,
118) -> Result<StatusCode, (StatusCode, String)> {
119 let bp_id = BlueprintId::new(id.clone());
120 state
121 .store
122 .unarchive_id(&bp_id)
123 .await
124 .map_err(|e| match e {
125 mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)
126 | mlua_swarm::blueprint::store::BlueprintStoreError::IdNotFound(_) => {
127 (StatusCode::NOT_FOUND, format!("unarchive_id: {e}"))
128 }
129 other => (
130 StatusCode::INTERNAL_SERVER_ERROR,
131 format!("unarchive_id: {other}"),
132 ),
133 })?;
134 Ok(StatusCode::NO_CONTENT)
135}
136
137fn parse_error_with_schema_hint(e: &serde_json::Error) -> String {
141 format!(
142 "blueprint parse: {e} \
143 (hint: fetch the Blueprint JSON Schema via the MCP adapter bp_schema tool)"
144 )
145}
146
147async fn seed_blueprint(
170 State(state): State<BlueprintsState>,
171 Path(id): Path<String>,
172 Json(raw_body): Json<serde_json::Value>,
173) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, String)> {
174 let body: Blueprint = if let Some(base) = state.ref_base.as_ref() {
175 let default_kind = match pre_read_default_agent_kind(&raw_body) {
181 kind if raw_body.get("default_agent_kind").is_some() => kind,
183 _ => state
185 .cli_default_agent_kind
186 .clone()
187 .unwrap_or_else(default_global_agent_kind),
188 };
189 let expanded = expand_file_refs(raw_body, base, default_kind)
190 .map_err(|e| (StatusCode::BAD_REQUEST, format!("ref expand: {e}")))?;
191 serde_json::from_value(expanded)
192 .map_err(|e| (StatusCode::BAD_REQUEST, parse_error_with_schema_hint(&e)))?
193 } else {
194 serde_json::from_value(raw_body)
195 .map_err(|e| (StatusCode::BAD_REQUEST, parse_error_with_schema_hint(&e)))?
196 };
197 let store = state.store;
198 if id != body.id.as_str() {
199 return Err((
200 StatusCode::BAD_REQUEST,
201 format!("path id={id} != body.id={}", body.id),
202 ));
203 }
204 let bp_id = BlueprintId::new(id.clone());
205 let v = blueprint_version(&body).map_err(|e| {
206 (
207 StatusCode::INTERNAL_SERVER_ERROR,
208 format!("bp version: {e}"),
209 )
210 })?;
211 let prev_head = match store.read_head(&bp_id).await {
212 Ok(traced) => Some(traced),
213 Err(mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)) => None,
214 Err(mlua_swarm::blueprint::store::BlueprintStoreError::Archived(_)) => {
215 return Err((
216 StatusCode::CONFLICT,
217 format!("blueprint {id} is archived; POST /v1/blueprints/{id}/unarchive first"),
218 ));
219 }
220 Err(e) => {
221 return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("read_head: {e}")));
222 }
223 };
224 if let Some(traced) = &prev_head {
225 if traced.trace.version == v {
226 return Ok((
227 StatusCode::OK,
228 Json(serde_json::json!({"id": id, "version": format!("{:?}", v), "seeded": false})),
229 ));
230 }
231 }
232 let parents: Vec<_> = prev_head
233 .as_ref()
234 .map(|t| vec![t.trace.version])
235 .unwrap_or_default();
236 let now_ms = std::time::SystemTime::now()
237 .duration_since(std::time::UNIX_EPOCH)
238 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
239 .as_millis() as i64;
240 let meta = CommitMetadata::seed(bp_id.clone(), v, now_ms);
241 store
242 .write_new(&bp_id, &body, &parents, meta)
243 .await
244 .map_err(|e| match &e {
245 mlua_swarm::blueprint::store::BlueprintStoreError::LockBusy => (
246 StatusCode::TOO_MANY_REQUESTS,
247 format!("blueprint {id} lock busy; retry"),
248 ),
249 mlua_swarm::blueprint::store::BlueprintStoreError::Archived(_) => (
250 StatusCode::CONFLICT,
251 format!("blueprint {id} is archived; POST /v1/blueprints/{id}/unarchive first"),
252 ),
253 _ => (StatusCode::INTERNAL_SERVER_ERROR, format!("write_new: {e}")),
254 })?;
255 Ok((
256 StatusCode::CREATED,
257 Json(serde_json::json!({"id": id, "version": format!("{:?}", v), "seeded": true})),
258 ))
259}
260
261#[derive(Debug, Serialize)]
262struct HeadResponse {
263 id: String,
264 version: String,
265 blueprint: Blueprint,
266}
267
268async fn get_head(
269 State(state): State<BlueprintsState>,
270 Path(id): Path<String>,
271) -> Result<Json<HeadResponse>, (StatusCode, String)> {
272 let store = state.store;
273 let bp_id = BlueprintId::new(id.clone());
274 let traced = store
275 .read_head(&bp_id)
276 .await
277 .map_err(|e| (StatusCode::NOT_FOUND, format!("read_head: {e}")))?;
278 Ok(Json(HeadResponse {
279 id,
280 version: format!("{:?}", traced.trace.version),
281 blueprint: traced.value,
282 }))
283}
284
285#[derive(Debug, Deserialize)]
286struct HistoryQuery {
287 #[serde(default = "default_limit")]
288 limit: usize,
289}
290
291fn default_limit() -> usize {
292 20
293}
294
295#[derive(Debug, Serialize)]
296struct HistoryEntry {
297 hash: String,
299 version_label: Option<String>,
301 rationale: String,
303}
304
305#[derive(Debug, Serialize)]
306struct HistoryResponse {
307 count: usize,
308 entries: Vec<HistoryEntry>,
309}
310
311async fn get_history(
312 State(state): State<BlueprintsState>,
313 Path(id): Path<String>,
314 Query(q): Query<HistoryQuery>,
315) -> Result<Json<HistoryResponse>, (StatusCode, String)> {
316 let store = state.store;
317 let bp_id = BlueprintId::new(id);
318 let versions = store
319 .history(&bp_id, q.limit)
320 .await
321 .map_err(|e| (StatusCode::NOT_FOUND, format!("history: {e}")))?;
322 let mut entries = Vec::with_capacity(versions.len());
323 for v in versions {
324 let traced = store.read_version(&bp_id, v).await.map_err(|e| {
325 (
326 StatusCode::INTERNAL_SERVER_ERROR,
327 format!("read_version: {e}"),
328 )
329 })?;
330 let rationale = store
331 .read_commit_rationale(&bp_id, v)
332 .await
333 .unwrap_or(None)
334 .unwrap_or_default();
335 entries.push(HistoryEntry {
336 hash: format!("{:?}", v),
337 version_label: traced.value.metadata.version_label.clone(),
338 rationale,
339 });
340 }
341 let count = entries.len();
342 Ok(Json(HistoryResponse { count, entries }))
343}
344
345#[derive(Debug, Serialize)]
352struct ExplainBlueprintRef {
353 id: String,
355 version: String,
358}
359
360#[derive(Debug, Serialize)]
363struct ExplainAgentRef {
364 name: String,
366 kind: AgentKind,
368}
369
370#[derive(Debug, Serialize)]
375struct ExplainWorkerBinding {
376 variant: String,
378}
379
380#[derive(Debug, Serialize)]
382struct ExplainDeclaredTools {
383 tools: Vec<String>,
385 informational: bool,
387 note: String,
389}
390
391#[derive(Debug, Serialize)]
394struct ExplainSystemPrompt {
395 bytes: usize,
397 lines: usize,
399 template_variables: Vec<String>,
403 template_syntax_error: Option<String>,
406 note: String,
408}
409
410#[derive(Debug, Serialize)]
412struct ExplainCtxKeyEntry {
413 value: serde_json::Value,
415 winning_tier: String,
418}
419
420#[derive(Debug, Serialize)]
425struct ExplainEffectiveCtx {
426 keys: BTreeMap<String, ExplainCtxKeyEntry>,
428 note: String,
430}
431
432#[derive(Debug, Serialize)]
434struct ExplainOutput {
435 projection_name: String,
439 naming_warnings: Vec<String>,
444 parts_note: String,
447}
448
449#[derive(Debug, Serialize)]
457struct ExplainRunner {
458 resolved: Option<Runner>,
464 error: Option<String>,
468 warning: Option<String>,
474}
475
476fn runner_kind_mismatch_warning(
481 runner: &Runner,
482 kind: &AgentKind,
483 agent_name: &str,
484) -> Option<String> {
485 match (runner, kind) {
486 (Runner::AgentBlockInProcess { .. }, AgentKind::AgentBlock) => None,
487 (Runner::AgentBlockInProcess { .. }, other) => Some(format!(
488 "agent '{agent_name}' resolves to Runner::AgentBlockInProcess but AgentDef.kind = \
489 {other:?} (expected AgentBlock)"
490 )),
491 (Runner::WsClaudeCode { .. }, AgentKind::AgentBlock) => Some(format!(
492 "agent '{agent_name}' resolves to Runner::WsClaudeCode but AgentDef.kind = AgentBlock"
493 )),
494 (Runner::WsClaudeCode { .. }, _) => None,
495 }
496}
497
498#[derive(Debug, Serialize)]
500struct ExplainAgentResponse {
501 blueprint: ExplainBlueprintRef,
503 agent: ExplainAgentRef,
505 worker_binding: Option<ExplainWorkerBinding>,
507 binding_note: Option<String>,
509 runner: ExplainRunner,
511 declared_tools: ExplainDeclaredTools,
513 system_prompt: Option<ExplainSystemPrompt>,
516 effective_ctx: ExplainEffectiveCtx,
518 output: ExplainOutput,
520}
521
522fn ctx_tier_label(tier: CtxTier) -> &'static str {
525 match tier {
526 CtxTier::AgentInline => "agent_inline",
527 CtxTier::MetaRef => "meta_ref",
528 CtxTier::BpGlobal => "bp_global",
529 }
530}
531
532fn explain_system_prompt(template: &str) -> ExplainSystemPrompt {
535 let (variables, template_syntax_error): (Vec<String>, Option<String>) =
536 match template_variables(template) {
537 Ok(vars) => (vars.into_iter().collect(), None),
538 Err(e) => (Vec::new(), Some(e.to_string())),
539 };
540 ExplainSystemPrompt {
541 bytes: template.len(),
542 lines: template.lines().count(),
543 template_variables: variables,
544 template_syntax_error,
545 note: "when the step directive is not a JSON object, only `value` is bound at render \
546 time"
547 .to_string(),
548 }
549}
550
551async fn explain_agent(
566 State(state): State<BlueprintsState>,
567 Path((id, agent)): Path<(String, String)>,
568) -> Result<Json<ExplainAgentResponse>, (StatusCode, String)> {
569 let store = state.store;
570 let bp_id = BlueprintId::new(id.clone());
571 let traced = store
572 .read_head(&bp_id)
573 .await
574 .map_err(|e| (StatusCode::NOT_FOUND, format!("read_head: {e}")))?;
575 let bp = traced.value;
576 let version = format!("{:?}", traced.trace.version);
577
578 let Some(agent_def) = bp.agents.iter().find(|ad| ad.name == agent) else {
579 let available: Vec<&str> = bp.agents.iter().map(|ad| ad.name.as_str()).collect();
580 return Err((
581 StatusCode::NOT_FOUND,
582 serde_json::json!({
583 "error": "agent not found in blueprint",
584 "agent": agent,
585 "available": available,
586 })
587 .to_string(),
588 ));
589 };
590
591 let profile = agent_def.profile.as_ref();
592
593 let (worker_binding, binding_note) = match profile.and_then(|p| p.worker_binding.as_ref()) {
594 Some(variant) => (
595 Some(ExplainWorkerBinding {
596 variant: variant.clone(),
597 }),
598 None,
599 ),
600 None => (
601 None,
602 Some(
603 "no worker_binding declared; WS operator dispatch will fail at compile \
604 (InvalidSpec)"
605 .to_string(),
606 ),
607 ),
608 };
609
610 let declared_tools = ExplainDeclaredTools {
611 tools: profile.map(|p| p.tools.clone()).unwrap_or_default(),
612 informational: true,
613 note: "declared tools do not grant anything; the effective tool surface is the worker \
614 wrapper's frontmatter (see operator.rs WorkerBinding doc)"
615 .to_string(),
616 };
617
618 let runner = match resolve_runner(&bp, agent_def) {
623 Ok(resolved) => {
624 let warning = resolved
625 .as_ref()
626 .and_then(|r| runner_kind_mismatch_warning(r, &agent_def.kind, &agent_def.name));
627 ExplainRunner {
628 resolved,
629 error: None,
630 warning,
631 }
632 }
633 Err(e) => ExplainRunner {
634 resolved: None,
635 error: Some(e.to_string()),
636 warning: None,
637 },
638 };
639
640 let system_prompt = profile
641 .filter(|p| !p.system_prompt.is_empty())
642 .map(|p| explain_system_prompt(&p.system_prompt));
643
644 let ctx_keys = explain_agent_ctx(&bp, &agent).unwrap_or_default();
645 let effective_ctx = ExplainEffectiveCtx {
646 keys: ctx_keys
647 .into_iter()
648 .map(|(k, resolution)| {
649 (
650 k,
651 ExplainCtxKeyEntry {
652 value: resolution.value,
653 winning_tier: ctx_tier_label(resolution.winning_tier).to_string(),
654 },
655 )
656 })
657 .collect(),
658 note: "static tiers only; Run/Task/Step runtime tiers always win over these \
659 (only-if-absent insertion order)"
660 .to_string(),
661 };
662
663 let (projection_name, naming_warnings) = match StepNaming::from_blueprint(&bp) {
664 Ok((naming, _soft_warnings)) => match naming.canonical_of_producer(&agent) {
665 Some(canonical) => (canonical.to_string(), Vec::new()),
666 None => (
667 agent.clone(),
668 vec![format!(
669 "agent '{agent}' does not appear in the blueprint's flow; using the agent \
670 name as a fallback projection name"
671 )],
672 ),
673 },
674 Err(e) => (
675 agent.clone(),
676 vec![format!("StepNaming::from_blueprint failed: {e}")],
677 ),
678 };
679
680 let output = ExplainOutput {
681 projection_name,
682 naming_warnings,
683 parts_note: "if the worker stages named artifact parts, the step OUTPUT changes shape \
684 to {\"out\", \"parts\"}; reference via $.<step>.out"
685 .to_string(),
686 };
687
688 Ok(Json(ExplainAgentResponse {
689 blueprint: ExplainBlueprintRef { id, version },
690 agent: ExplainAgentRef {
691 name: agent_def.name.clone(),
692 kind: agent_def.kind.clone(),
693 },
694 worker_binding,
695 binding_note,
696 runner,
697 declared_tools,
698 system_prompt,
699 effective_ctx,
700 output,
701 }))
702}
703
704#[derive(Debug, Serialize)]
712struct WorkerBindingSummary {
713 variant: String,
715}
716
717#[derive(Debug, Serialize)]
724struct AgentSummary {
725 name: String,
727 kind: String,
730 worker_binding: Option<WorkerBindingSummary>,
734 declared_tools_count: usize,
736 system_prompt_bytes: usize,
739 effective_ctx_key_count: usize,
742 projection_name: String,
748}
749
750#[derive(Debug, Serialize)]
752struct BatchExplainAgentsResponse {
753 blueprint: ExplainBlueprintRef,
755 agents: Vec<AgentSummary>,
757}
758
759async fn explain_agents_batch(
772 State(state): State<BlueprintsState>,
773 Path(id): Path<String>,
774) -> Result<Json<BatchExplainAgentsResponse>, (StatusCode, String)> {
775 let store = state.store;
776 let bp_id = BlueprintId::new(id.clone());
777 let traced = store
778 .read_head(&bp_id)
779 .await
780 .map_err(|e| (StatusCode::NOT_FOUND, format!("read_head: {e}")))?;
781 let bp = traced.value;
782 let version = format!("{:?}", traced.trace.version);
783
784 let naming = StepNaming::from_blueprint(&bp)
789 .ok()
790 .map(|(naming, _)| naming);
791
792 let agents = bp
793 .agents
794 .iter()
795 .map(|agent_def| {
796 let profile = agent_def.profile.as_ref();
797 let worker_binding = profile
798 .and_then(|p| p.worker_binding.as_ref())
799 .map(|variant| WorkerBindingSummary {
800 variant: variant.clone(),
801 });
802 let declared_tools_count = profile.map(|p| p.tools.len()).unwrap_or(0);
803 let system_prompt_bytes = profile.map(|p| p.system_prompt.len()).unwrap_or(0);
804 let effective_ctx_key_count = explain_agent_ctx(&bp, &agent_def.name)
805 .map(|keys| keys.len())
806 .unwrap_or(0);
807 let projection_name = naming
808 .as_ref()
809 .and_then(|naming| naming.canonical_of_producer(&agent_def.name))
810 .map(|canonical| canonical.to_string())
811 .unwrap_or_else(|| agent_def.name.clone());
812 AgentSummary {
813 name: agent_def.name.clone(),
814 kind: format!("{:?}", agent_def.kind),
815 worker_binding,
816 declared_tools_count,
817 system_prompt_bytes,
818 effective_ctx_key_count,
819 projection_name,
820 }
821 })
822 .collect();
823
824 Ok(Json(BatchExplainAgentsResponse {
825 blueprint: ExplainBlueprintRef { id, version },
826 agents,
827 }))
828}
829
830#[cfg(test)]
831mod explain_agent_tests {
832 use super::*;
833 use mlua_swarm::blueprint::store::InMemoryBlueprintStore;
834 use mlua_swarm::blueprint::{
835 current_schema_version, AgentDef, AgentMeta, AgentProfile, BlueprintMetadata,
836 CompilerHints, CompilerStrategy,
837 };
838 use serde_json::json;
839
840 fn agent_def(name: &str, profile: Option<AgentProfile>, meta: Option<AgentMeta>) -> AgentDef {
841 AgentDef {
842 name: name.to_string(),
843 kind: AgentKind::RustFn,
844 spec: json!({ "fn_id": name }),
845 profile,
846 meta,
847 runner: None,
848 runner_ref: None,
849 verdict: None,
850 }
851 }
852
853 fn single_step_bp(
857 bp_id: &str,
858 agent_name: &str,
859 profile: Option<AgentProfile>,
860 meta: Option<AgentMeta>,
861 default_agent_ctx: Option<serde_json::Value>,
862 ) -> Blueprint {
863 Blueprint {
864 schema_version: current_schema_version(),
865 id: bp_id.into(),
866 flow: serde_json::from_value(json!({
867 "kind": "step",
868 "ref": agent_name,
869 "in": {"op": "path", "at": "$.input"},
870 "out": {"op": "path", "at": "$.out"},
871 }))
872 .expect("flow parse"),
873 agents: vec![agent_def(agent_name, profile, meta)],
874 operators: vec![],
875 metas: vec![],
876 hints: CompilerHints::default(),
877 strategy: CompilerStrategy::default(),
878 metadata: BlueprintMetadata::default(),
879 spawner_hints: Default::default(),
880 default_agent_kind: AgentKind::Operator,
881 default_operator_kind: None,
882 default_init_ctx: None,
883 default_agent_ctx,
884 default_context_policy: None,
885 projection_placement: None,
886 audits: vec![],
887 degradation_policy: None,
888 runners: vec![],
889 default_runner: None,
890 }
891 }
892
893 async fn seed(store: &InMemoryBlueprintStore, bp: &Blueprint) {
894 let bp_id = BlueprintId::new(bp.id.as_str());
895 let v = blueprint_version(bp).expect("version");
896 store
897 .write_new(&bp_id, bp, &[], CommitMetadata::seed(bp_id.clone(), v, 0))
898 .await
899 .expect("write_new");
900 }
901
902 fn state_with(store: InMemoryBlueprintStore) -> BlueprintsState {
903 BlueprintsState {
904 store: Arc::new(store),
905 ref_base: None,
906 cli_default_agent_kind: None,
907 }
908 }
909
910 #[tokio::test]
911 async fn full_case_reports_binding_ctx_override_and_system_prompt() {
912 let profile = AgentProfile {
913 system_prompt: "Hello {{ name }}, mode={{ mode }}".to_string(),
914 tools: vec!["Read".to_string(), "Grep".to_string()],
915 worker_binding: Some("mse-worker-knowledge".to_string()),
916 ..Default::default()
917 };
918 let meta = AgentMeta {
919 ctx: Some(json!({ "work_dir": "/inline" })),
920 ..Default::default()
921 };
922 let bp = single_step_bp(
923 "explain-full-bp",
924 "researcher",
925 Some(profile),
926 Some(meta),
927 Some(json!({ "work_dir": "/bp-global", "extra": "kept" })),
928 );
929 let store = InMemoryBlueprintStore::new();
930 seed(&store, &bp).await;
931
932 let resp = explain_agent(
933 State(state_with(store)),
934 Path(("explain-full-bp".to_string(), "researcher".to_string())),
935 )
936 .await
937 .expect("explain_agent")
938 .0;
939
940 assert_eq!(resp.blueprint.id, "explain-full-bp");
941 assert!(!resp.blueprint.version.is_empty());
942 assert_eq!(resp.agent.name, "researcher");
943 assert_eq!(resp.agent.kind, AgentKind::RustFn);
944
945 let binding = resp.worker_binding.expect("worker_binding present");
946 assert_eq!(binding.variant, "mse-worker-knowledge");
947 assert!(resp.binding_note.is_none());
948
949 assert_eq!(
950 resp.declared_tools.tools,
951 vec!["Read".to_string(), "Grep".to_string()]
952 );
953 assert!(resp.declared_tools.informational);
954
955 let sp = resp.system_prompt.expect("system_prompt present");
956 assert_eq!(sp.bytes, "Hello {{ name }}, mode={{ mode }}".len());
957 assert_eq!(sp.lines, 1);
958 assert_eq!(
959 sp.template_variables,
960 vec!["mode".to_string(), "name".to_string()]
961 );
962 assert!(sp.template_syntax_error.is_none());
963
964 assert_eq!(resp.effective_ctx.keys["work_dir"].value, json!("/inline"));
965 assert_eq!(
966 resp.effective_ctx.keys["work_dir"].winning_tier,
967 "agent_inline"
968 );
969 assert_eq!(resp.effective_ctx.keys["extra"].value, json!("kept"));
970 assert_eq!(resp.effective_ctx.keys["extra"].winning_tier, "bp_global");
971
972 assert_eq!(resp.output.projection_name, "researcher");
973 assert!(resp.output.naming_warnings.is_empty());
974 }
975
976 #[tokio::test]
977 async fn agent_without_worker_binding_reports_binding_note() {
978 let profile = AgentProfile {
979 tools: vec!["Read".to_string()],
980 ..Default::default()
981 };
982 let bp = single_step_bp("explain-no-binding-bp", "scout", Some(profile), None, None);
983 let store = InMemoryBlueprintStore::new();
984 seed(&store, &bp).await;
985
986 let resp = explain_agent(
987 State(state_with(store)),
988 Path(("explain-no-binding-bp".to_string(), "scout".to_string())),
989 )
990 .await
991 .expect("explain_agent")
992 .0;
993
994 assert!(resp.worker_binding.is_none());
995 let note = resp.binding_note.expect("binding_note present");
996 assert!(note.contains("no worker_binding declared"));
997 assert!(resp.system_prompt.is_none());
998 }
999
1000 #[tokio::test]
1001 async fn unknown_agent_name_returns_404_with_available_list() {
1002 let bp = single_step_bp("explain-404-agent-bp", "foo", None, None, None);
1003 let store = InMemoryBlueprintStore::new();
1004 seed(&store, &bp).await;
1005
1006 let err = explain_agent(
1007 State(state_with(store)),
1008 Path((
1009 "explain-404-agent-bp".to_string(),
1010 "no-such-agent".to_string(),
1011 )),
1012 )
1013 .await
1014 .expect_err("expected 404");
1015
1016 assert_eq!(err.0, StatusCode::NOT_FOUND);
1017 let body: serde_json::Value = serde_json::from_str(&err.1).expect("json body");
1018 assert_eq!(body["error"], "agent not found in blueprint");
1019 assert_eq!(body["agent"], "no-such-agent");
1020 assert_eq!(body["available"], json!(["foo"]));
1021 }
1022
1023 #[tokio::test]
1024 async fn unknown_blueprint_id_returns_404_same_as_get_head() {
1025 let store = InMemoryBlueprintStore::new();
1026
1027 let err = explain_agent(
1028 State(state_with(store)),
1029 Path(("no-such-bp".to_string(), "any-agent".to_string())),
1030 )
1031 .await
1032 .expect_err("expected 404");
1033
1034 assert_eq!(err.0, StatusCode::NOT_FOUND);
1035 }
1036
1037 #[tokio::test]
1038 async fn template_syntax_error_is_reported_without_500() {
1039 let profile = AgentProfile {
1040 system_prompt: "hello {{ unclosed".to_string(),
1041 ..Default::default()
1042 };
1043 let bp = single_step_bp(
1044 "explain-syntax-error-bp",
1045 "scout",
1046 Some(profile),
1047 None,
1048 None,
1049 );
1050 let store = InMemoryBlueprintStore::new();
1051 seed(&store, &bp).await;
1052
1053 let resp = explain_agent(
1054 State(state_with(store)),
1055 Path(("explain-syntax-error-bp".to_string(), "scout".to_string())),
1056 )
1057 .await
1058 .expect("explain_agent")
1059 .0;
1060
1061 let sp = resp.system_prompt.expect("system_prompt present");
1062 assert!(sp.template_variables.is_empty());
1063 assert!(sp.template_syntax_error.is_some());
1064 }
1065
1066 #[tokio::test]
1069 async fn runner_resolves_from_legacy_worker_binding_when_nothing_else_declared() {
1070 let profile = AgentProfile {
1071 worker_binding: Some("mse-worker-knowledge".to_string()),
1072 tools: vec!["Read".to_string()],
1073 ..Default::default()
1074 };
1075 let bp = single_step_bp(
1076 "explain-runner-legacy-bp",
1077 "scout",
1078 Some(profile),
1079 None,
1080 None,
1081 );
1082 let store = InMemoryBlueprintStore::new();
1083 seed(&store, &bp).await;
1084
1085 let resp = explain_agent(
1086 State(state_with(store)),
1087 Path(("explain-runner-legacy-bp".to_string(), "scout".to_string())),
1088 )
1089 .await
1090 .expect("explain_agent")
1091 .0;
1092
1093 assert_eq!(
1094 resp.runner.resolved,
1095 Some(mlua_swarm_schema::Runner::WsClaudeCode {
1096 variant: "mse-worker-knowledge".to_string(),
1097 tools: vec!["Read".to_string()],
1098 })
1099 );
1100 assert!(resp.runner.error.is_none());
1101 assert!(resp.runner.warning.is_none());
1102 }
1103
1104 #[tokio::test]
1105 async fn runner_reports_unresolved_runner_ref_as_error_level_finding() {
1106 let mut bp = single_step_bp("explain-runner-unresolved-bp", "scout", None, None, None);
1107 bp.agents[0].runner_ref = Some("no-such-entry".to_string());
1108 let store = InMemoryBlueprintStore::new();
1109 seed(&store, &bp).await;
1110
1111 let resp = explain_agent(
1112 State(state_with(store)),
1113 Path((
1114 "explain-runner-unresolved-bp".to_string(),
1115 "scout".to_string(),
1116 )),
1117 )
1118 .await
1119 .expect("explain_agent")
1120 .0;
1121
1122 assert!(resp.runner.resolved.is_none());
1123 let error = resp.runner.error.expect("error-level finding present");
1124 assert!(
1125 error.contains("no-such-entry"),
1126 "error must name the unresolved runner_ref: {error}"
1127 );
1128 assert!(resp.runner.warning.is_none());
1129 }
1130
1131 #[tokio::test]
1132 async fn runner_reports_backend_kind_mismatch_as_warn_level_finding() {
1133 let mut bp = single_step_bp("explain-runner-mismatch-bp", "scout", None, None, None);
1137 bp.runners = vec![mlua_swarm_schema::RunnerDef {
1138 name: "in-process".to_string(),
1139 runner: mlua_swarm_schema::Runner::AgentBlockInProcess {
1140 tools: vec!["Bash".to_string()],
1141 },
1142 }];
1143 bp.agents[0].runner_ref = Some("in-process".to_string());
1144 let store = InMemoryBlueprintStore::new();
1145 seed(&store, &bp).await;
1146
1147 let resp = explain_agent(
1148 State(state_with(store)),
1149 Path((
1150 "explain-runner-mismatch-bp".to_string(),
1151 "scout".to_string(),
1152 )),
1153 )
1154 .await
1155 .expect("explain_agent")
1156 .0;
1157
1158 assert!(resp.runner.resolved.is_some());
1159 assert!(resp.runner.error.is_none());
1160 let warning = resp.runner.warning.expect("warn-level finding present");
1161 assert!(
1162 warning.contains("AgentBlockInProcess") && warning.contains("RustFn"),
1163 "warning must name both the resolved backend and the mismatched kind: {warning}"
1164 );
1165 }
1166
1167 fn batch_bp() -> Blueprint {
1173 let bound_profile = AgentProfile {
1174 system_prompt: "hello world".to_string(),
1175 tools: vec!["Read".to_string(), "Grep".to_string()],
1176 worker_binding: Some("mse-worker-knowledge".to_string()),
1177 ..Default::default()
1178 };
1179 let bound_meta = AgentMeta {
1180 ctx: Some(json!({ "work_dir": "/inline" })),
1181 ..Default::default()
1182 };
1183 Blueprint {
1184 schema_version: current_schema_version(),
1185 id: "explain-batch-bp".into(),
1186 flow: serde_json::from_value(json!({
1187 "kind": "step",
1188 "ref": "bound_agent",
1189 "in": {"op": "path", "at": "$.input"},
1190 "out": {"op": "path", "at": "$.out"},
1191 }))
1192 .expect("flow parse"),
1193 agents: vec![
1194 agent_def("bound_agent", Some(bound_profile), Some(bound_meta)),
1195 agent_def("unbound_agent", None, None),
1196 agent_def("orphan_agent", None, None),
1197 ],
1198 operators: vec![],
1199 metas: vec![],
1200 hints: CompilerHints::default(),
1201 strategy: CompilerStrategy::default(),
1202 metadata: BlueprintMetadata::default(),
1203 spawner_hints: Default::default(),
1204 default_agent_kind: AgentKind::Operator,
1205 default_operator_kind: None,
1206 default_init_ctx: None,
1207 default_agent_ctx: Some(json!({ "work_dir": "/bp-global", "extra": "kept" })),
1208 default_context_policy: None,
1209 projection_placement: None,
1210 audits: vec![],
1211 degradation_policy: None,
1212 runners: vec![],
1213 default_runner: None,
1214 }
1215 }
1216
1217 #[tokio::test]
1218 async fn explain_agents_batch_reports_a_summary_row_per_agent() {
1219 let bp = batch_bp();
1220 let store = InMemoryBlueprintStore::new();
1221 seed(&store, &bp).await;
1222
1223 let resp = explain_agents_batch(
1224 State(state_with(store)),
1225 Path("explain-batch-bp".to_string()),
1226 )
1227 .await
1228 .expect("explain_agents_batch")
1229 .0;
1230
1231 assert_eq!(resp.blueprint.id, "explain-batch-bp");
1232 assert!(!resp.blueprint.version.is_empty());
1233 assert_eq!(resp.agents.len(), 3);
1234
1235 let bound = resp
1236 .agents
1237 .iter()
1238 .find(|a| a.name == "bound_agent")
1239 .expect("bound_agent row");
1240 assert_eq!(bound.kind, format!("{:?}", AgentKind::RustFn));
1241 let binding = bound
1242 .worker_binding
1243 .as_ref()
1244 .expect("worker_binding present");
1245 assert_eq!(binding.variant, "mse-worker-knowledge");
1246 assert_eq!(bound.declared_tools_count, 2);
1247 assert_eq!(bound.system_prompt_bytes, "hello world".len());
1248 assert_eq!(bound.effective_ctx_key_count, 2);
1250 assert_eq!(bound.projection_name, "bound_agent");
1252
1253 let unbound = resp
1254 .agents
1255 .iter()
1256 .find(|a| a.name == "unbound_agent")
1257 .expect("unbound_agent row");
1258 assert!(unbound.worker_binding.is_none());
1259 assert_eq!(unbound.declared_tools_count, 0);
1260 assert_eq!(unbound.system_prompt_bytes, 0);
1261 assert_eq!(unbound.effective_ctx_key_count, 2);
1263 assert_eq!(unbound.projection_name, "unbound_agent");
1265
1266 let orphan = resp
1267 .agents
1268 .iter()
1269 .find(|a| a.name == "orphan_agent")
1270 .expect("orphan_agent row");
1271 assert_eq!(orphan.projection_name, "orphan_agent");
1272 }
1273
1274 #[tokio::test]
1275 async fn explain_agents_batch_zero_agents_returns_empty_list_not_404() {
1276 let bp = Blueprint {
1277 schema_version: current_schema_version(),
1278 id: "explain-batch-empty-bp".into(),
1279 flow: serde_json::from_value(json!({
1280 "kind": "step",
1281 "ref": "unused",
1282 "in": {"op": "path", "at": "$.input"},
1283 "out": {"op": "path", "at": "$.out"},
1284 }))
1285 .expect("flow parse"),
1286 agents: vec![],
1287 operators: vec![],
1288 metas: vec![],
1289 hints: CompilerHints::default(),
1290 strategy: CompilerStrategy::default(),
1291 metadata: BlueprintMetadata::default(),
1292 spawner_hints: Default::default(),
1293 default_agent_kind: AgentKind::Operator,
1294 default_operator_kind: None,
1295 default_init_ctx: None,
1296 default_agent_ctx: None,
1297 default_context_policy: None,
1298 projection_placement: None,
1299 audits: vec![],
1300 degradation_policy: None,
1301 runners: vec![],
1302 default_runner: None,
1303 };
1304 let store = InMemoryBlueprintStore::new();
1305 seed(&store, &bp).await;
1306
1307 let resp = explain_agents_batch(
1308 State(state_with(store)),
1309 Path("explain-batch-empty-bp".to_string()),
1310 )
1311 .await
1312 .expect("explain_agents_batch")
1313 .0;
1314
1315 assert!(resp.agents.is_empty());
1316 }
1317
1318 #[tokio::test]
1319 async fn explain_agents_batch_unknown_blueprint_id_returns_404_same_as_get_head() {
1320 let store = InMemoryBlueprintStore::new();
1321
1322 let err = explain_agents_batch(State(state_with(store)), Path("no-such-bp".to_string()))
1323 .await
1324 .expect_err("expected 404");
1325
1326 assert_eq!(err.0, StatusCode::NOT_FOUND);
1327 }
1328}