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 check_policy: None,
891 }
892 }
893
894 async fn seed(store: &InMemoryBlueprintStore, bp: &Blueprint) {
895 let bp_id = BlueprintId::new(bp.id.as_str());
896 let v = blueprint_version(bp).expect("version");
897 store
898 .write_new(&bp_id, bp, &[], CommitMetadata::seed(bp_id.clone(), v, 0))
899 .await
900 .expect("write_new");
901 }
902
903 fn state_with(store: InMemoryBlueprintStore) -> BlueprintsState {
904 BlueprintsState {
905 store: Arc::new(store),
906 ref_base: None,
907 cli_default_agent_kind: None,
908 }
909 }
910
911 #[tokio::test]
912 async fn full_case_reports_binding_ctx_override_and_system_prompt() {
913 let profile = AgentProfile {
914 system_prompt: "Hello {{ name }}, mode={{ mode }}".to_string(),
915 tools: vec!["Read".to_string(), "Grep".to_string()],
916 worker_binding: Some("mse-worker-knowledge".to_string()),
917 ..Default::default()
918 };
919 let meta = AgentMeta {
920 ctx: Some(json!({ "work_dir": "/inline" })),
921 ..Default::default()
922 };
923 let bp = single_step_bp(
924 "explain-full-bp",
925 "researcher",
926 Some(profile),
927 Some(meta),
928 Some(json!({ "work_dir": "/bp-global", "extra": "kept" })),
929 );
930 let store = InMemoryBlueprintStore::new();
931 seed(&store, &bp).await;
932
933 let resp = explain_agent(
934 State(state_with(store)),
935 Path(("explain-full-bp".to_string(), "researcher".to_string())),
936 )
937 .await
938 .expect("explain_agent")
939 .0;
940
941 assert_eq!(resp.blueprint.id, "explain-full-bp");
942 assert!(!resp.blueprint.version.is_empty());
943 assert_eq!(resp.agent.name, "researcher");
944 assert_eq!(resp.agent.kind, AgentKind::RustFn);
945
946 let binding = resp.worker_binding.expect("worker_binding present");
947 assert_eq!(binding.variant, "mse-worker-knowledge");
948 assert!(resp.binding_note.is_none());
949
950 assert_eq!(
951 resp.declared_tools.tools,
952 vec!["Read".to_string(), "Grep".to_string()]
953 );
954 assert!(resp.declared_tools.informational);
955
956 let sp = resp.system_prompt.expect("system_prompt present");
957 assert_eq!(sp.bytes, "Hello {{ name }}, mode={{ mode }}".len());
958 assert_eq!(sp.lines, 1);
959 assert_eq!(
960 sp.template_variables,
961 vec!["mode".to_string(), "name".to_string()]
962 );
963 assert!(sp.template_syntax_error.is_none());
964
965 assert_eq!(resp.effective_ctx.keys["work_dir"].value, json!("/inline"));
966 assert_eq!(
967 resp.effective_ctx.keys["work_dir"].winning_tier,
968 "agent_inline"
969 );
970 assert_eq!(resp.effective_ctx.keys["extra"].value, json!("kept"));
971 assert_eq!(resp.effective_ctx.keys["extra"].winning_tier, "bp_global");
972
973 assert_eq!(resp.output.projection_name, "researcher");
974 assert!(resp.output.naming_warnings.is_empty());
975 }
976
977 #[tokio::test]
978 async fn agent_without_worker_binding_reports_binding_note() {
979 let profile = AgentProfile {
980 tools: vec!["Read".to_string()],
981 ..Default::default()
982 };
983 let bp = single_step_bp("explain-no-binding-bp", "scout", Some(profile), None, None);
984 let store = InMemoryBlueprintStore::new();
985 seed(&store, &bp).await;
986
987 let resp = explain_agent(
988 State(state_with(store)),
989 Path(("explain-no-binding-bp".to_string(), "scout".to_string())),
990 )
991 .await
992 .expect("explain_agent")
993 .0;
994
995 assert!(resp.worker_binding.is_none());
996 let note = resp.binding_note.expect("binding_note present");
997 assert!(note.contains("no worker_binding declared"));
998 assert!(resp.system_prompt.is_none());
999 }
1000
1001 #[tokio::test]
1002 async fn unknown_agent_name_returns_404_with_available_list() {
1003 let bp = single_step_bp("explain-404-agent-bp", "foo", None, None, None);
1004 let store = InMemoryBlueprintStore::new();
1005 seed(&store, &bp).await;
1006
1007 let err = explain_agent(
1008 State(state_with(store)),
1009 Path((
1010 "explain-404-agent-bp".to_string(),
1011 "no-such-agent".to_string(),
1012 )),
1013 )
1014 .await
1015 .expect_err("expected 404");
1016
1017 assert_eq!(err.0, StatusCode::NOT_FOUND);
1018 let body: serde_json::Value = serde_json::from_str(&err.1).expect("json body");
1019 assert_eq!(body["error"], "agent not found in blueprint");
1020 assert_eq!(body["agent"], "no-such-agent");
1021 assert_eq!(body["available"], json!(["foo"]));
1022 }
1023
1024 #[tokio::test]
1025 async fn unknown_blueprint_id_returns_404_same_as_get_head() {
1026 let store = InMemoryBlueprintStore::new();
1027
1028 let err = explain_agent(
1029 State(state_with(store)),
1030 Path(("no-such-bp".to_string(), "any-agent".to_string())),
1031 )
1032 .await
1033 .expect_err("expected 404");
1034
1035 assert_eq!(err.0, StatusCode::NOT_FOUND);
1036 }
1037
1038 #[tokio::test]
1039 async fn template_syntax_error_is_reported_without_500() {
1040 let profile = AgentProfile {
1041 system_prompt: "hello {{ unclosed".to_string(),
1042 ..Default::default()
1043 };
1044 let bp = single_step_bp(
1045 "explain-syntax-error-bp",
1046 "scout",
1047 Some(profile),
1048 None,
1049 None,
1050 );
1051 let store = InMemoryBlueprintStore::new();
1052 seed(&store, &bp).await;
1053
1054 let resp = explain_agent(
1055 State(state_with(store)),
1056 Path(("explain-syntax-error-bp".to_string(), "scout".to_string())),
1057 )
1058 .await
1059 .expect("explain_agent")
1060 .0;
1061
1062 let sp = resp.system_prompt.expect("system_prompt present");
1063 assert!(sp.template_variables.is_empty());
1064 assert!(sp.template_syntax_error.is_some());
1065 }
1066
1067 #[tokio::test]
1070 async fn runner_resolves_from_legacy_worker_binding_when_nothing_else_declared() {
1071 let profile = AgentProfile {
1072 worker_binding: Some("mse-worker-knowledge".to_string()),
1073 tools: vec!["Read".to_string()],
1074 ..Default::default()
1075 };
1076 let bp = single_step_bp(
1077 "explain-runner-legacy-bp",
1078 "scout",
1079 Some(profile),
1080 None,
1081 None,
1082 );
1083 let store = InMemoryBlueprintStore::new();
1084 seed(&store, &bp).await;
1085
1086 let resp = explain_agent(
1087 State(state_with(store)),
1088 Path(("explain-runner-legacy-bp".to_string(), "scout".to_string())),
1089 )
1090 .await
1091 .expect("explain_agent")
1092 .0;
1093
1094 assert_eq!(
1095 resp.runner.resolved,
1096 Some(mlua_swarm_schema::Runner::WsClaudeCode {
1097 variant: "mse-worker-knowledge".to_string(),
1098 tools: vec!["Read".to_string()],
1099 })
1100 );
1101 assert!(resp.runner.error.is_none());
1102 assert!(resp.runner.warning.is_none());
1103 }
1104
1105 #[tokio::test]
1106 async fn runner_reports_unresolved_runner_ref_as_error_level_finding() {
1107 let mut bp = single_step_bp("explain-runner-unresolved-bp", "scout", None, None, None);
1108 bp.agents[0].runner_ref = Some("no-such-entry".to_string());
1109 let store = InMemoryBlueprintStore::new();
1110 seed(&store, &bp).await;
1111
1112 let resp = explain_agent(
1113 State(state_with(store)),
1114 Path((
1115 "explain-runner-unresolved-bp".to_string(),
1116 "scout".to_string(),
1117 )),
1118 )
1119 .await
1120 .expect("explain_agent")
1121 .0;
1122
1123 assert!(resp.runner.resolved.is_none());
1124 let error = resp.runner.error.expect("error-level finding present");
1125 assert!(
1126 error.contains("no-such-entry"),
1127 "error must name the unresolved runner_ref: {error}"
1128 );
1129 assert!(resp.runner.warning.is_none());
1130 }
1131
1132 #[tokio::test]
1133 async fn runner_reports_backend_kind_mismatch_as_warn_level_finding() {
1134 let mut bp = single_step_bp("explain-runner-mismatch-bp", "scout", None, None, None);
1138 bp.runners = vec![mlua_swarm_schema::RunnerDef {
1139 name: "in-process".to_string(),
1140 runner: mlua_swarm_schema::Runner::AgentBlockInProcess {
1141 tools: vec!["Bash".to_string()],
1142 },
1143 }];
1144 bp.agents[0].runner_ref = Some("in-process".to_string());
1145 let store = InMemoryBlueprintStore::new();
1146 seed(&store, &bp).await;
1147
1148 let resp = explain_agent(
1149 State(state_with(store)),
1150 Path((
1151 "explain-runner-mismatch-bp".to_string(),
1152 "scout".to_string(),
1153 )),
1154 )
1155 .await
1156 .expect("explain_agent")
1157 .0;
1158
1159 assert!(resp.runner.resolved.is_some());
1160 assert!(resp.runner.error.is_none());
1161 let warning = resp.runner.warning.expect("warn-level finding present");
1162 assert!(
1163 warning.contains("AgentBlockInProcess") && warning.contains("RustFn"),
1164 "warning must name both the resolved backend and the mismatched kind: {warning}"
1165 );
1166 }
1167
1168 fn batch_bp() -> Blueprint {
1174 let bound_profile = AgentProfile {
1175 system_prompt: "hello world".to_string(),
1176 tools: vec!["Read".to_string(), "Grep".to_string()],
1177 worker_binding: Some("mse-worker-knowledge".to_string()),
1178 ..Default::default()
1179 };
1180 let bound_meta = AgentMeta {
1181 ctx: Some(json!({ "work_dir": "/inline" })),
1182 ..Default::default()
1183 };
1184 Blueprint {
1185 schema_version: current_schema_version(),
1186 id: "explain-batch-bp".into(),
1187 flow: serde_json::from_value(json!({
1188 "kind": "step",
1189 "ref": "bound_agent",
1190 "in": {"op": "path", "at": "$.input"},
1191 "out": {"op": "path", "at": "$.out"},
1192 }))
1193 .expect("flow parse"),
1194 agents: vec![
1195 agent_def("bound_agent", Some(bound_profile), Some(bound_meta)),
1196 agent_def("unbound_agent", None, None),
1197 agent_def("orphan_agent", None, None),
1198 ],
1199 operators: vec![],
1200 metas: vec![],
1201 hints: CompilerHints::default(),
1202 strategy: CompilerStrategy::default(),
1203 metadata: BlueprintMetadata::default(),
1204 spawner_hints: Default::default(),
1205 default_agent_kind: AgentKind::Operator,
1206 default_operator_kind: None,
1207 default_init_ctx: None,
1208 default_agent_ctx: Some(json!({ "work_dir": "/bp-global", "extra": "kept" })),
1209 default_context_policy: None,
1210 projection_placement: None,
1211 audits: vec![],
1212 degradation_policy: None,
1213 runners: vec![],
1214 default_runner: None,
1215 check_policy: None,
1216 }
1217 }
1218
1219 #[tokio::test]
1220 async fn explain_agents_batch_reports_a_summary_row_per_agent() {
1221 let bp = batch_bp();
1222 let store = InMemoryBlueprintStore::new();
1223 seed(&store, &bp).await;
1224
1225 let resp = explain_agents_batch(
1226 State(state_with(store)),
1227 Path("explain-batch-bp".to_string()),
1228 )
1229 .await
1230 .expect("explain_agents_batch")
1231 .0;
1232
1233 assert_eq!(resp.blueprint.id, "explain-batch-bp");
1234 assert!(!resp.blueprint.version.is_empty());
1235 assert_eq!(resp.agents.len(), 3);
1236
1237 let bound = resp
1238 .agents
1239 .iter()
1240 .find(|a| a.name == "bound_agent")
1241 .expect("bound_agent row");
1242 assert_eq!(bound.kind, format!("{:?}", AgentKind::RustFn));
1243 let binding = bound
1244 .worker_binding
1245 .as_ref()
1246 .expect("worker_binding present");
1247 assert_eq!(binding.variant, "mse-worker-knowledge");
1248 assert_eq!(bound.declared_tools_count, 2);
1249 assert_eq!(bound.system_prompt_bytes, "hello world".len());
1250 assert_eq!(bound.effective_ctx_key_count, 2);
1252 assert_eq!(bound.projection_name, "bound_agent");
1254
1255 let unbound = resp
1256 .agents
1257 .iter()
1258 .find(|a| a.name == "unbound_agent")
1259 .expect("unbound_agent row");
1260 assert!(unbound.worker_binding.is_none());
1261 assert_eq!(unbound.declared_tools_count, 0);
1262 assert_eq!(unbound.system_prompt_bytes, 0);
1263 assert_eq!(unbound.effective_ctx_key_count, 2);
1265 assert_eq!(unbound.projection_name, "unbound_agent");
1267
1268 let orphan = resp
1269 .agents
1270 .iter()
1271 .find(|a| a.name == "orphan_agent")
1272 .expect("orphan_agent row");
1273 assert_eq!(orphan.projection_name, "orphan_agent");
1274 }
1275
1276 #[tokio::test]
1277 async fn explain_agents_batch_zero_agents_returns_empty_list_not_404() {
1278 let bp = Blueprint {
1279 schema_version: current_schema_version(),
1280 id: "explain-batch-empty-bp".into(),
1281 flow: serde_json::from_value(json!({
1282 "kind": "step",
1283 "ref": "unused",
1284 "in": {"op": "path", "at": "$.input"},
1285 "out": {"op": "path", "at": "$.out"},
1286 }))
1287 .expect("flow parse"),
1288 agents: vec![],
1289 operators: vec![],
1290 metas: vec![],
1291 hints: CompilerHints::default(),
1292 strategy: CompilerStrategy::default(),
1293 metadata: BlueprintMetadata::default(),
1294 spawner_hints: Default::default(),
1295 default_agent_kind: AgentKind::Operator,
1296 default_operator_kind: None,
1297 default_init_ctx: None,
1298 default_agent_ctx: None,
1299 default_context_policy: None,
1300 projection_placement: None,
1301 audits: vec![],
1302 degradation_policy: None,
1303 runners: vec![],
1304 default_runner: None,
1305 check_policy: None,
1306 };
1307 let store = InMemoryBlueprintStore::new();
1308 seed(&store, &bp).await;
1309
1310 let resp = explain_agents_batch(
1311 State(state_with(store)),
1312 Path("explain-batch-empty-bp".to_string()),
1313 )
1314 .await
1315 .expect("explain_agents_batch")
1316 .0;
1317
1318 assert!(resp.agents.is_empty());
1319 }
1320
1321 #[tokio::test]
1322 async fn explain_agents_batch_unknown_blueprint_id_returns_404_same_as_get_head() {
1323 let store = InMemoryBlueprintStore::new();
1324
1325 let err = explain_agents_batch(State(state_with(store)), Path("no-such-bp".to_string()))
1326 .await
1327 .expect_err("expected 404");
1328
1329 assert_eq!(err.0, StatusCode::NOT_FOUND);
1330 }
1331}