use axum::{
extract::{Path, Query, State},
http::StatusCode,
routing::{get, post},
Json, Router,
};
use mlua_swarm::blueprint::loader::{expand_file_refs, pre_read_default_agent_kind};
use mlua_swarm::blueprint::store::{
blueprint_version, BlueprintId, BlueprintStore, CommitMetadata,
};
use mlua_swarm::blueprint::{default_global_agent_kind, AgentKind, Blueprint};
use mlua_swarm::core::explain::{explain_agent_ctx, CtxTier};
use mlua_swarm::core::step_naming::StepNaming;
use mlua_swarm::operator::render::template_variables;
use mlua_swarm_schema::{resolve_runner, Runner};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Clone)]
pub struct BlueprintsState {
pub store: Arc<dyn BlueprintStore>,
pub ref_base: Option<PathBuf>,
pub cli_default_agent_kind: Option<AgentKind>,
}
pub fn build_blueprints_router(store: Arc<dyn BlueprintStore>) -> Router {
build_blueprints_router_with_refs(store, None, None)
}
pub fn build_blueprints_router_with_refs(
store: Arc<dyn BlueprintStore>,
ref_base: Option<PathBuf>,
cli_default_agent_kind: Option<AgentKind>,
) -> Router {
let state = BlueprintsState {
store,
ref_base,
cli_default_agent_kind,
};
Router::new()
.route("/v1/blueprints/:id/head", get(get_head))
.route("/v1/blueprints/:id/history", get(get_history))
.route(
"/v1/blueprints/:id/agents/:agent/explain",
get(explain_agent),
)
.route(
"/v1/blueprints/:id/agents/explain",
get(explain_agents_batch),
)
.route("/v1/blueprints/:id/unarchive", post(unarchive_blueprint))
.route(
"/v1/blueprints/:id",
post(seed_blueprint).delete(archive_blueprint),
)
.with_state(state)
}
async fn archive_blueprint(
State(state): State<BlueprintsState>,
Path(id): Path<String>,
) -> Result<StatusCode, (StatusCode, String)> {
let bp_id = BlueprintId::new(id.clone());
state.store.archive_id(&bp_id).await.map_err(|e| match e {
mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)
| mlua_swarm::blueprint::store::BlueprintStoreError::IdNotFound(_) => {
(StatusCode::NOT_FOUND, format!("archive_id: {e}"))
}
other => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("archive_id: {other}"),
),
})?;
Ok(StatusCode::NO_CONTENT)
}
async fn unarchive_blueprint(
State(state): State<BlueprintsState>,
Path(id): Path<String>,
) -> Result<StatusCode, (StatusCode, String)> {
let bp_id = BlueprintId::new(id.clone());
state
.store
.unarchive_id(&bp_id)
.await
.map_err(|e| match e {
mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)
| mlua_swarm::blueprint::store::BlueprintStoreError::IdNotFound(_) => {
(StatusCode::NOT_FOUND, format!("unarchive_id: {e}"))
}
other => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("unarchive_id: {other}"),
),
})?;
Ok(StatusCode::NO_CONTENT)
}
fn parse_error_with_schema_hint(e: &serde_json::Error) -> String {
format!(
"blueprint parse: {e} \
(hint: fetch the Blueprint JSON Schema via the MCP adapter bp_schema tool)"
)
}
async fn seed_blueprint(
State(state): State<BlueprintsState>,
Path(id): Path<String>,
Json(raw_body): Json<serde_json::Value>,
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, String)> {
let body: Blueprint = if let Some(base) = state.ref_base.as_ref() {
let default_kind = match pre_read_default_agent_kind(&raw_body) {
kind if raw_body.get("default_agent_kind").is_some() => kind,
_ => state
.cli_default_agent_kind
.clone()
.unwrap_or_else(default_global_agent_kind),
};
let expanded = expand_file_refs(raw_body, base, default_kind)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("ref expand: {e}")))?;
serde_json::from_value(expanded)
.map_err(|e| (StatusCode::BAD_REQUEST, parse_error_with_schema_hint(&e)))?
} else {
serde_json::from_value(raw_body)
.map_err(|e| (StatusCode::BAD_REQUEST, parse_error_with_schema_hint(&e)))?
};
let store = state.store;
if id != body.id.as_str() {
return Err((
StatusCode::BAD_REQUEST,
format!("path id={id} != body.id={}", body.id),
));
}
let bp_id = BlueprintId::new(id.clone());
let v = blueprint_version(&body).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("bp version: {e}"),
)
})?;
let prev_head = match store.read_head(&bp_id).await {
Ok(traced) => Some(traced),
Err(mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)) => None,
Err(mlua_swarm::blueprint::store::BlueprintStoreError::Archived(_)) => {
return Err((
StatusCode::CONFLICT,
format!("blueprint {id} is archived; POST /v1/blueprints/{id}/unarchive first"),
));
}
Err(e) => {
return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("read_head: {e}")));
}
};
if let Some(traced) = &prev_head {
if traced.trace.version == v {
return Ok((
StatusCode::OK,
Json(serde_json::json!({"id": id, "version": format!("{:?}", v), "seeded": false})),
));
}
}
let parents: Vec<_> = prev_head
.as_ref()
.map(|t| vec![t.trace.version])
.unwrap_or_default();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.as_millis() as i64;
let meta = CommitMetadata::seed(bp_id.clone(), v, now_ms);
store
.write_new(&bp_id, &body, &parents, meta)
.await
.map_err(|e| match &e {
mlua_swarm::blueprint::store::BlueprintStoreError::LockBusy => (
StatusCode::TOO_MANY_REQUESTS,
format!("blueprint {id} lock busy; retry"),
),
mlua_swarm::blueprint::store::BlueprintStoreError::Archived(_) => (
StatusCode::CONFLICT,
format!("blueprint {id} is archived; POST /v1/blueprints/{id}/unarchive first"),
),
_ => (StatusCode::INTERNAL_SERVER_ERROR, format!("write_new: {e}")),
})?;
Ok((
StatusCode::CREATED,
Json(serde_json::json!({"id": id, "version": format!("{:?}", v), "seeded": true})),
))
}
#[derive(Debug, Serialize)]
struct HeadResponse {
id: String,
version: String,
blueprint: Blueprint,
}
async fn get_head(
State(state): State<BlueprintsState>,
Path(id): Path<String>,
) -> Result<Json<HeadResponse>, (StatusCode, String)> {
let store = state.store;
let bp_id = BlueprintId::new(id.clone());
let traced = store
.read_head(&bp_id)
.await
.map_err(|e| (StatusCode::NOT_FOUND, format!("read_head: {e}")))?;
Ok(Json(HeadResponse {
id,
version: format!("{:?}", traced.trace.version),
blueprint: traced.value,
}))
}
#[derive(Debug, Deserialize)]
struct HistoryQuery {
#[serde(default = "default_limit")]
limit: usize,
}
fn default_limit() -> usize {
20
}
#[derive(Debug, Serialize)]
struct HistoryEntry {
hash: String,
version_label: Option<String>,
rationale: String,
}
#[derive(Debug, Serialize)]
struct HistoryResponse {
count: usize,
entries: Vec<HistoryEntry>,
}
async fn get_history(
State(state): State<BlueprintsState>,
Path(id): Path<String>,
Query(q): Query<HistoryQuery>,
) -> Result<Json<HistoryResponse>, (StatusCode, String)> {
let store = state.store;
let bp_id = BlueprintId::new(id);
let versions = store
.history(&bp_id, q.limit)
.await
.map_err(|e| (StatusCode::NOT_FOUND, format!("history: {e}")))?;
let mut entries = Vec::with_capacity(versions.len());
for v in versions {
let traced = store.read_version(&bp_id, v).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("read_version: {e}"),
)
})?;
let rationale = store
.read_commit_rationale(&bp_id, v)
.await
.unwrap_or(None)
.unwrap_or_default();
entries.push(HistoryEntry {
hash: format!("{:?}", v),
version_label: traced.value.metadata.version_label.clone(),
rationale,
});
}
let count = entries.len();
Ok(Json(HistoryResponse { count, entries }))
}
#[derive(Debug, Serialize)]
struct ExplainBlueprintRef {
id: String,
version: String,
}
#[derive(Debug, Serialize)]
struct ExplainAgentRef {
name: String,
kind: AgentKind,
}
#[derive(Debug, Serialize)]
struct ExplainWorkerBinding {
variant: String,
}
#[derive(Debug, Serialize)]
struct ExplainDeclaredTools {
tools: Vec<String>,
informational: bool,
note: String,
}
#[derive(Debug, Serialize)]
struct ExplainSystemPrompt {
bytes: usize,
lines: usize,
template_variables: Vec<String>,
template_syntax_error: Option<String>,
note: String,
}
#[derive(Debug, Serialize)]
struct ExplainCtxKeyEntry {
value: serde_json::Value,
winning_tier: String,
}
#[derive(Debug, Serialize)]
struct ExplainEffectiveCtx {
keys: BTreeMap<String, ExplainCtxKeyEntry>,
note: String,
}
#[derive(Debug, Serialize)]
struct ExplainOutput {
projection_name: String,
naming_warnings: Vec<String>,
parts_note: String,
}
#[derive(Debug, Serialize)]
struct ExplainRunner {
resolved: Option<Runner>,
error: Option<String>,
warning: Option<String>,
}
fn runner_kind_mismatch_warning(
runner: &Runner,
kind: &AgentKind,
agent_name: &str,
) -> Option<String> {
match (runner, kind) {
(Runner::AgentBlockInProcess { .. }, AgentKind::AgentBlock) => None,
(Runner::AgentBlockInProcess { .. }, other) => Some(format!(
"agent '{agent_name}' resolves to Runner::AgentBlockInProcess but AgentDef.kind = \
{other:?} (expected AgentBlock)"
)),
(Runner::WsClaudeCode { .. }, AgentKind::AgentBlock) => Some(format!(
"agent '{agent_name}' resolves to Runner::WsClaudeCode but AgentDef.kind = AgentBlock"
)),
(Runner::WsClaudeCode { .. }, _) => None,
}
}
#[derive(Debug, Serialize)]
struct ExplainAgentResponse {
blueprint: ExplainBlueprintRef,
agent: ExplainAgentRef,
worker_binding: Option<ExplainWorkerBinding>,
binding_note: Option<String>,
runner: ExplainRunner,
declared_tools: ExplainDeclaredTools,
system_prompt: Option<ExplainSystemPrompt>,
effective_ctx: ExplainEffectiveCtx,
output: ExplainOutput,
}
fn ctx_tier_label(tier: CtxTier) -> &'static str {
match tier {
CtxTier::AgentInline => "agent_inline",
CtxTier::MetaRef => "meta_ref",
CtxTier::BpGlobal => "bp_global",
}
}
fn explain_system_prompt(template: &str) -> ExplainSystemPrompt {
let (variables, template_syntax_error): (Vec<String>, Option<String>) =
match template_variables(template) {
Ok(vars) => (vars.into_iter().collect(), None),
Err(e) => (Vec::new(), Some(e.to_string())),
};
ExplainSystemPrompt {
bytes: template.len(),
lines: template.lines().count(),
template_variables: variables,
template_syntax_error,
note: "when the step directive is not a JSON object, only `value` is bound at render \
time"
.to_string(),
}
}
async fn explain_agent(
State(state): State<BlueprintsState>,
Path((id, agent)): Path<(String, String)>,
) -> Result<Json<ExplainAgentResponse>, (StatusCode, String)> {
let store = state.store;
let bp_id = BlueprintId::new(id.clone());
let traced = store
.read_head(&bp_id)
.await
.map_err(|e| (StatusCode::NOT_FOUND, format!("read_head: {e}")))?;
let bp = traced.value;
let version = format!("{:?}", traced.trace.version);
let Some(agent_def) = bp.agents.iter().find(|ad| ad.name == agent) else {
let available: Vec<&str> = bp.agents.iter().map(|ad| ad.name.as_str()).collect();
return Err((
StatusCode::NOT_FOUND,
serde_json::json!({
"error": "agent not found in blueprint",
"agent": agent,
"available": available,
})
.to_string(),
));
};
let profile = agent_def.profile.as_ref();
let (worker_binding, binding_note) = match profile.and_then(|p| p.worker_binding.as_ref()) {
Some(variant) => (
Some(ExplainWorkerBinding {
variant: variant.clone(),
}),
None,
),
None => (
None,
Some(
"no worker_binding declared; WS operator dispatch will fail at compile \
(InvalidSpec)"
.to_string(),
),
),
};
let declared_tools = ExplainDeclaredTools {
tools: profile.map(|p| p.tools.clone()).unwrap_or_default(),
informational: true,
note: "declared tools do not grant anything; the effective tool surface is the worker \
wrapper's frontmatter (see operator.rs WorkerBinding doc)"
.to_string(),
};
let runner = match resolve_runner(&bp, agent_def) {
Ok(resolved) => {
let warning = resolved
.as_ref()
.and_then(|r| runner_kind_mismatch_warning(r, &agent_def.kind, &agent_def.name));
ExplainRunner {
resolved,
error: None,
warning,
}
}
Err(e) => ExplainRunner {
resolved: None,
error: Some(e.to_string()),
warning: None,
},
};
let system_prompt = profile
.filter(|p| !p.system_prompt.is_empty())
.map(|p| explain_system_prompt(&p.system_prompt));
let ctx_keys = explain_agent_ctx(&bp, &agent).unwrap_or_default();
let effective_ctx = ExplainEffectiveCtx {
keys: ctx_keys
.into_iter()
.map(|(k, resolution)| {
(
k,
ExplainCtxKeyEntry {
value: resolution.value,
winning_tier: ctx_tier_label(resolution.winning_tier).to_string(),
},
)
})
.collect(),
note: "static tiers only; Run/Task/Step runtime tiers always win over these \
(only-if-absent insertion order)"
.to_string(),
};
let (projection_name, naming_warnings) = match StepNaming::from_blueprint(&bp) {
Ok((naming, _soft_warnings)) => match naming.canonical_of_producer(&agent) {
Some(canonical) => (canonical.to_string(), Vec::new()),
None => (
agent.clone(),
vec![format!(
"agent '{agent}' does not appear in the blueprint's flow; using the agent \
name as a fallback projection name"
)],
),
},
Err(e) => (
agent.clone(),
vec![format!("StepNaming::from_blueprint failed: {e}")],
),
};
let output = ExplainOutput {
projection_name,
naming_warnings,
parts_note: "if the worker stages named artifact parts, the step OUTPUT changes shape \
to {\"out\", \"parts\"}; reference via $.<step>.out"
.to_string(),
};
Ok(Json(ExplainAgentResponse {
blueprint: ExplainBlueprintRef { id, version },
agent: ExplainAgentRef {
name: agent_def.name.clone(),
kind: agent_def.kind.clone(),
},
worker_binding,
binding_note,
runner,
declared_tools,
system_prompt,
effective_ctx,
output,
}))
}
#[derive(Debug, Serialize)]
struct WorkerBindingSummary {
variant: String,
}
#[derive(Debug, Serialize)]
struct AgentSummary {
name: String,
kind: String,
worker_binding: Option<WorkerBindingSummary>,
declared_tools_count: usize,
system_prompt_bytes: usize,
effective_ctx_key_count: usize,
projection_name: String,
}
#[derive(Debug, Serialize)]
struct BatchExplainAgentsResponse {
blueprint: ExplainBlueprintRef,
agents: Vec<AgentSummary>,
}
async fn explain_agents_batch(
State(state): State<BlueprintsState>,
Path(id): Path<String>,
) -> Result<Json<BatchExplainAgentsResponse>, (StatusCode, String)> {
let store = state.store;
let bp_id = BlueprintId::new(id.clone());
let traced = store
.read_head(&bp_id)
.await
.map_err(|e| (StatusCode::NOT_FOUND, format!("read_head: {e}")))?;
let bp = traced.value;
let version = format!("{:?}", traced.trace.version);
let naming = StepNaming::from_blueprint(&bp)
.ok()
.map(|(naming, _)| naming);
let agents = bp
.agents
.iter()
.map(|agent_def| {
let profile = agent_def.profile.as_ref();
let worker_binding = profile
.and_then(|p| p.worker_binding.as_ref())
.map(|variant| WorkerBindingSummary {
variant: variant.clone(),
});
let declared_tools_count = profile.map(|p| p.tools.len()).unwrap_or(0);
let system_prompt_bytes = profile.map(|p| p.system_prompt.len()).unwrap_or(0);
let effective_ctx_key_count = explain_agent_ctx(&bp, &agent_def.name)
.map(|keys| keys.len())
.unwrap_or(0);
let projection_name = naming
.as_ref()
.and_then(|naming| naming.canonical_of_producer(&agent_def.name))
.map(|canonical| canonical.to_string())
.unwrap_or_else(|| agent_def.name.clone());
AgentSummary {
name: agent_def.name.clone(),
kind: format!("{:?}", agent_def.kind),
worker_binding,
declared_tools_count,
system_prompt_bytes,
effective_ctx_key_count,
projection_name,
}
})
.collect();
Ok(Json(BatchExplainAgentsResponse {
blueprint: ExplainBlueprintRef { id, version },
agents,
}))
}
#[cfg(test)]
mod explain_agent_tests {
use super::*;
use mlua_swarm::blueprint::store::InMemoryBlueprintStore;
use mlua_swarm::blueprint::{
current_schema_version, AgentDef, AgentMeta, AgentProfile, BlueprintMetadata,
CompilerHints, CompilerStrategy,
};
use serde_json::json;
fn agent_def(name: &str, profile: Option<AgentProfile>, meta: Option<AgentMeta>) -> AgentDef {
AgentDef {
name: name.to_string(),
kind: AgentKind::RustFn,
spec: json!({ "fn_id": name }),
profile,
meta,
runner: None,
runner_ref: None,
verdict: None,
}
}
fn single_step_bp(
bp_id: &str,
agent_name: &str,
profile: Option<AgentProfile>,
meta: Option<AgentMeta>,
default_agent_ctx: Option<serde_json::Value>,
) -> Blueprint {
Blueprint {
schema_version: current_schema_version(),
id: bp_id.into(),
flow: serde_json::from_value(json!({
"kind": "step",
"ref": agent_name,
"in": {"op": "path", "at": "$.input"},
"out": {"op": "path", "at": "$.out"},
}))
.expect("flow parse"),
agents: vec![agent_def(agent_name, profile, meta)],
operators: vec![],
metas: vec![],
hints: CompilerHints::default(),
strategy: CompilerStrategy::default(),
metadata: BlueprintMetadata::default(),
spawner_hints: Default::default(),
default_agent_kind: AgentKind::Operator,
default_operator_kind: None,
default_init_ctx: None,
default_agent_ctx,
default_context_policy: None,
projection_placement: None,
audits: vec![],
degradation_policy: None,
runners: vec![],
default_runner: None,
check_policy: None,
}
}
async fn seed(store: &InMemoryBlueprintStore, bp: &Blueprint) {
let bp_id = BlueprintId::new(bp.id.as_str());
let v = blueprint_version(bp).expect("version");
store
.write_new(&bp_id, bp, &[], CommitMetadata::seed(bp_id.clone(), v, 0))
.await
.expect("write_new");
}
fn state_with(store: InMemoryBlueprintStore) -> BlueprintsState {
BlueprintsState {
store: Arc::new(store),
ref_base: None,
cli_default_agent_kind: None,
}
}
#[tokio::test]
async fn full_case_reports_binding_ctx_override_and_system_prompt() {
let profile = AgentProfile {
system_prompt: "Hello {{ name }}, mode={{ mode }}".to_string(),
tools: vec!["Read".to_string(), "Grep".to_string()],
worker_binding: Some("mse-worker-knowledge".to_string()),
..Default::default()
};
let meta = AgentMeta {
ctx: Some(json!({ "work_dir": "/inline" })),
..Default::default()
};
let bp = single_step_bp(
"explain-full-bp",
"researcher",
Some(profile),
Some(meta),
Some(json!({ "work_dir": "/bp-global", "extra": "kept" })),
);
let store = InMemoryBlueprintStore::new();
seed(&store, &bp).await;
let resp = explain_agent(
State(state_with(store)),
Path(("explain-full-bp".to_string(), "researcher".to_string())),
)
.await
.expect("explain_agent")
.0;
assert_eq!(resp.blueprint.id, "explain-full-bp");
assert!(!resp.blueprint.version.is_empty());
assert_eq!(resp.agent.name, "researcher");
assert_eq!(resp.agent.kind, AgentKind::RustFn);
let binding = resp.worker_binding.expect("worker_binding present");
assert_eq!(binding.variant, "mse-worker-knowledge");
assert!(resp.binding_note.is_none());
assert_eq!(
resp.declared_tools.tools,
vec!["Read".to_string(), "Grep".to_string()]
);
assert!(resp.declared_tools.informational);
let sp = resp.system_prompt.expect("system_prompt present");
assert_eq!(sp.bytes, "Hello {{ name }}, mode={{ mode }}".len());
assert_eq!(sp.lines, 1);
assert_eq!(
sp.template_variables,
vec!["mode".to_string(), "name".to_string()]
);
assert!(sp.template_syntax_error.is_none());
assert_eq!(resp.effective_ctx.keys["work_dir"].value, json!("/inline"));
assert_eq!(
resp.effective_ctx.keys["work_dir"].winning_tier,
"agent_inline"
);
assert_eq!(resp.effective_ctx.keys["extra"].value, json!("kept"));
assert_eq!(resp.effective_ctx.keys["extra"].winning_tier, "bp_global");
assert_eq!(resp.output.projection_name, "researcher");
assert!(resp.output.naming_warnings.is_empty());
}
#[tokio::test]
async fn agent_without_worker_binding_reports_binding_note() {
let profile = AgentProfile {
tools: vec!["Read".to_string()],
..Default::default()
};
let bp = single_step_bp("explain-no-binding-bp", "scout", Some(profile), None, None);
let store = InMemoryBlueprintStore::new();
seed(&store, &bp).await;
let resp = explain_agent(
State(state_with(store)),
Path(("explain-no-binding-bp".to_string(), "scout".to_string())),
)
.await
.expect("explain_agent")
.0;
assert!(resp.worker_binding.is_none());
let note = resp.binding_note.expect("binding_note present");
assert!(note.contains("no worker_binding declared"));
assert!(resp.system_prompt.is_none());
}
#[tokio::test]
async fn unknown_agent_name_returns_404_with_available_list() {
let bp = single_step_bp("explain-404-agent-bp", "foo", None, None, None);
let store = InMemoryBlueprintStore::new();
seed(&store, &bp).await;
let err = explain_agent(
State(state_with(store)),
Path((
"explain-404-agent-bp".to_string(),
"no-such-agent".to_string(),
)),
)
.await
.expect_err("expected 404");
assert_eq!(err.0, StatusCode::NOT_FOUND);
let body: serde_json::Value = serde_json::from_str(&err.1).expect("json body");
assert_eq!(body["error"], "agent not found in blueprint");
assert_eq!(body["agent"], "no-such-agent");
assert_eq!(body["available"], json!(["foo"]));
}
#[tokio::test]
async fn unknown_blueprint_id_returns_404_same_as_get_head() {
let store = InMemoryBlueprintStore::new();
let err = explain_agent(
State(state_with(store)),
Path(("no-such-bp".to_string(), "any-agent".to_string())),
)
.await
.expect_err("expected 404");
assert_eq!(err.0, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn template_syntax_error_is_reported_without_500() {
let profile = AgentProfile {
system_prompt: "hello {{ unclosed".to_string(),
..Default::default()
};
let bp = single_step_bp(
"explain-syntax-error-bp",
"scout",
Some(profile),
None,
None,
);
let store = InMemoryBlueprintStore::new();
seed(&store, &bp).await;
let resp = explain_agent(
State(state_with(store)),
Path(("explain-syntax-error-bp".to_string(), "scout".to_string())),
)
.await
.expect("explain_agent")
.0;
let sp = resp.system_prompt.expect("system_prompt present");
assert!(sp.template_variables.is_empty());
assert!(sp.template_syntax_error.is_some());
}
#[tokio::test]
async fn runner_resolves_from_legacy_worker_binding_when_nothing_else_declared() {
let profile = AgentProfile {
worker_binding: Some("mse-worker-knowledge".to_string()),
tools: vec!["Read".to_string()],
..Default::default()
};
let bp = single_step_bp(
"explain-runner-legacy-bp",
"scout",
Some(profile),
None,
None,
);
let store = InMemoryBlueprintStore::new();
seed(&store, &bp).await;
let resp = explain_agent(
State(state_with(store)),
Path(("explain-runner-legacy-bp".to_string(), "scout".to_string())),
)
.await
.expect("explain_agent")
.0;
assert_eq!(
resp.runner.resolved,
Some(mlua_swarm_schema::Runner::WsClaudeCode {
variant: "mse-worker-knowledge".to_string(),
tools: vec!["Read".to_string()],
})
);
assert!(resp.runner.error.is_none());
assert!(resp.runner.warning.is_none());
}
#[tokio::test]
async fn runner_reports_unresolved_runner_ref_as_error_level_finding() {
let mut bp = single_step_bp("explain-runner-unresolved-bp", "scout", None, None, None);
bp.agents[0].runner_ref = Some("no-such-entry".to_string());
let store = InMemoryBlueprintStore::new();
seed(&store, &bp).await;
let resp = explain_agent(
State(state_with(store)),
Path((
"explain-runner-unresolved-bp".to_string(),
"scout".to_string(),
)),
)
.await
.expect("explain_agent")
.0;
assert!(resp.runner.resolved.is_none());
let error = resp.runner.error.expect("error-level finding present");
assert!(
error.contains("no-such-entry"),
"error must name the unresolved runner_ref: {error}"
);
assert!(resp.runner.warning.is_none());
}
#[tokio::test]
async fn runner_reports_backend_kind_mismatch_as_warn_level_finding() {
let mut bp = single_step_bp("explain-runner-mismatch-bp", "scout", None, None, None);
bp.runners = vec![mlua_swarm_schema::RunnerDef {
name: "in-process".to_string(),
runner: mlua_swarm_schema::Runner::AgentBlockInProcess {
tools: vec!["Bash".to_string()],
},
}];
bp.agents[0].runner_ref = Some("in-process".to_string());
let store = InMemoryBlueprintStore::new();
seed(&store, &bp).await;
let resp = explain_agent(
State(state_with(store)),
Path((
"explain-runner-mismatch-bp".to_string(),
"scout".to_string(),
)),
)
.await
.expect("explain_agent")
.0;
assert!(resp.runner.resolved.is_some());
assert!(resp.runner.error.is_none());
let warning = resp.runner.warning.expect("warn-level finding present");
assert!(
warning.contains("AgentBlockInProcess") && warning.contains("RustFn"),
"warning must name both the resolved backend and the mismatched kind: {warning}"
);
}
fn batch_bp() -> Blueprint {
let bound_profile = AgentProfile {
system_prompt: "hello world".to_string(),
tools: vec!["Read".to_string(), "Grep".to_string()],
worker_binding: Some("mse-worker-knowledge".to_string()),
..Default::default()
};
let bound_meta = AgentMeta {
ctx: Some(json!({ "work_dir": "/inline" })),
..Default::default()
};
Blueprint {
schema_version: current_schema_version(),
id: "explain-batch-bp".into(),
flow: serde_json::from_value(json!({
"kind": "step",
"ref": "bound_agent",
"in": {"op": "path", "at": "$.input"},
"out": {"op": "path", "at": "$.out"},
}))
.expect("flow parse"),
agents: vec![
agent_def("bound_agent", Some(bound_profile), Some(bound_meta)),
agent_def("unbound_agent", None, None),
agent_def("orphan_agent", None, None),
],
operators: vec![],
metas: vec![],
hints: CompilerHints::default(),
strategy: CompilerStrategy::default(),
metadata: BlueprintMetadata::default(),
spawner_hints: Default::default(),
default_agent_kind: AgentKind::Operator,
default_operator_kind: None,
default_init_ctx: None,
default_agent_ctx: Some(json!({ "work_dir": "/bp-global", "extra": "kept" })),
default_context_policy: None,
projection_placement: None,
audits: vec![],
degradation_policy: None,
runners: vec![],
default_runner: None,
check_policy: None,
}
}
#[tokio::test]
async fn explain_agents_batch_reports_a_summary_row_per_agent() {
let bp = batch_bp();
let store = InMemoryBlueprintStore::new();
seed(&store, &bp).await;
let resp = explain_agents_batch(
State(state_with(store)),
Path("explain-batch-bp".to_string()),
)
.await
.expect("explain_agents_batch")
.0;
assert_eq!(resp.blueprint.id, "explain-batch-bp");
assert!(!resp.blueprint.version.is_empty());
assert_eq!(resp.agents.len(), 3);
let bound = resp
.agents
.iter()
.find(|a| a.name == "bound_agent")
.expect("bound_agent row");
assert_eq!(bound.kind, format!("{:?}", AgentKind::RustFn));
let binding = bound
.worker_binding
.as_ref()
.expect("worker_binding present");
assert_eq!(binding.variant, "mse-worker-knowledge");
assert_eq!(bound.declared_tools_count, 2);
assert_eq!(bound.system_prompt_bytes, "hello world".len());
assert_eq!(bound.effective_ctx_key_count, 2);
assert_eq!(bound.projection_name, "bound_agent");
let unbound = resp
.agents
.iter()
.find(|a| a.name == "unbound_agent")
.expect("unbound_agent row");
assert!(unbound.worker_binding.is_none());
assert_eq!(unbound.declared_tools_count, 0);
assert_eq!(unbound.system_prompt_bytes, 0);
assert_eq!(unbound.effective_ctx_key_count, 2);
assert_eq!(unbound.projection_name, "unbound_agent");
let orphan = resp
.agents
.iter()
.find(|a| a.name == "orphan_agent")
.expect("orphan_agent row");
assert_eq!(orphan.projection_name, "orphan_agent");
}
#[tokio::test]
async fn explain_agents_batch_zero_agents_returns_empty_list_not_404() {
let bp = Blueprint {
schema_version: current_schema_version(),
id: "explain-batch-empty-bp".into(),
flow: serde_json::from_value(json!({
"kind": "step",
"ref": "unused",
"in": {"op": "path", "at": "$.input"},
"out": {"op": "path", "at": "$.out"},
}))
.expect("flow parse"),
agents: vec![],
operators: vec![],
metas: vec![],
hints: CompilerHints::default(),
strategy: CompilerStrategy::default(),
metadata: BlueprintMetadata::default(),
spawner_hints: Default::default(),
default_agent_kind: AgentKind::Operator,
default_operator_kind: None,
default_init_ctx: None,
default_agent_ctx: None,
default_context_policy: None,
projection_placement: None,
audits: vec![],
degradation_policy: None,
runners: vec![],
default_runner: None,
check_policy: None,
};
let store = InMemoryBlueprintStore::new();
seed(&store, &bp).await;
let resp = explain_agents_batch(
State(state_with(store)),
Path("explain-batch-empty-bp".to_string()),
)
.await
.expect("explain_agents_batch")
.0;
assert!(resp.agents.is_empty());
}
#[tokio::test]
async fn explain_agents_batch_unknown_blueprint_id_returns_404_same_as_get_head() {
let store = InMemoryBlueprintStore::new();
let err = explain_agents_batch(State(state_with(store)), Path("no-such-bp".to_string()))
.await
.expect_err("expected 404");
assert_eq!(err.0, StatusCode::NOT_FOUND);
}
}