Skip to main content

mlua_swarm_server/
blueprints.rs

1//! HTTP surface for inspecting Blueprint state (= for debug / animation verification).
2//! `/v1/blueprints/:id/head` returns the head Blueprint JSON;
3//! `/v1/blueprints/:id/history` returns the commit-version list.
4//! Callers pass a shared `Store` via `Arc` and mount the router.
5
6use 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/// Router state: BP store + the base dir used to resolve `$file` / `$agent_md`
26/// refs + `default_agent_kind` from the CLI (= layer (2) of the 4-tier cascade —
27/// the CLI override layer).
28/// When `ref_base = None`, ref expansion is skipped (= seed bodies are parsed
29/// as raw JSON).
30#[derive(Clone)]
31pub struct BlueprintsState {
32    /// Backing Blueprint store (git2 or in-memory backend).
33    pub store: Arc<dyn BlueprintStore>,
34    /// Base dir for `$file` / `$agent_md` ref expansion; `None` skips expansion.
35    pub ref_base: Option<PathBuf>,
36    /// CLI-level `default_agent_kind` override (layer (2) of the 4-tier cascade).
37    pub cli_default_agent_kind: Option<AgentKind>,
38}
39
40/// Minimal entry: no `ref_base` (ref expansion skipped) and no CLI default kind override.
41pub fn build_blueprints_router(store: Arc<dyn BlueprintStore>) -> Router {
42    build_blueprints_router_with_refs(store, None, None)
43}
44
45/// When `ref_base` is set, `seed_blueprint` resolves `{"$file": ...}` /
46/// `{"$agent_md": ...}` refs in the body under that base dir and expands them.
47/// Path hygiene (absolute paths and `..` are rejected) is enforced inside
48/// `expand_file_refs`, sandboxed to the subtree under the base dir.
49///
50/// `cli_default_agent_kind` = the override from CLI `--default-agent-kind`
51/// (= layer (2) of the 4-tier cascade). Falls back when the BP JSON top-level
52/// `default_agent_kind` (= (3)) is absent; if that too is absent, uses the
53/// Schema `impl Default` = `Operator` (= (1)).
54pub 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
83/// `DELETE /v1/blueprints/:id` — archive (logical soft-delete) the id.
84/// Appends an archive marker commit; the underlying Blueprint YAML is
85/// preserved as history. After archive, `read_head` /
86/// `TaskApplication::resolve` reject with `Archived`, and `list_ids`
87/// filters the id out by default.
88///
89/// Semantic rename: the HTTP path stays `DELETE` for client
90/// compatibility, but the behavior is archive, not physical delete.
91/// Restore via `POST /v1/blueprints/:id/unarchive`.
92///
93/// Returns: 204 No Content.
94async 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
112/// `POST /v1/blueprints/:id/unarchive` — reverse of archive. Appends
113/// an unarchive marker commit so the audit trail records the event.
114async 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
136/// Format a Blueprint deserialization failure with a schema pointer, so a
137/// register error is self-serviceable (the schema export is the MCP adapter
138/// `bp_schema` tool = schemars JSON Schema of `Blueprint`).
139fn 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
146/// `POST /v1/blueprints/:id` — register / re-register a Blueprint.
147///
148/// Semantics:
149/// - No prior head → seed as first commit (`write_new`, empty
150///   parents). Returns 201.
151/// - Prior head with **same** `ContentHash` → idempotent no-op.
152///   Returns 200 with `seeded: false`.
153/// - Prior head with **different** `ContentHash` → append a new
154///   commit on top of the current head (Git-native commit graph
155///   advance). Returns 201.
156/// - Prior head archived → returns 409 `Archived` (call
157///   `POST /:id/unarchive` first).
158/// - Concurrent POST on the same id → per-id lock contention returns
159///   429 Too Many Requests (client retry).
160///
161/// Path id vs body.id mismatch returns 400.
162///
163/// When `BlueprintsState.ref_base = Some(dir)`, `{"$file": ...}` /
164/// `{"$agent_md": ...}` refs in the body are expanded under the base
165/// dir via `expand_file_refs` before being parsed into a typed
166/// `Blueprint` (= path hygiene is applied by the loader, rejecting
167/// absolute paths and `..`).
168async 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        // Four-tier cascade for the kind resolution: (3) BP JSON top-level
175        // `default_agent_kind` → (2) CLI value → (1) Schema impl Default =
176        // Operator. Handed to expand_file_refs so the loader can resolve the
177        // kind when the $agent_md sibling is missing. The sibling `"kind"`
178        // literal (tier 4) wins first inside expand_file_refs.
179        let default_kind = match pre_read_default_agent_kind(&raw_body) {
180            // BP top-level carries a literal → use it verbatim.
181            kind if raw_body.get("default_agent_kind").is_some() => kind,
182            // BP top-level absent → CLI value fallback → Schema default.
183            _ => 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    /// Content hash (= debug representation of `BlueprintVersion`).
297    hash: String,
298    /// SemVer label (`Blueprint.metadata.version_label`); `null` when unset.
299    version_label: Option<String>,
300    /// One-line changelog (= `CommitMetadata.rationale`).
301    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// ──────────────────────────────────────────────────────────────────────────
345// GET /v1/blueprints/:id/agents/:agent/explain
346// ──────────────────────────────────────────────────────────────────────────
347
348/// `blueprint` field of [`ExplainAgentResponse`]: which Blueprint this
349/// explain view was resolved against.
350#[derive(Debug, Serialize)]
351struct ExplainBlueprintRef {
352    /// Blueprint id (echoed back from the path param).
353    id: String,
354    /// Head commit version (`Trace.version`, debug-formatted — same
355    /// convention as [`HeadResponse::version`]).
356    version: String,
357}
358
359/// `agent` field of [`ExplainAgentResponse`]: the resolved agent's
360/// identity, verbatim from the Blueprint's `AgentDef`.
361#[derive(Debug, Serialize)]
362struct ExplainAgentRef {
363    /// Agent name (= `AgentDef.name`, echoed back from the path param).
364    name: String,
365    /// Worker IMPL kind (= `AgentDef.kind`).
366    kind: AgentKind,
367}
368
369/// `worker_binding` field of [`ExplainAgentResponse`] when the agent
370/// declares one. Mirrors `mlua_swarm::operator::WorkerBinding::variant`;
371/// its `tools` half is reported separately under `declared_tools`, so it
372/// is not duplicated here.
373#[derive(Debug, Serialize)]
374struct ExplainWorkerBinding {
375    /// Worker variant name (`AgentDef.profile.worker_binding`).
376    variant: String,
377}
378
379/// `declared_tools` field of [`ExplainAgentResponse`].
380#[derive(Debug, Serialize)]
381struct ExplainDeclaredTools {
382    /// `AgentDef.profile.tools`, verbatim (`[]` when `profile` is absent).
383    tools: Vec<String>,
384    /// Always `true` — see [`Self::note`].
385    informational: bool,
386    /// Explains why `tools` does not grant anything by itself.
387    note: String,
388}
389
390/// `system_prompt` field of [`ExplainAgentResponse`], present when
391/// `AgentDef.profile.system_prompt` is non-empty.
392#[derive(Debug, Serialize)]
393struct ExplainSystemPrompt {
394    /// UTF-8 byte length of the raw (unrendered) template.
395    bytes: usize,
396    /// Line count of the raw template (`str::lines` count).
397    lines: usize,
398    /// Variables `mlua_swarm::operator::render::template_variables`
399    /// reports the template requires. Empty when
400    /// [`Self::template_syntax_error`] is `Some`.
401    template_variables: Vec<String>,
402    /// `Some(message)` when the template failed to parse; `None`
403    /// otherwise.
404    template_syntax_error: Option<String>,
405    /// Explains the non-`Object` `initial_directive` binding rule.
406    note: String,
407}
408
409/// One key's entry in [`ExplainEffectiveCtx::keys`].
410#[derive(Debug, Serialize)]
411struct ExplainCtxKeyEntry {
412    /// The value this key resolves to (the winning tier's value).
413    value: serde_json::Value,
414    /// Which static tier supplied [`Self::value`] — one of
415    /// `"agent_inline"` / `"meta_ref"` / `"bp_global"`.
416    winning_tier: String,
417}
418
419/// `effective_ctx` field of [`ExplainAgentResponse`]: the static 3-tier
420/// cascade resolution `mlua_swarm::core::explain::explain_agent_ctx`
421/// computes (byte-identical to the runtime merge — see that function's
422/// doc for why this reuses rather than reimplements the merge).
423#[derive(Debug, Serialize)]
424struct ExplainEffectiveCtx {
425    /// Per-key winner table.
426    keys: BTreeMap<String, ExplainCtxKeyEntry>,
427    /// Explains that Run/Task/Step runtime tiers are out of scope here.
428    note: String,
429}
430
431/// `output` field of [`ExplainAgentResponse`].
432#[derive(Debug, Serialize)]
433struct ExplainOutput {
434    /// The canonical step-projection name
435    /// (`StepNaming::canonical_of_producer`), or the agent name itself as
436    /// a fallback — see [`Self::naming_warnings`].
437    projection_name: String,
438    /// Non-empty when [`Self::projection_name`] fell back to the agent
439    /// name, or `StepNaming::from_blueprint` itself failed (explain is a
440    /// diagnostic view, so neither case 500s — see [`explain_agent`]'s
441    /// doc).
442    naming_warnings: Vec<String>,
443    /// Explains the `{"out","parts"}` OUTPUT shape change for parts
444    /// staging.
445    parts_note: String,
446}
447
448/// Response body for `GET /v1/blueprints/:id/agents/:agent/explain`.
449#[derive(Debug, Serialize)]
450struct ExplainAgentResponse {
451    /// Which Blueprint this view was resolved against.
452    blueprint: ExplainBlueprintRef,
453    /// The resolved agent's identity.
454    agent: ExplainAgentRef,
455    /// The Blueprint-baked worker binding, if declared.
456    worker_binding: Option<ExplainWorkerBinding>,
457    /// `Some(reason)` when [`Self::worker_binding`] is `None`.
458    binding_note: Option<String>,
459    /// The agent's declared (informational-only) tool list.
460    declared_tools: ExplainDeclaredTools,
461    /// The rendered-template diagnostics, when `profile.system_prompt` is
462    /// non-empty.
463    system_prompt: Option<ExplainSystemPrompt>,
464    /// The static ctx cascade resolution.
465    effective_ctx: ExplainEffectiveCtx,
466    /// The step-projection naming resolution.
467    output: ExplainOutput,
468}
469
470/// Maps a static [`CtxTier`] to the wire label
471/// [`ExplainCtxKeyEntry::winning_tier`] reports.
472fn 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
480/// Builds [`ExplainSystemPrompt`] from a non-empty `profile.system_prompt`
481/// template.
482fn 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
499/// `GET /v1/blueprints/:id/agents/:agent/explain` — read-only, dry-run
500/// visualization of how `agent`'s Blueprint definition materializes into
501/// its runtime worker contract (see `workspace/tasks/explain-agent/issue.md`
502/// for the full design rationale). Same unauthenticated trust tier as
503/// [`get_head`] (an operator-diagnostic route; no engine state is touched
504/// — every value here is resolved statically from the head Blueprint
505/// alone).
506///
507/// 404s when the Blueprint id itself is not found (same error mapping as
508/// [`get_head`]) or when `agent` is not a name in `bp.agents` (JSON body:
509/// `{"error", "agent", "available"}`). A `StepNaming::from_blueprint`
510/// failure does not 500 — `output.projection_name` falls back to the
511/// agent name and the failure is reported via `output.naming_warnings`
512/// (this endpoint is a diagnostic view, not a compile gate).
513async 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// ──────────────────────────────────────────────────────────────────────────
630// GET /v1/blueprints/:id/agents/explain (batch summary)
631// ──────────────────────────────────────────────────────────────────────────
632
633/// `worker_binding` field of [`AgentSummary`] — same shape as
634/// [`ExplainWorkerBinding`] (kept as a distinct type so the batch response
635/// schema doesn't couple to the single-agent view's naming).
636#[derive(Debug, Serialize)]
637struct WorkerBindingSummary {
638    /// Worker variant name (`AgentDef.profile.worker_binding`).
639    variant: String,
640}
641
642/// One row of [`BatchExplainAgentsResponse::agents`] — a summary, not the
643/// full [`ExplainAgentResponse`] detail: a whole-Blueprint sweep response
644/// must stay small, so this reports counts/presence rather than the raw
645/// `declared_tools` list or the rendered `system_prompt` template. Drill
646/// down via `GET /v1/blueprints/:id/agents/:agent/explain` for the full
647/// per-agent view.
648#[derive(Debug, Serialize)]
649struct AgentSummary {
650    /// Agent name (`AgentDef.name`).
651    name: String,
652    /// Worker IMPL kind (`AgentDef.kind`, debug-formatted — same
653    /// convention as the `bp_doctor` MCP tool's per-agent `kind` field).
654    kind: String,
655    /// The Blueprint-baked worker binding, if declared. `null` (not
656    /// omitted) when absent — the caller needs to see every agent,
657    /// bound or not.
658    worker_binding: Option<WorkerBindingSummary>,
659    /// `AgentDef.profile.tools.len()`; `0` when `profile` is absent.
660    declared_tools_count: usize,
661    /// UTF-8 byte length of `profile.system_prompt`; `0` when `profile`
662    /// is absent or the template is empty.
663    system_prompt_bytes: usize,
664    /// Number of keys `explain_agent_ctx` resolves for this agent (the
665    /// static 3-tier cascade); `0` when the agent has no static ctx.
666    effective_ctx_key_count: usize,
667    /// The canonical step-projection name
668    /// (`StepNaming::canonical_of_producer`), falling back to the agent
669    /// name on a naming miss — same fail-soft convention as
670    /// [`ExplainOutput::projection_name`], but without a
671    /// `naming_warnings` companion (this is a summary row).
672    projection_name: String,
673}
674
675/// Response body for `GET /v1/blueprints/:id/agents/explain`.
676#[derive(Debug, Serialize)]
677struct BatchExplainAgentsResponse {
678    /// Which Blueprint this sweep was resolved against.
679    blueprint: ExplainBlueprintRef,
680    /// One row per `bp.agents` entry, in Blueprint order.
681    agents: Vec<AgentSummary>,
682}
683
684/// `GET /v1/blueprints/:id/agents/explain` — batch summary sweep across
685/// every agent in the Blueprint. Same read-only, dry-run, unauthenticated
686/// trust tier as [`explain_agent`] / [`get_head`] — nothing here is
687/// resolved beyond the head Blueprint.
688///
689/// 404s only when the Blueprint id itself is not found (same error
690/// mapping as [`get_head`]); a Blueprint with zero agents returns
691/// `agents: []`, not 404 (there is no per-agent path segment to fail to
692/// resolve here). `StepNaming::from_blueprint` failing does not 500 —
693/// every row's `projection_name` falls back to the agent name, mirroring
694/// [`explain_agent`]'s per-agent fail-soft convention (this batch view
695/// just has no `naming_warnings` companion field to report it through).
696async 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    // Resolved once for the whole Blueprint (StepNaming::from_blueprint is
710    // a whole-BP operation, not per-agent); a failure fails soft the same
711    // way explain_agent's per-agent lookup does — every row below falls
712    // back to the agent name via `.unwrap_or_else`.
713    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    /// A single-step Blueprint whose sole Step dispatches `agent_name` —
776    /// enough for `StepNaming::from_blueprint` to resolve a real (non-
777    /// fallback) `canonical_of_producer` entry.
778    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    // ─── GH #47: batch summary sweep (explain_agents_batch) ────────────
987
988    /// A 3-agent Blueprint whose flow only dispatches `bound_agent` — the
989    /// other two are unreferenced by the flow, so `StepNaming` misses them
990    /// (fail-soft fallback to the agent name is exercised for both).
991    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        // work_dir (agent_inline override) + extra (bp-global carry) = 2 keys.
1066        assert_eq!(bound.effective_ctx_key_count, 2);
1067        // Referenced by the flow -> a real (non-fallback) canonical name.
1068        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        // Only the bp-global tier applies (no agent-level meta) = 2 keys.
1079        assert_eq!(unbound.effective_ctx_key_count, 2);
1080        // Not referenced by the flow -> StepNaming miss -> fallback to name.
1081        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}