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 serde::{Deserialize, Serialize};
21use std::collections::BTreeMap;
22use std::path::PathBuf;
23use std::sync::Arc;
24
25#[derive(Clone)]
31pub struct BlueprintsState {
32 pub store: Arc<dyn BlueprintStore>,
34 pub ref_base: Option<PathBuf>,
36 pub cli_default_agent_kind: Option<AgentKind>,
38}
39
40pub fn build_blueprints_router(store: Arc<dyn BlueprintStore>) -> Router {
42 build_blueprints_router_with_refs(store, None, None)
43}
44
45pub fn build_blueprints_router_with_refs(
55 store: Arc<dyn BlueprintStore>,
56 ref_base: Option<PathBuf>,
57 cli_default_agent_kind: Option<AgentKind>,
58) -> Router {
59 let state = BlueprintsState {
60 store,
61 ref_base,
62 cli_default_agent_kind,
63 };
64 Router::new()
65 .route("/v1/blueprints/:id/head", get(get_head))
66 .route("/v1/blueprints/:id/history", get(get_history))
67 .route(
68 "/v1/blueprints/:id/agents/:agent/explain",
69 get(explain_agent),
70 )
71 .route(
72 "/v1/blueprints/:id/agents/explain",
73 get(explain_agents_batch),
74 )
75 .route("/v1/blueprints/:id/unarchive", post(unarchive_blueprint))
76 .route(
77 "/v1/blueprints/:id",
78 post(seed_blueprint).delete(archive_blueprint),
79 )
80 .with_state(state)
81}
82
83async fn archive_blueprint(
95 State(state): State<BlueprintsState>,
96 Path(id): Path<String>,
97) -> Result<StatusCode, (StatusCode, String)> {
98 let bp_id = BlueprintId::new(id.clone());
99 state.store.archive_id(&bp_id).await.map_err(|e| match e {
100 mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)
101 | mlua_swarm::blueprint::store::BlueprintStoreError::IdNotFound(_) => {
102 (StatusCode::NOT_FOUND, format!("archive_id: {e}"))
103 }
104 other => (
105 StatusCode::INTERNAL_SERVER_ERROR,
106 format!("archive_id: {other}"),
107 ),
108 })?;
109 Ok(StatusCode::NO_CONTENT)
110}
111
112async fn unarchive_blueprint(
115 State(state): State<BlueprintsState>,
116 Path(id): Path<String>,
117) -> Result<StatusCode, (StatusCode, String)> {
118 let bp_id = BlueprintId::new(id.clone());
119 state
120 .store
121 .unarchive_id(&bp_id)
122 .await
123 .map_err(|e| match e {
124 mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)
125 | mlua_swarm::blueprint::store::BlueprintStoreError::IdNotFound(_) => {
126 (StatusCode::NOT_FOUND, format!("unarchive_id: {e}"))
127 }
128 other => (
129 StatusCode::INTERNAL_SERVER_ERROR,
130 format!("unarchive_id: {other}"),
131 ),
132 })?;
133 Ok(StatusCode::NO_CONTENT)
134}
135
136fn parse_error_with_schema_hint(e: &serde_json::Error) -> String {
140 format!(
141 "blueprint parse: {e} \
142 (hint: fetch the Blueprint JSON Schema via the MCP adapter bp_schema tool)"
143 )
144}
145
146async fn seed_blueprint(
169 State(state): State<BlueprintsState>,
170 Path(id): Path<String>,
171 Json(raw_body): Json<serde_json::Value>,
172) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, String)> {
173 let body: Blueprint = if let Some(base) = state.ref_base.as_ref() {
174 let default_kind = match pre_read_default_agent_kind(&raw_body) {
180 kind if raw_body.get("default_agent_kind").is_some() => kind,
182 _ => state
184 .cli_default_agent_kind
185 .clone()
186 .unwrap_or_else(default_global_agent_kind),
187 };
188 let expanded = expand_file_refs(raw_body, base, default_kind)
189 .map_err(|e| (StatusCode::BAD_REQUEST, format!("ref expand: {e}")))?;
190 serde_json::from_value(expanded)
191 .map_err(|e| (StatusCode::BAD_REQUEST, parse_error_with_schema_hint(&e)))?
192 } else {
193 serde_json::from_value(raw_body)
194 .map_err(|e| (StatusCode::BAD_REQUEST, parse_error_with_schema_hint(&e)))?
195 };
196 let store = state.store;
197 if id != body.id.as_str() {
198 return Err((
199 StatusCode::BAD_REQUEST,
200 format!("path id={id} != body.id={}", body.id),
201 ));
202 }
203 let bp_id = BlueprintId::new(id.clone());
204 let v = blueprint_version(&body).map_err(|e| {
205 (
206 StatusCode::INTERNAL_SERVER_ERROR,
207 format!("bp version: {e}"),
208 )
209 })?;
210 let prev_head = match store.read_head(&bp_id).await {
211 Ok(traced) => Some(traced),
212 Err(mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)) => None,
213 Err(mlua_swarm::blueprint::store::BlueprintStoreError::Archived(_)) => {
214 return Err((
215 StatusCode::CONFLICT,
216 format!("blueprint {id} is archived; POST /v1/blueprints/{id}/unarchive first"),
217 ));
218 }
219 Err(e) => {
220 return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("read_head: {e}")));
221 }
222 };
223 if let Some(traced) = &prev_head {
224 if traced.trace.version == v {
225 return Ok((
226 StatusCode::OK,
227 Json(serde_json::json!({"id": id, "version": format!("{:?}", v), "seeded": false})),
228 ));
229 }
230 }
231 let parents: Vec<_> = prev_head
232 .as_ref()
233 .map(|t| vec![t.trace.version])
234 .unwrap_or_default();
235 let now_ms = std::time::SystemTime::now()
236 .duration_since(std::time::UNIX_EPOCH)
237 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
238 .as_millis() as i64;
239 let meta = CommitMetadata::seed(bp_id.clone(), v, now_ms);
240 store
241 .write_new(&bp_id, &body, &parents, meta)
242 .await
243 .map_err(|e| match &e {
244 mlua_swarm::blueprint::store::BlueprintStoreError::LockBusy => (
245 StatusCode::TOO_MANY_REQUESTS,
246 format!("blueprint {id} lock busy; retry"),
247 ),
248 mlua_swarm::blueprint::store::BlueprintStoreError::Archived(_) => (
249 StatusCode::CONFLICT,
250 format!("blueprint {id} is archived; POST /v1/blueprints/{id}/unarchive first"),
251 ),
252 _ => (StatusCode::INTERNAL_SERVER_ERROR, format!("write_new: {e}")),
253 })?;
254 Ok((
255 StatusCode::CREATED,
256 Json(serde_json::json!({"id": id, "version": format!("{:?}", v), "seeded": true})),
257 ))
258}
259
260#[derive(Debug, Serialize)]
261struct HeadResponse {
262 id: String,
263 version: String,
264 blueprint: Blueprint,
265}
266
267async fn get_head(
268 State(state): State<BlueprintsState>,
269 Path(id): Path<String>,
270) -> Result<Json<HeadResponse>, (StatusCode, String)> {
271 let store = state.store;
272 let bp_id = BlueprintId::new(id.clone());
273 let traced = store
274 .read_head(&bp_id)
275 .await
276 .map_err(|e| (StatusCode::NOT_FOUND, format!("read_head: {e}")))?;
277 Ok(Json(HeadResponse {
278 id,
279 version: format!("{:?}", traced.trace.version),
280 blueprint: traced.value,
281 }))
282}
283
284#[derive(Debug, Deserialize)]
285struct HistoryQuery {
286 #[serde(default = "default_limit")]
287 limit: usize,
288}
289
290fn default_limit() -> usize {
291 20
292}
293
294#[derive(Debug, Serialize)]
295struct HistoryEntry {
296 hash: String,
298 version_label: Option<String>,
300 rationale: String,
302}
303
304#[derive(Debug, Serialize)]
305struct HistoryResponse {
306 count: usize,
307 entries: Vec<HistoryEntry>,
308}
309
310async fn get_history(
311 State(state): State<BlueprintsState>,
312 Path(id): Path<String>,
313 Query(q): Query<HistoryQuery>,
314) -> Result<Json<HistoryResponse>, (StatusCode, String)> {
315 let store = state.store;
316 let bp_id = BlueprintId::new(id);
317 let versions = store
318 .history(&bp_id, q.limit)
319 .await
320 .map_err(|e| (StatusCode::NOT_FOUND, format!("history: {e}")))?;
321 let mut entries = Vec::with_capacity(versions.len());
322 for v in versions {
323 let traced = store.read_version(&bp_id, v).await.map_err(|e| {
324 (
325 StatusCode::INTERNAL_SERVER_ERROR,
326 format!("read_version: {e}"),
327 )
328 })?;
329 let rationale = store
330 .read_commit_rationale(&bp_id, v)
331 .await
332 .unwrap_or(None)
333 .unwrap_or_default();
334 entries.push(HistoryEntry {
335 hash: format!("{:?}", v),
336 version_label: traced.value.metadata.version_label.clone(),
337 rationale,
338 });
339 }
340 let count = entries.len();
341 Ok(Json(HistoryResponse { count, entries }))
342}
343
344#[derive(Debug, Serialize)]
351struct ExplainBlueprintRef {
352 id: String,
354 version: String,
357}
358
359#[derive(Debug, Serialize)]
362struct ExplainAgentRef {
363 name: String,
365 kind: AgentKind,
367}
368
369#[derive(Debug, Serialize)]
374struct ExplainWorkerBinding {
375 variant: String,
377}
378
379#[derive(Debug, Serialize)]
381struct ExplainDeclaredTools {
382 tools: Vec<String>,
384 informational: bool,
386 note: String,
388}
389
390#[derive(Debug, Serialize)]
393struct ExplainSystemPrompt {
394 bytes: usize,
396 lines: usize,
398 template_variables: Vec<String>,
402 template_syntax_error: Option<String>,
405 note: String,
407}
408
409#[derive(Debug, Serialize)]
411struct ExplainCtxKeyEntry {
412 value: serde_json::Value,
414 winning_tier: String,
417}
418
419#[derive(Debug, Serialize)]
424struct ExplainEffectiveCtx {
425 keys: BTreeMap<String, ExplainCtxKeyEntry>,
427 note: String,
429}
430
431#[derive(Debug, Serialize)]
433struct ExplainOutput {
434 projection_name: String,
438 naming_warnings: Vec<String>,
443 parts_note: String,
446}
447
448#[derive(Debug, Serialize)]
450struct ExplainAgentResponse {
451 blueprint: ExplainBlueprintRef,
453 agent: ExplainAgentRef,
455 worker_binding: Option<ExplainWorkerBinding>,
457 binding_note: Option<String>,
459 declared_tools: ExplainDeclaredTools,
461 system_prompt: Option<ExplainSystemPrompt>,
464 effective_ctx: ExplainEffectiveCtx,
466 output: ExplainOutput,
468}
469
470fn ctx_tier_label(tier: CtxTier) -> &'static str {
473 match tier {
474 CtxTier::AgentInline => "agent_inline",
475 CtxTier::MetaRef => "meta_ref",
476 CtxTier::BpGlobal => "bp_global",
477 }
478}
479
480fn explain_system_prompt(template: &str) -> ExplainSystemPrompt {
483 let (variables, template_syntax_error): (Vec<String>, Option<String>) =
484 match template_variables(template) {
485 Ok(vars) => (vars.into_iter().collect(), None),
486 Err(e) => (Vec::new(), Some(e.to_string())),
487 };
488 ExplainSystemPrompt {
489 bytes: template.len(),
490 lines: template.lines().count(),
491 template_variables: variables,
492 template_syntax_error,
493 note: "when the step directive is not a JSON object, only `value` is bound at render \
494 time"
495 .to_string(),
496 }
497}
498
499async fn explain_agent(
514 State(state): State<BlueprintsState>,
515 Path((id, agent)): Path<(String, String)>,
516) -> Result<Json<ExplainAgentResponse>, (StatusCode, String)> {
517 let store = state.store;
518 let bp_id = BlueprintId::new(id.clone());
519 let traced = store
520 .read_head(&bp_id)
521 .await
522 .map_err(|e| (StatusCode::NOT_FOUND, format!("read_head: {e}")))?;
523 let bp = traced.value;
524 let version = format!("{:?}", traced.trace.version);
525
526 let Some(agent_def) = bp.agents.iter().find(|ad| ad.name == agent) else {
527 let available: Vec<&str> = bp.agents.iter().map(|ad| ad.name.as_str()).collect();
528 return Err((
529 StatusCode::NOT_FOUND,
530 serde_json::json!({
531 "error": "agent not found in blueprint",
532 "agent": agent,
533 "available": available,
534 })
535 .to_string(),
536 ));
537 };
538
539 let profile = agent_def.profile.as_ref();
540
541 let (worker_binding, binding_note) = match profile.and_then(|p| p.worker_binding.as_ref()) {
542 Some(variant) => (
543 Some(ExplainWorkerBinding {
544 variant: variant.clone(),
545 }),
546 None,
547 ),
548 None => (
549 None,
550 Some(
551 "no worker_binding declared; WS operator dispatch will fail at compile \
552 (InvalidSpec)"
553 .to_string(),
554 ),
555 ),
556 };
557
558 let declared_tools = ExplainDeclaredTools {
559 tools: profile.map(|p| p.tools.clone()).unwrap_or_default(),
560 informational: true,
561 note: "declared tools do not grant anything; the effective tool surface is the worker \
562 wrapper's frontmatter (see operator.rs WorkerBinding doc)"
563 .to_string(),
564 };
565
566 let system_prompt = profile
567 .filter(|p| !p.system_prompt.is_empty())
568 .map(|p| explain_system_prompt(&p.system_prompt));
569
570 let ctx_keys = explain_agent_ctx(&bp, &agent).unwrap_or_default();
571 let effective_ctx = ExplainEffectiveCtx {
572 keys: ctx_keys
573 .into_iter()
574 .map(|(k, resolution)| {
575 (
576 k,
577 ExplainCtxKeyEntry {
578 value: resolution.value,
579 winning_tier: ctx_tier_label(resolution.winning_tier).to_string(),
580 },
581 )
582 })
583 .collect(),
584 note: "static tiers only; Run/Task/Step runtime tiers always win over these \
585 (only-if-absent insertion order)"
586 .to_string(),
587 };
588
589 let (projection_name, naming_warnings) = match StepNaming::from_blueprint(&bp) {
590 Ok((naming, _soft_warnings)) => match naming.canonical_of_producer(&agent) {
591 Some(canonical) => (canonical.to_string(), Vec::new()),
592 None => (
593 agent.clone(),
594 vec![format!(
595 "agent '{agent}' does not appear in the blueprint's flow; using the agent \
596 name as a fallback projection name"
597 )],
598 ),
599 },
600 Err(e) => (
601 agent.clone(),
602 vec![format!("StepNaming::from_blueprint failed: {e}")],
603 ),
604 };
605
606 let output = ExplainOutput {
607 projection_name,
608 naming_warnings,
609 parts_note: "if the worker stages named artifact parts, the step OUTPUT changes shape \
610 to {\"out\", \"parts\"}; reference via $.<step>.out"
611 .to_string(),
612 };
613
614 Ok(Json(ExplainAgentResponse {
615 blueprint: ExplainBlueprintRef { id, version },
616 agent: ExplainAgentRef {
617 name: agent_def.name.clone(),
618 kind: agent_def.kind.clone(),
619 },
620 worker_binding,
621 binding_note,
622 declared_tools,
623 system_prompt,
624 effective_ctx,
625 output,
626 }))
627}
628
629#[derive(Debug, Serialize)]
637struct WorkerBindingSummary {
638 variant: String,
640}
641
642#[derive(Debug, Serialize)]
649struct AgentSummary {
650 name: String,
652 kind: String,
655 worker_binding: Option<WorkerBindingSummary>,
659 declared_tools_count: usize,
661 system_prompt_bytes: usize,
664 effective_ctx_key_count: usize,
667 projection_name: String,
673}
674
675#[derive(Debug, Serialize)]
677struct BatchExplainAgentsResponse {
678 blueprint: ExplainBlueprintRef,
680 agents: Vec<AgentSummary>,
682}
683
684async fn explain_agents_batch(
697 State(state): State<BlueprintsState>,
698 Path(id): Path<String>,
699) -> Result<Json<BatchExplainAgentsResponse>, (StatusCode, String)> {
700 let store = state.store;
701 let bp_id = BlueprintId::new(id.clone());
702 let traced = store
703 .read_head(&bp_id)
704 .await
705 .map_err(|e| (StatusCode::NOT_FOUND, format!("read_head: {e}")))?;
706 let bp = traced.value;
707 let version = format!("{:?}", traced.trace.version);
708
709 let naming = StepNaming::from_blueprint(&bp)
714 .ok()
715 .map(|(naming, _)| naming);
716
717 let agents = bp
718 .agents
719 .iter()
720 .map(|agent_def| {
721 let profile = agent_def.profile.as_ref();
722 let worker_binding = profile
723 .and_then(|p| p.worker_binding.as_ref())
724 .map(|variant| WorkerBindingSummary {
725 variant: variant.clone(),
726 });
727 let declared_tools_count = profile.map(|p| p.tools.len()).unwrap_or(0);
728 let system_prompt_bytes = profile.map(|p| p.system_prompt.len()).unwrap_or(0);
729 let effective_ctx_key_count = explain_agent_ctx(&bp, &agent_def.name)
730 .map(|keys| keys.len())
731 .unwrap_or(0);
732 let projection_name = naming
733 .as_ref()
734 .and_then(|naming| naming.canonical_of_producer(&agent_def.name))
735 .map(|canonical| canonical.to_string())
736 .unwrap_or_else(|| agent_def.name.clone());
737 AgentSummary {
738 name: agent_def.name.clone(),
739 kind: format!("{:?}", agent_def.kind),
740 worker_binding,
741 declared_tools_count,
742 system_prompt_bytes,
743 effective_ctx_key_count,
744 projection_name,
745 }
746 })
747 .collect();
748
749 Ok(Json(BatchExplainAgentsResponse {
750 blueprint: ExplainBlueprintRef { id, version },
751 agents,
752 }))
753}
754
755#[cfg(test)]
756mod explain_agent_tests {
757 use super::*;
758 use mlua_swarm::blueprint::store::InMemoryBlueprintStore;
759 use mlua_swarm::blueprint::{
760 current_schema_version, AgentDef, AgentMeta, AgentProfile, BlueprintMetadata,
761 CompilerHints, CompilerStrategy,
762 };
763 use serde_json::json;
764
765 fn agent_def(name: &str, profile: Option<AgentProfile>, meta: Option<AgentMeta>) -> AgentDef {
766 AgentDef {
767 name: name.to_string(),
768 kind: AgentKind::RustFn,
769 spec: json!({ "fn_id": name }),
770 profile,
771 meta,
772 }
773 }
774
775 fn single_step_bp(
779 bp_id: &str,
780 agent_name: &str,
781 profile: Option<AgentProfile>,
782 meta: Option<AgentMeta>,
783 default_agent_ctx: Option<serde_json::Value>,
784 ) -> Blueprint {
785 Blueprint {
786 schema_version: current_schema_version(),
787 id: bp_id.into(),
788 flow: serde_json::from_value(json!({
789 "kind": "step",
790 "ref": agent_name,
791 "in": {"op": "path", "at": "$.input"},
792 "out": {"op": "path", "at": "$.out"},
793 }))
794 .expect("flow parse"),
795 agents: vec![agent_def(agent_name, profile, meta)],
796 operators: vec![],
797 metas: vec![],
798 hints: CompilerHints::default(),
799 strategy: CompilerStrategy::default(),
800 metadata: BlueprintMetadata::default(),
801 spawner_hints: Default::default(),
802 default_agent_kind: AgentKind::Operator,
803 default_operator_kind: None,
804 default_init_ctx: None,
805 default_agent_ctx,
806 default_context_policy: None,
807 projection_placement: None,
808 audits: vec![],
809 degradation_policy: None,
810 }
811 }
812
813 async fn seed(store: &InMemoryBlueprintStore, bp: &Blueprint) {
814 let bp_id = BlueprintId::new(bp.id.as_str());
815 let v = blueprint_version(bp).expect("version");
816 store
817 .write_new(&bp_id, bp, &[], CommitMetadata::seed(bp_id.clone(), v, 0))
818 .await
819 .expect("write_new");
820 }
821
822 fn state_with(store: InMemoryBlueprintStore) -> BlueprintsState {
823 BlueprintsState {
824 store: Arc::new(store),
825 ref_base: None,
826 cli_default_agent_kind: None,
827 }
828 }
829
830 #[tokio::test]
831 async fn full_case_reports_binding_ctx_override_and_system_prompt() {
832 let profile = AgentProfile {
833 system_prompt: "Hello {{ name }}, mode={{ mode }}".to_string(),
834 tools: vec!["Read".to_string(), "Grep".to_string()],
835 worker_binding: Some("mse-worker-knowledge".to_string()),
836 ..Default::default()
837 };
838 let meta = AgentMeta {
839 ctx: Some(json!({ "work_dir": "/inline" })),
840 ..Default::default()
841 };
842 let bp = single_step_bp(
843 "explain-full-bp",
844 "researcher",
845 Some(profile),
846 Some(meta),
847 Some(json!({ "work_dir": "/bp-global", "extra": "kept" })),
848 );
849 let store = InMemoryBlueprintStore::new();
850 seed(&store, &bp).await;
851
852 let resp = explain_agent(
853 State(state_with(store)),
854 Path(("explain-full-bp".to_string(), "researcher".to_string())),
855 )
856 .await
857 .expect("explain_agent")
858 .0;
859
860 assert_eq!(resp.blueprint.id, "explain-full-bp");
861 assert!(!resp.blueprint.version.is_empty());
862 assert_eq!(resp.agent.name, "researcher");
863 assert_eq!(resp.agent.kind, AgentKind::RustFn);
864
865 let binding = resp.worker_binding.expect("worker_binding present");
866 assert_eq!(binding.variant, "mse-worker-knowledge");
867 assert!(resp.binding_note.is_none());
868
869 assert_eq!(
870 resp.declared_tools.tools,
871 vec!["Read".to_string(), "Grep".to_string()]
872 );
873 assert!(resp.declared_tools.informational);
874
875 let sp = resp.system_prompt.expect("system_prompt present");
876 assert_eq!(sp.bytes, "Hello {{ name }}, mode={{ mode }}".len());
877 assert_eq!(sp.lines, 1);
878 assert_eq!(
879 sp.template_variables,
880 vec!["mode".to_string(), "name".to_string()]
881 );
882 assert!(sp.template_syntax_error.is_none());
883
884 assert_eq!(resp.effective_ctx.keys["work_dir"].value, json!("/inline"));
885 assert_eq!(
886 resp.effective_ctx.keys["work_dir"].winning_tier,
887 "agent_inline"
888 );
889 assert_eq!(resp.effective_ctx.keys["extra"].value, json!("kept"));
890 assert_eq!(resp.effective_ctx.keys["extra"].winning_tier, "bp_global");
891
892 assert_eq!(resp.output.projection_name, "researcher");
893 assert!(resp.output.naming_warnings.is_empty());
894 }
895
896 #[tokio::test]
897 async fn agent_without_worker_binding_reports_binding_note() {
898 let profile = AgentProfile {
899 tools: vec!["Read".to_string()],
900 ..Default::default()
901 };
902 let bp = single_step_bp("explain-no-binding-bp", "scout", Some(profile), None, None);
903 let store = InMemoryBlueprintStore::new();
904 seed(&store, &bp).await;
905
906 let resp = explain_agent(
907 State(state_with(store)),
908 Path(("explain-no-binding-bp".to_string(), "scout".to_string())),
909 )
910 .await
911 .expect("explain_agent")
912 .0;
913
914 assert!(resp.worker_binding.is_none());
915 let note = resp.binding_note.expect("binding_note present");
916 assert!(note.contains("no worker_binding declared"));
917 assert!(resp.system_prompt.is_none());
918 }
919
920 #[tokio::test]
921 async fn unknown_agent_name_returns_404_with_available_list() {
922 let bp = single_step_bp("explain-404-agent-bp", "foo", None, None, None);
923 let store = InMemoryBlueprintStore::new();
924 seed(&store, &bp).await;
925
926 let err = explain_agent(
927 State(state_with(store)),
928 Path((
929 "explain-404-agent-bp".to_string(),
930 "no-such-agent".to_string(),
931 )),
932 )
933 .await
934 .expect_err("expected 404");
935
936 assert_eq!(err.0, StatusCode::NOT_FOUND);
937 let body: serde_json::Value = serde_json::from_str(&err.1).expect("json body");
938 assert_eq!(body["error"], "agent not found in blueprint");
939 assert_eq!(body["agent"], "no-such-agent");
940 assert_eq!(body["available"], json!(["foo"]));
941 }
942
943 #[tokio::test]
944 async fn unknown_blueprint_id_returns_404_same_as_get_head() {
945 let store = InMemoryBlueprintStore::new();
946
947 let err = explain_agent(
948 State(state_with(store)),
949 Path(("no-such-bp".to_string(), "any-agent".to_string())),
950 )
951 .await
952 .expect_err("expected 404");
953
954 assert_eq!(err.0, StatusCode::NOT_FOUND);
955 }
956
957 #[tokio::test]
958 async fn template_syntax_error_is_reported_without_500() {
959 let profile = AgentProfile {
960 system_prompt: "hello {{ unclosed".to_string(),
961 ..Default::default()
962 };
963 let bp = single_step_bp(
964 "explain-syntax-error-bp",
965 "scout",
966 Some(profile),
967 None,
968 None,
969 );
970 let store = InMemoryBlueprintStore::new();
971 seed(&store, &bp).await;
972
973 let resp = explain_agent(
974 State(state_with(store)),
975 Path(("explain-syntax-error-bp".to_string(), "scout".to_string())),
976 )
977 .await
978 .expect("explain_agent")
979 .0;
980
981 let sp = resp.system_prompt.expect("system_prompt present");
982 assert!(sp.template_variables.is_empty());
983 assert!(sp.template_syntax_error.is_some());
984 }
985
986 fn batch_bp() -> Blueprint {
992 let bound_profile = AgentProfile {
993 system_prompt: "hello world".to_string(),
994 tools: vec!["Read".to_string(), "Grep".to_string()],
995 worker_binding: Some("mse-worker-knowledge".to_string()),
996 ..Default::default()
997 };
998 let bound_meta = AgentMeta {
999 ctx: Some(json!({ "work_dir": "/inline" })),
1000 ..Default::default()
1001 };
1002 Blueprint {
1003 schema_version: current_schema_version(),
1004 id: "explain-batch-bp".into(),
1005 flow: serde_json::from_value(json!({
1006 "kind": "step",
1007 "ref": "bound_agent",
1008 "in": {"op": "path", "at": "$.input"},
1009 "out": {"op": "path", "at": "$.out"},
1010 }))
1011 .expect("flow parse"),
1012 agents: vec![
1013 agent_def("bound_agent", Some(bound_profile), Some(bound_meta)),
1014 agent_def("unbound_agent", None, None),
1015 agent_def("orphan_agent", None, None),
1016 ],
1017 operators: vec![],
1018 metas: vec![],
1019 hints: CompilerHints::default(),
1020 strategy: CompilerStrategy::default(),
1021 metadata: BlueprintMetadata::default(),
1022 spawner_hints: Default::default(),
1023 default_agent_kind: AgentKind::Operator,
1024 default_operator_kind: None,
1025 default_init_ctx: None,
1026 default_agent_ctx: Some(json!({ "work_dir": "/bp-global", "extra": "kept" })),
1027 default_context_policy: None,
1028 projection_placement: None,
1029 audits: vec![],
1030 degradation_policy: None,
1031 }
1032 }
1033
1034 #[tokio::test]
1035 async fn explain_agents_batch_reports_a_summary_row_per_agent() {
1036 let bp = batch_bp();
1037 let store = InMemoryBlueprintStore::new();
1038 seed(&store, &bp).await;
1039
1040 let resp = explain_agents_batch(
1041 State(state_with(store)),
1042 Path("explain-batch-bp".to_string()),
1043 )
1044 .await
1045 .expect("explain_agents_batch")
1046 .0;
1047
1048 assert_eq!(resp.blueprint.id, "explain-batch-bp");
1049 assert!(!resp.blueprint.version.is_empty());
1050 assert_eq!(resp.agents.len(), 3);
1051
1052 let bound = resp
1053 .agents
1054 .iter()
1055 .find(|a| a.name == "bound_agent")
1056 .expect("bound_agent row");
1057 assert_eq!(bound.kind, format!("{:?}", AgentKind::RustFn));
1058 let binding = bound
1059 .worker_binding
1060 .as_ref()
1061 .expect("worker_binding present");
1062 assert_eq!(binding.variant, "mse-worker-knowledge");
1063 assert_eq!(bound.declared_tools_count, 2);
1064 assert_eq!(bound.system_prompt_bytes, "hello world".len());
1065 assert_eq!(bound.effective_ctx_key_count, 2);
1067 assert_eq!(bound.projection_name, "bound_agent");
1069
1070 let unbound = resp
1071 .agents
1072 .iter()
1073 .find(|a| a.name == "unbound_agent")
1074 .expect("unbound_agent row");
1075 assert!(unbound.worker_binding.is_none());
1076 assert_eq!(unbound.declared_tools_count, 0);
1077 assert_eq!(unbound.system_prompt_bytes, 0);
1078 assert_eq!(unbound.effective_ctx_key_count, 2);
1080 assert_eq!(unbound.projection_name, "unbound_agent");
1082
1083 let orphan = resp
1084 .agents
1085 .iter()
1086 .find(|a| a.name == "orphan_agent")
1087 .expect("orphan_agent row");
1088 assert_eq!(orphan.projection_name, "orphan_agent");
1089 }
1090
1091 #[tokio::test]
1092 async fn explain_agents_batch_zero_agents_returns_empty_list_not_404() {
1093 let bp = Blueprint {
1094 schema_version: current_schema_version(),
1095 id: "explain-batch-empty-bp".into(),
1096 flow: serde_json::from_value(json!({
1097 "kind": "step",
1098 "ref": "unused",
1099 "in": {"op": "path", "at": "$.input"},
1100 "out": {"op": "path", "at": "$.out"},
1101 }))
1102 .expect("flow parse"),
1103 agents: vec![],
1104 operators: vec![],
1105 metas: vec![],
1106 hints: CompilerHints::default(),
1107 strategy: CompilerStrategy::default(),
1108 metadata: BlueprintMetadata::default(),
1109 spawner_hints: Default::default(),
1110 default_agent_kind: AgentKind::Operator,
1111 default_operator_kind: None,
1112 default_init_ctx: None,
1113 default_agent_ctx: None,
1114 default_context_policy: None,
1115 projection_placement: None,
1116 audits: vec![],
1117 degradation_policy: None,
1118 };
1119 let store = InMemoryBlueprintStore::new();
1120 seed(&store, &bp).await;
1121
1122 let resp = explain_agents_batch(
1123 State(state_with(store)),
1124 Path("explain-batch-empty-bp".to_string()),
1125 )
1126 .await
1127 .expect("explain_agents_batch")
1128 .0;
1129
1130 assert!(resp.agents.is_empty());
1131 }
1132
1133 #[tokio::test]
1134 async fn explain_agents_batch_unknown_blueprint_id_returns_404_same_as_get_head() {
1135 let store = InMemoryBlueprintStore::new();
1136
1137 let err = explain_agents_batch(State(state_with(store)), Path("no-such-bp".to_string()))
1138 .await
1139 .expect_err("expected 404");
1140
1141 assert_eq!(err.0, StatusCode::NOT_FOUND);
1142 }
1143}