mlua_swarm_schema/lib.rs
1//! Blueprint schema — Swarm IF SoT (= the core type set that defines "how a Blueprint object is written").
2//!
3//! This crate provides **schema types + serde derives only** as a pure IF crate. Execution
4//! layers (SpawnerFactory / EngineDispatcher / Compiler) are not included here; consumers
5//! (the `mlua-swarm` crate) own them. External consumers, sibling worktrees, and
6//! future bundles can read/write Blueprints by depending on this single crate.
7//!
8//! # Versioning contract
9//!
10//! `Blueprint.schema_version` is tied to this crate's semver. It is fixed at 0.1.0 for now;
11//! during 0.x breaking changes are free, and 1.0 will freeze the schema.
12//!
13//! # IN-immutability (extension discipline)
14//!
15//! This crate is the IN side of the swarm layering and stays **plain serde
16//! data**: no compile pass, no field the engine macro-expands, no DSL
17//! dialect. Flow conds are written literally against the Flow.ir Expr set
18//! (`Eq($.<step>.verdict, Lit("blocked"))` — domain verdicts are plain
19//! strings in step output). Authoring sugar (builders) lives OUT on the
20//! consumer side; runtime behavior extension lives in the engine's
21//! `SpawnerLayer` middleware.
22//!
23//! # AgentKind handling (= internal SoT)
24//!
25//! [`AgentKind`] is the SoT for the SpawnerAdapter offering axis. It is a closed enum managed
26//! inside Swarm, extended by variant addition through **explicit maintenance**. String lookup
27//! or a `Custom` escape hatch is deliberately avoided (= structurally eliminates the "silly
28//! runtime typos" class of failures).
29//!
30//! # Examples
31//!
32//! Build a minimal [`Blueprint`] with a single [`AgentDef`] via struct literal:
33//!
34//! ```
35//! use mlua_swarm_schema::{
36//! AgentDef, AgentKind, Blueprint, current_schema_version,
37//! };
38//! use mlua_flow_ir::{Expr, Node};
39//! use serde_json::json;
40//!
41//! let bp = Blueprint {
42//! schema_version: current_schema_version(),
43//! id: "hello".into(),
44//! flow: Node::Step {
45//! ref_: "greeter".into(),
46//! in_: Expr::Lit { value: json!({"name": "world"}) },
47//! out: Expr::Path { at: "$.greeting".parse().unwrap() },
48//! },
49//! agents: vec![AgentDef {
50//! name: "greeter".into(),
51//! kind: AgentKind::RustFn,
52//! spec: json!({"fn_id": "hello_world"}),
53//! profile: None,
54//! meta: None,
55//! runner: None,
56//! runner_ref: None,
57//! verdict: None,
58//! }],
59//! operators: vec![],
60//! metas: vec![],
61//! hints: Default::default(),
62//! strategy: Default::default(),
63//! metadata: Default::default(),
64//! spawner_hints: Default::default(),
65//! default_agent_kind: AgentKind::Operator,
66//! default_operator_kind: None,
67//! default_init_ctx: None,
68//! default_agent_ctx: None,
69//! default_context_policy: None,
70//! projection_placement: None,
71//! audits: vec![],
72//! degradation_policy: None,
73//! runners: vec![],
74//! default_runner: None,
75//! check_policy: None,
76//! blueprint_ref_includes: vec![],
77//! };
78//!
79//! assert_eq!(bp.id.as_str(), "hello");
80//! assert_eq!(bp.agents.len(), 1);
81//! assert_eq!(bp.strategy.strict_refs, true);
82//! ```
83//!
84//! Round-trip a [`Blueprint`] through JSON (= confirms `serde` derives and the
85//! `deny_unknown_fields` contract):
86//!
87//! ```
88//! use mlua_swarm_schema::{AgentKind, Blueprint, BlueprintMetadata};
89//! use mlua_flow_ir::{Expr, Node};
90//! use serde_json::json;
91//!
92//! let bp = Blueprint {
93//! schema_version: mlua_swarm_schema::current_schema_version(),
94//! id: "roundtrip".into(),
95//! flow: Node::Seq { children: vec![] },
96//! agents: vec![],
97//! operators: vec![],
98//! metas: vec![],
99//! hints: Default::default(),
100//! strategy: Default::default(),
101//! metadata: BlueprintMetadata {
102//! description: Some("roundtrip smoke".into()),
103//! default_run_ttl_secs: Some(1800),
104//! ..Default::default()
105//! },
106//! spawner_hints: Default::default(),
107//! default_agent_kind: AgentKind::Operator,
108//! default_operator_kind: None,
109//! default_init_ctx: None,
110//! default_agent_ctx: None,
111//! default_context_policy: None,
112//! projection_placement: None,
113//! audits: vec![],
114//! degradation_policy: None,
115//! runners: vec![],
116//! default_runner: None,
117//! check_policy: None,
118//! blueprint_ref_includes: vec![],
119//! };
120//!
121//! let json = serde_json::to_string(&bp).unwrap();
122//! let back: Blueprint = serde_json::from_str(&json).unwrap();
123//! assert_eq!(bp, back);
124//! assert_eq!(back.metadata.default_run_ttl_secs, Some(1800));
125//! ```
126
127#![warn(missing_docs)]
128
129use mlua_flow_ir::Node as FlowNode;
130use schemars::JsonSchema;
131use serde::{Deserialize, Serialize};
132use serde_json::Value;
133use std::collections::HashMap;
134
135// ──────────────────────────────────────────────────────────────────────────
136// Versioning
137// ──────────────────────────────────────────────────────────────────────────
138
139/// Current Blueprint schema version. Tied to this crate's semver.
140pub const CURRENT_SCHEMA_VERSION: &str = "0.1.0";
141
142fn default_schema_version() -> semver::Version {
143 current_schema_version()
144}
145
146/// Blueprint construction helper: returns the semver of the current schema version.
147/// Callers can write `schema_version: current_schema_version(),`.
148pub fn current_schema_version() -> semver::Version {
149 semver::Version::parse(CURRENT_SCHEMA_VERSION)
150 .expect("CURRENT_SCHEMA_VERSION must be valid semver")
151}
152
153// ──────────────────────────────────────────────────────────────────────────
154// BlueprintId (human-facing ID newtype)
155// ──────────────────────────────────────────────────────────────────────────
156
157/// Identifier for a Blueprint series — the domain name (`coding`,
158/// `design`, `testing`, etc.). Default: [`BlueprintId::main`].
159///
160/// One representation across the workspace (issue #14): this type is
161/// shared by the schema's [`Blueprint::id`] and the engine's store-layer
162/// keys (`mlua-swarm` re-exports it at the old
163/// `blueprint::store::types::BlueprintId` path). The value is
164/// user-supplied — there is no prefix convention to validate, unlike the
165/// engine's minted `T-` / `R-` / `ST-` ids — so construction is
166/// infallible; the inner string is private so call sites go through
167/// [`BlueprintId::new`] and the accessors. `#[serde(transparent)]` keeps
168/// both the JSON wire shape and the generated JSON Schema a plain string.
169#[derive(
170 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
171)]
172#[serde(transparent)]
173pub struct BlueprintId(String);
174
175impl BlueprintId {
176 /// The default series name used when a caller doesn't pick one.
177 pub const MAIN: &'static str = "main";
178
179 /// Shorthand for `BlueprintId::new(BlueprintId::MAIN)`.
180 pub fn main() -> Self {
181 Self(Self::MAIN.to_string())
182 }
183
184 /// Wrap any string-like value as a `BlueprintId` (user-supplied key;
185 /// nothing to validate).
186 pub fn new(s: impl Into<String>) -> Self {
187 Self(s.into())
188 }
189
190 /// Borrow the inner series name.
191 pub fn as_str(&self) -> &str {
192 &self.0
193 }
194
195 /// Consume the id and return the inner series name.
196 pub fn into_string(self) -> String {
197 self.0
198 }
199}
200
201impl std::fmt::Display for BlueprintId {
202 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203 f.write_str(&self.0)
204 }
205}
206
207impl From<String> for BlueprintId {
208 fn from(s: String) -> Self {
209 Self(s)
210 }
211}
212
213impl From<&str> for BlueprintId {
214 fn from(s: &str) -> Self {
215 Self(s.to_string())
216 }
217}
218
219#[cfg(test)]
220mod blueprint_id_tests {
221 use super::*;
222
223 /// issue #14 convergence guard: `Blueprint.id` becoming a newtype must
224 /// not change the generated JSON Schema — the property stays an inline
225 /// plain string (no `$ref`), byte-compatible with the `String` era.
226 #[test]
227 fn blueprint_id_field_schema_stays_a_plain_inline_string() {
228 let schema = schemars::schema_for!(Blueprint);
229 let v = serde_json::to_value(&schema).expect("schema serializes");
230 let id = &v["properties"]["id"];
231 assert_eq!(id["type"], "string", "id must stay a plain string: {id}");
232 assert!(id.get("$ref").is_none(), "id must not become a $ref: {id}");
233 }
234
235 /// The JSON wire shape of the newtype is the bare string.
236 #[test]
237 fn blueprint_id_serde_is_transparent() {
238 let id = BlueprintId::new("coding");
239 assert_eq!(
240 serde_json::to_value(&id).unwrap(),
241 serde_json::json!("coding")
242 );
243 let back: BlueprintId = serde_json::from_value(serde_json::json!("coding")).unwrap();
244 assert_eq!(back, id);
245 }
246}
247
248// ──────────────────────────────────────────────────────────────────────────
249// Blueprint (top-level package)
250// ──────────────────────────────────────────────────────────────────────────
251
252/// Unified package of flow.ir + Swarm extension layers. The entry-point type of Swarm.
253#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
254#[serde(deny_unknown_fields)]
255pub struct Blueprint {
256 /// Schema version (= tied to this crate's semver). Default = `CURRENT_SCHEMA_VERSION`.
257 /// Serialized as a semver string (e.g. `"0.1.0"`).
258 #[serde(default = "default_schema_version")]
259 #[schemars(with = "String")]
260 pub schema_version: semver::Version,
261 /// Blueprint identifier (= unique key within the caller's namespace).
262 #[schemars(with = "String")]
263 pub id: BlueprintId,
264 /// Embeds the flow.ir Node verbatim (= keeps flow.ir side unpolluted).
265 /// Opaque in the JSON Schema (the Node shape is owned by the `mlua-flow-ir`
266 /// crate, a separate repo; see its docs for the Node / Expr grammar).
267 #[schemars(with = "Value")]
268 pub flow: FlowNode,
269 /// Swarm extension layer: agent → backend mapping.
270 #[serde(default)]
271 pub agents: Vec<AgentDef>,
272 /// Swarm extension layer: **design-time definition** of Operator roles (first-class).
273 ///
274 /// `AgentDef.spec.operator_ref` references an `OperatorDef.name` (logical role name) in
275 /// this vec. Embedding runtime-generated IDs such as sid into the BP is forbidden
276 /// (= collapses the design-time vs runtime boundary). Runtime backend bindings are
277 /// established via the attach / register path; the BP side holds only logical names.
278 ///
279 /// Every `kind = Operator` agent must have its `spec.operator_ref` present in this
280 /// list — the compiler validates it at `compile()` time. May be `[]` only when the
281 /// Blueprint declares no Operator agents.
282 #[serde(default)]
283 pub operators: Vec<OperatorDef>,
284 /// GH #21 Phase 2 — named, BP-scoped pool of [`MetaDef`] entries. Two
285 /// independent consumers resolve names against this pool: a
286 /// `$step_meta.ref` envelope embedded in a Step's evaluated `in`
287 /// value (the Step tier — resolved by `EngineDispatcher` in the
288 /// `mlua-swarm` core crate at dispatch time), and
289 /// [`AgentMeta::meta_ref`] (the Agent tier — resolved at launch
290 /// time). The pool lets multiple Steps and/or Agents share one
291 /// declarative context object by name instead of repeating it
292 /// inline. `[]` = no named `MetaDef`s declared (pre-#21-Phase-2
293 /// Blueprints unaffected).
294 #[serde(default, skip_serializing_if = "Vec::is_empty")]
295 pub metas: Vec<MetaDef>,
296 /// Swarm extension layer: per-agent hints (interpreted by the Compiler).
297 #[serde(default)]
298 pub hints: CompilerHints,
299 /// Swarm extension layer: Compiler behavior strategy (strict / lenient).
300 #[serde(default)]
301 pub strategy: CompilerStrategy,
302 /// Blueprint metadata (description / origin / tags / ttl / version label / alias).
303 #[serde(default)]
304 pub metadata: BlueprintMetadata,
305 /// Swarm extension layer: hint keys of the layers to wrap around the SpawnerStack.
306 /// Resolved by the LayerRegistry at engine bind time (= unregistered keys are silently
307 /// skipped). Flow / Blueprint do not hold middleware implementations (e.g. MainAIMiddleware)
308 /// directly; they only declare required capabilities as string keys (= implementations
309 /// live in the engine-side LayerRegistry).
310 #[serde(default)]
311 pub spawner_hints: SpawnerHints,
312 /// BP-wide default `AgentKind` (= fallback when `AgentDef.kind` is omitted).
313 /// Four-layer cascade: (1) Schema impl Default = Operator, (2) CLI
314 /// `--default-agent-kind`, (3) this field (BP JSON literal), (4) `AgentDef.kind`
315 /// (per-agent literal). (5) `CompilerHints.kind_override` allows runtime override.
316 /// All default resolution flows through this path.
317 #[serde(default = "default_global_agent_kind")]
318 pub default_agent_kind: AgentKind,
319 /// BP-wide default `OperatorKind` (= the "BP Global" tier of the 4-tier
320 /// `OperatorKind` cascade). `None` when the Blueprint author does not
321 /// declare a default; the caller-side resolver then falls through to
322 /// the hardcoded `OperatorKind::default()` (Automate).
323 ///
324 /// # 4-tier cascade (highest to lowest priority)
325 ///
326 /// 1. Runtime Agent-level (per-agent override supplied at task-launch time)
327 /// 2. Runtime Global (the launch-time `operator_kind` request)
328 /// 3. BP Agent-level (`OperatorDef.kind`, resolved via `AgentDef.spec.operator_ref`)
329 /// 4. BP Global (this field)
330 /// 5. Default Fallback (`OperatorKind::default()` = Automate)
331 ///
332 /// The collapse itself is implemented once on the engine side and consumed
333 /// per-agent when resolving operator info.
334 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub default_operator_kind: Option<OperatorKind>,
336 /// Blueprint-level default initial `ctx` for flow-ir eval.
337 /// `TaskLaunchService::launch` shallow-merges this with the
338 /// Task-level `init_ctx` (Task wins on key collision when both
339 /// are `Object`; if Task's `init_ctx` is not an `Object`, it
340 /// full-replaces the default). `None` — no default is merged;
341 /// backward-compat with pre-#19 Blueprints.
342 #[serde(default, skip_serializing_if = "Option::is_none")]
343 #[schemars(with = "Option<Value>")]
344 pub default_init_ctx: Option<Value>,
345 /// GH #21 Phase 1 — "BP Global" tier of the agent-context supply axis:
346 /// a declarative object merged into `ctx.meta.runtime` (and, for
347 /// unnamed keys, `AgentContextView.extra`) targeting every agent's
348 /// runtime materialization. Contrast with [`Self::default_init_ctx`]:
349 /// that field seeds the flow-ir eval `ctx` once at flow start, while
350 /// this one is consumed per-spawn by
351 /// `AgentContextMiddleware`/`AgentContextView` (Contract C, GH #20) —
352 /// a pure flow-ir eval seed vs. an Agent/LLM-boundary runtime default.
353 /// `None` = no BP-global default (pre-#21 Blueprints unaffected).
354 #[serde(default, skip_serializing_if = "Option::is_none")]
355 #[schemars(with = "Option<Value>")]
356 pub default_agent_ctx: Option<Value>,
357 /// GH #21 Phase 1 — "BP Global" tier of the [`ContextPolicy`] cascade:
358 /// the default filter applied to the materialized `AgentContextView`
359 /// when the targeted agent declares no `AgentMeta.context_policy` of
360 /// its own. `None` = pass-all (the pre-#21 behavior).
361 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub default_context_policy: Option<ContextPolicy>,
363 /// GH #27 (follow-up to #23) — Blueprint-declared override of the
364 /// `mlua-swarm` core crate's projection placement resolver (root
365 /// preference + target directory template for materialized step
366 /// OUTPUT files). `None` = the resolver's byte-compat default (root =
367 /// `work_dir` falling back to `project_root`; dir_template =
368 /// `"workspace/tasks/{task_id}/ctx"`) — every pre-#27 Blueprint is
369 /// unaffected. See [`ProjectionPlacementSpec`]'s doc for field detail.
370 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub projection_placement: Option<ProjectionPlacementSpec>,
372 /// GH #34 — Blueprint-declared after-run audit hooks: the engine
373 /// auto-kicks each listed [`AuditDef`]'s agent once a matching Step
374 /// settles, and persists its findings as an `OutputEvent::Artifact`
375 /// named `"audit:<step_ref>"` on the AUDITED step's own output tail
376 /// (see `mlua-swarm` core's `AfterRunAuditMiddleware` for the
377 /// dispatch mechanics). `audits[].agent` is validated at
378 /// `Compiler::compile` time against `Blueprint.agents[].name`
379 /// (mirrors the `operator_ref` validation). `[]` (the default) = no
380 /// audit hooks declared — every pre-#34 Blueprint is unaffected,
381 /// byte-for-byte.
382 ///
383 /// **Binding invariant**: an audit's verdict, findings, or even its
384 /// own failure NEVER change the audited step's outcome or gate the
385 /// flow — audits are purely observational.
386 #[serde(default, skip_serializing_if = "Vec::is_empty")]
387 pub audits: Vec<AuditDef>,
388 /// GH #32 — Blueprint-declared policy for worker-reported degradations
389 /// (see `mlua-swarm` core's `RunRecord.degradations` /
390 /// `DegradationEntry`). `None` (the default) is schema-only for now:
391 /// [`DegradationPolicy::Warn`] and [`DegradationPolicy::Fail`] carry the
392 /// same observational behavior at this point — degradations are always
393 /// persisted, never gate the flow. Engine enforcement of `Fail`
394 /// (terminating a Run on any reported degradation) is a follow-up; this
395 /// field only declares author intent today. Every pre-#32 Blueprint is
396 /// unaffected.
397 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub degradation_policy: Option<DegradationPolicy>,
399 /// GH #46 M2 — named registry of [`RunnerDef`] entries (Tier 1 of the
400 /// 3-tier Worker model: Runner / Agent / Context). Referenced by
401 /// `AgentDef.runner_ref` and [`Self::default_runner`] by name.
402 /// Same registry shape as [`Self::metas`] (GH #21 Phase 2). `[]` (the
403 /// default) = no Runner registry declared — every pre-#46 Blueprint
404 /// is unaffected, byte-for-byte.
405 #[serde(default, skip_serializing_if = "Vec::is_empty")]
406 pub runners: Vec<RunnerDef>,
407 /// GH #46 M2 — the "BP Global" tier of the [`resolve_runner`] cascade:
408 /// a [`RunnerDef::name`] reference into [`Self::runners`] (inline
409 /// `Runner` values are not accepted here — registry names only,
410 /// mirroring [`Self::default_agent_ctx`]'s design). Ranks BELOW an
411 /// agent's own inline `runner` / `runner_ref` / legacy
412 /// `profile.worker_binding` declaration (see [`resolve_runner`]'s
413 /// cascade doc for the full precedence). `None` = no BP-wide default
414 /// declared — every pre-#46 Blueprint is unaffected.
415 #[serde(default, skip_serializing_if = "Option::is_none")]
416 pub default_runner: Option<String>,
417 /// "Blueprint" tier (tier 2) of the `check_policy`
418 /// cascade: `launch request > blueprint > server config` (highest to
419 /// lowest priority). The launch entry point resolves
420 /// `launch.check_policy.or(blueprint.check_policy)` exactly once and
421 /// threads the result into every spawned step's `TaskSpec.check_policy`;
422 /// `None` here (the default) is a no declaration — resolution falls
423 /// through to the launch-request tier and, absent that, to the
424 /// server-wide `EngineCfg.check_policy` default. Every pre-cascade
425 /// Blueprint is unaffected, byte-for-byte. See [`CheckPolicy`] for the
426 /// three fail-open reaction modes.
427 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub check_policy: Option<CheckPolicy>,
429 /// Authoring-time include list consumed by the compile-side linker
430 /// (tier 2 of the include cascade — see `mlua-swarm-compile`'s
431 /// `ResolveConfig`). Each entry is a directory path resolved
432 /// relative to the bp.lua parent that `$agent_md` / `$file` refs
433 /// will search after the parent dir itself. Bare list; the schema
434 /// carries the field only so `deny_unknown_fields` won't reject a
435 /// bp.lua that declares it. `[]` (the default) — no in-bp includes;
436 /// every pre-cascade Blueprint is unaffected.
437 #[serde(default, skip_serializing_if = "Vec::is_empty")]
438 #[schemars(with = "Vec<String>")]
439 pub blueprint_ref_includes: Vec<std::path::PathBuf>,
440}
441
442/// How a submit-time projection sink reacts when a fail-open condition
443/// is encountered.
444///
445/// This is the Swarm IF SoT type for the `check_policy` axis; the
446/// `mlua-swarm` core crate re-exports it as `crate::core::config::CheckPolicy`
447/// so every existing path (`EngineCfg.check_policy`, `TaskSpec.check_policy`,
448/// `apply_check_policy`) keeps its old type path unchanged.
449///
450/// Fail-open conditions include: `work_dir` / `project_root` unresolved,
451/// `OutputStore` write error, `FileProjectionAdapter::materialize_submission`
452/// error, and state lookup error. Each call site inside the engine's
453/// `materialize_final_submission` / `materialize_artifact_submission`
454/// currently logs a `tracing::warn!` and returns without materializing the
455/// file / dual-write; `CheckPolicy` is the first-class knob that lets a
456/// caller opt into a different reaction without changing that behaviour by
457/// default.
458///
459/// The three modes are (a) [`CheckPolicy::Silent`] — no log, no error,
460/// operation continues; (b) [`CheckPolicy::Warn`] — log warn (existing
461/// message literal preserved), no error, operation continues (the
462/// default = pre-existing behaviour); (c) [`CheckPolicy::Strict`] — log
463/// the same warn AND return `EngineError::CheckPolicyStrict` (in the core
464/// crate) so the caller can fail the step / launch fast. When Strict
465/// returns an error, the underlying `OutputStore` may already have
466/// appended (dual-write side-effect is not rolled back) — this "state
467/// dirty on fail" semantics is intentional: the append happens **before**
468/// the fail-open branch runs, so Strict surfaces the mismatch instead of
469/// hiding it.
470///
471/// The wire form is snake_case (`"silent"` / `"warn"` / `"strict"`); the
472/// default is [`CheckPolicy::Warn`].
473#[derive(Copy, Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
474#[serde(rename_all = "snake_case")]
475pub enum CheckPolicy {
476 /// Skip both the log warn and the error path — completely silent.
477 /// The operation continues (fail-open is still in effect).
478 Silent,
479 /// Log a `tracing::warn!` with the call site's existing message and
480 /// continue (fail-open). Default — byte-identical to the
481 /// pre-`CheckPolicy` behaviour of every submit-time projection sink
482 /// code path.
483 #[default]
484 Warn,
485 /// Log the same warn AND return `EngineError::CheckPolicyStrict` (the
486 /// core crate's error variant). A caller that has opted in can fail the
487 /// step / launch fast instead of proceeding with a partially-realized
488 /// submission. This mode also drives a launch-time pre-dispatch
489 /// validation in `TaskLaunchService::launch` (the `mlua-swarm` core
490 /// crate): a launch whose effective policy resolves to `Strict` and
491 /// that supplies neither `project_root` nor `work_dir` is rejected
492 /// with `TaskLaunchError::PreDispatch` before any step is dispatched,
493 /// rather than dispatching a step that would deterministically hit
494 /// this same error at its first submit-time file materialize.
495 Strict,
496}
497
498/// GH #32 — Blueprint-declared policy for worker-reported degradations. See
499/// [`Blueprint::degradation_policy`] for the (currently schema-only)
500/// enforcement contract.
501#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
502#[serde(rename_all = "snake_case")]
503pub enum DegradationPolicy {
504 /// Observational only (today's only enforced behavior, regardless of
505 /// which variant is declared): degradations are persisted to
506 /// `RunRecord.degradations` and surfaced via `mse_doctor` /
507 /// `GET /v1/runs/:id`, but never change the Run's outcome.
508 Warn,
509 /// Declares intent to terminate the Run on any reported degradation.
510 /// Not yet enforced by the engine — schema-only until the follow-up
511 /// lands.
512 Fail,
513}
514
515/// GH #34 — one Blueprint-declared after-run audit hook. See
516/// [`Blueprint::audits`] for the persistence / invariant contract.
517#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
518#[serde(deny_unknown_fields)]
519pub struct AuditDef {
520 /// Name of the audit agent (must match a [`Blueprint::agents`] entry's
521 /// `name`) the engine dispatches after a matched step settles.
522 /// Validated at `Compiler::compile` time (mirrors
523 /// `AgentDef.spec.operator_ref`'s `operator_ref` validation) — an
524 /// unresolved name rejects compilation.
525 pub agent: String,
526 /// Step names this audit applies to, matched against the step's agent
527 /// ref name. `None`, or a list containing the literal `"*"`, means
528 /// "every step". `Some(vec![])` (an explicit empty list) audits no
529 /// step. `None` is the default.
530 #[serde(default, skip_serializing_if = "Option::is_none")]
531 pub steps: Option<Vec<String>>,
532 /// Dispatch timing for this audit's agent (see [`AuditMode`]).
533 /// Defaults to [`AuditMode::Async`].
534 #[serde(default)]
535 pub mode: AuditMode,
536}
537
538/// GH #34 — dispatch timing for an [`AuditDef`]'s audit agent. Neither
539/// variant ever changes the audited step's outcome (see
540/// [`Blueprint::audits`]'s binding invariant).
541#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
542#[serde(rename_all = "snake_case")]
543pub enum AuditMode {
544 /// Fire-and-forget: the audit runs in the background after the
545 /// audited step settles; the audited step's own spawn signal returns
546 /// immediately, without waiting for the audit to finish.
547 #[default]
548 Async,
549 /// Awaited before the audited step's spawn signal is returned to the
550 /// engine — still never alters that signal or the step's recorded
551 /// outcome.
552 Sync,
553}
554
555/// Receptacle for a Blueprint-driven filter over the materialized
556/// `AgentContextView` (GH #20/#21). Declared BP-side via
557/// [`Blueprint::default_context_policy`] (BP-global) or
558/// `AgentMeta::context_policy` (per-agent, outranks the BP-global tier) —
559/// resolved and applied by `AgentContextMiddleware` in the `mlua-swarm`
560/// core crate (this crate stays execution-free; see the crate doc).
561/// Default (`include: None, exclude: vec![]`) is pass-all — [`Self::allows`]
562/// returns `true` for every field name.
563#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
564#[serde(deny_unknown_fields)]
565pub struct ContextPolicy {
566 /// Field names to keep. `None` means "keep everything" (pass-all).
567 /// Matched against the `AgentContextView` named-field strings
568 /// (`"project_root"` / `"work_dir"` / `"task_metadata"` / `"run_id"` /
569 /// `"project_name_alias"`) and `extra` keys by their own key string.
570 /// Identity fields (`task_id` / `agent` / `attempt`) are never
571 /// filtered regardless of `include`.
572 #[serde(default)]
573 pub include: Option<Vec<String>>,
574 /// Field names to drop, applied AFTER `include` (exclude wins when a
575 /// name appears in both). Same name-matching rule as `include`.
576 #[serde(default)]
577 pub exclude: Vec<String>,
578 /// Which preceding steps' OUTPUT pointers a worker's fetch payload may
579 /// see (`WorkerPayload.context.steps`, ST5 of the `projection-adapter`
580 /// design). `None` = pass-all (every submitted step, the pre-ST5
581 /// `ctx_step_dir` behavior); `Some(list)` = only the named steps;
582 /// `Some(vec![])` = none. Evaluated by [`Self::allows_step`], a sibling
583 /// of [`Self::allows`] with the same include/exclude precedence rule
584 /// but a separate namespace (step names vs. `AgentContextView` field /
585 /// `extra` key names never collide).
586 #[serde(default)]
587 pub steps: Option<Vec<String>>,
588 /// Step names to drop, applied AFTER `steps` (exclude wins when a name
589 /// appears in both). Same name-matching rule as `steps`.
590 #[serde(default)]
591 pub steps_exclude: Vec<String>,
592}
593
594impl ContextPolicy {
595 /// Whether `name` survives this policy: `false` if `exclude` lists it;
596 /// otherwise `true` when `include` is `None` (pass-all) or lists
597 /// `name`. Shared by both the schema crate (tests) and the `mlua-swarm`
598 /// core crate's `AgentContextView::apply_policy`, so the include/exclude
599 /// evaluation rule has exactly one implementation.
600 pub fn allows(&self, name: &str) -> bool {
601 if self.exclude.iter().any(|excluded| excluded == name) {
602 return false;
603 }
604 match &self.include {
605 Some(list) => list.iter().any(|included| included == name),
606 None => true,
607 }
608 }
609
610 /// Whether the preceding step named `name` survives this policy for the
611 /// worker fetch payload's `context.steps` pointer list: `false` if
612 /// `steps_exclude` lists it; otherwise `true` when `steps` is `None`
613 /// (pass-all) or lists `name`. Same precedence rule as [`Self::allows`],
614 /// evaluated against the separate `steps` / `steps_exclude` fields.
615 pub fn allows_step(&self, name: &str) -> bool {
616 if self.steps_exclude.iter().any(|excluded| excluded == name) {
617 return false;
618 }
619 match &self.steps {
620 Some(list) => list.iter().any(|included| included == name),
621 None => true,
622 }
623 }
624}
625
626/// Global default `AgentKind` at the Schema impl Default layer. Bottom of the 4-layer cascade.
627pub fn default_global_agent_kind() -> AgentKind {
628 AgentKind::Operator
629}
630
631/// Set of **capability hint keys** for the SpawnerLayer required by a Blueprint.
632///
633/// # Design rationale (= for the person who will reconstruct this later)
634///
635/// A Blueprint is a pure layer of flow.ir + agent name binding and holds no middleware
636/// **implementation**. Nevertheless there are cases where the caller must be told the BP
637/// needs certain **capabilities** — e.g. "MainAI hook required", "Operator delegate path
638/// required", operator role mode switching, presence/absence of senior escalation, and
639/// so on.
640///
641/// `spawner_hints.layers` is the place where those capabilities are declared as **string
642/// keys**. The engine-side `LayerRegistry` (= consumer crate) resolves key → factory and
643/// wraps the compiled routes with a `SpawnerStack`. The Blueprint does not import the
644/// concrete `MainAIMiddleware` type; it exposes intent through strings such as `"main_ai"`
645/// (= separates the pure Flow layer from implementation details).
646///
647/// # Canonical hint keys
648///
649/// - `"main_ai"` → `MainAIMiddleware` (= fires SpawnHook before/after when kind is MainAi/Composite)
650/// - `"senior_escalation"` → `SeniorEscalationMiddleware` (= fires SeniorBridge.ask on worker ok=false)
651/// - `"operator_delegate"` → `OperatorDelegateMiddleware` (= delegates the entire spawn to an external Operator.execute)
652///
653/// # Behavior of unregistered keys
654///
655/// If the engine-side LayerRegistry has no matching factory, the key is **silently skipped**
656/// (= lenient default). This preserves Blueprint portability (= an unsupported capability in
657/// another deployment falls back gracefully).
658#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)]
659#[serde(deny_unknown_fields)]
660pub struct SpawnerHints {
661 /// Ordered list of layer hint keys to wrap around the SpawnerStack.
662 #[serde(default)]
663 pub layers: Vec<String>,
664}
665
666// ──────────────────────────────────────────────────────────────────────────
667// AgentDef / AgentKind / AgentProfile / AgentMeta
668// ──────────────────────────────────────────────────────────────────────────
669
670/// Maps an agent name to a Worker IMPL kind and its configuration. Referenced from flow.ir
671/// `Step.ref` by name.
672///
673/// # Design
674///
675/// `AgentDef.kind` directly expresses the **Worker IMPL axis** (= not the old Spawner axis).
676/// Dispatching to a host Spawner adapter (`InProcSpawner` / `ProcessSpawner` /
677/// `OperatorSpawner`) is done by an internal Resolver on the compiler side. The design goal
678/// is "do not make the caller aware of which Spawner hosts the Worker IMPL"; the caller
679/// (Blueprint author) sees only the WorkerIMPL viewpoint.
680///
681/// A Spawner-axis hint (= "which adapter would you prefer running this Worker on", as a
682/// priority list) will be added via a future `spawner_hint: Vec<Spawner>` field as a carry.
683/// The current internal Resolver is a fixed 1:1 mapping, so the field is unnecessary today.
684#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
685#[serde(deny_unknown_fields)]
686pub struct AgentDef {
687 /// Agent name (= referenced from flow.ir `Step.ref`).
688 pub name: String,
689 /// Worker IMPL kind (= see [`AgentKind`]).
690 pub kind: AgentKind,
691 /// Free-form schema per kind. Interpreted by the SpawnerFactory.
692 #[serde(default)]
693 pub spec: Value,
694 /// Agent persona information (system_prompt / model / tools, etc.). Orthogonal to the
695 /// backend kind and is a first-class field. Expected to be populated by
696 /// `agent_md_loader` from the frontmatter + body of an `agent.md`. `None` = an agent
697 /// without a profile (= backend built solely from `spec`).
698 #[serde(default)]
699 pub profile: Option<AgentProfile>,
700 /// Agent-level metadata (description / version / tags).
701 #[serde(default)]
702 pub meta: Option<AgentMeta>,
703 /// GH #46 M2 — inline [`Runner`] declaration: the highest-priority
704 /// tier of the [`resolve_runner`] cascade. `None` = this agent
705 /// declares no inline Runner (falls through to [`Self::runner_ref`],
706 /// then the legacy `profile.worker_binding` fallback, then
707 /// `Blueprint.default_runner`).
708 #[serde(default, skip_serializing_if = "Option::is_none")]
709 pub runner: Option<Runner>,
710 /// GH #46 M2 — a [`RunnerDef::name`] reference into
711 /// `Blueprint.runners` (second-priority tier of [`resolve_runner`]).
712 /// `None` = this agent declares no Runner registry reference.
713 #[serde(default, skip_serializing_if = "Option::is_none")]
714 pub runner_ref: Option<String>,
715 /// GH #50 — opt-in declaration of which OUTPUT channel this agent's
716 /// verdict token lives on, and the closed set of tokens it may emit
717 /// through that channel (see [`VerdictContract`]). Consumed by the
718 /// `mlua-swarm` core crate's `Compiler::compile` to lint
719 /// `Branch`/`Loop` `Eq`/`Ne`/`In` conds against this agent's output at
720 /// register time; a follow-up submit-time producer gate is a separate
721 /// enforcement point. `None` (the default) — this agent declares no
722 /// contract; a cond comparing its output to a literal is unchanged (at
723 /// most a `tracing::warn!`, never rejected) — every pre-GH-#50
724 /// Blueprint is unaffected, byte-for-byte.
725 #[serde(default, skip_serializing_if = "Option::is_none")]
726 pub verdict: Option<VerdictContract>,
727}
728
729/// Agent persona information. Orthogonal to the backend kind (Shell / InProc / Operator).
730///
731/// Populated by `agent_md_loader::load_dir` from the frontmatter and Markdown body of
732/// `agents/*.md` in agent-profiles. The backend (e.g. AgentBlockOperator) receives this
733/// struct at construction / dispatch time and consumes `system_prompt` as the LLM API
734/// system message and `model` / `tools` as configuration.
735///
736/// C-C-specific fields (`permissionMode` / `memory` / `abtest`, etc.) are dumped into
737/// `extras: Value`, and consumers that need them read them out. This is the escape hatch
738/// that keeps the schema future-proof rather than making it strict.
739#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
740#[serde(deny_unknown_fields)]
741pub struct AgentProfile {
742 /// Markdown body (= system prompt content).
743 #[serde(default)]
744 pub system_prompt: String,
745 /// LLM model identifier (e.g. `"sonnet"` / `"haiku"` / `"opus"`).
746 #[serde(default)]
747 pub model: Option<String>,
748 /// Reasoning effort (e.g. `"low"` / `"medium"` / `"high"`).
749 #[serde(default)]
750 pub effort: Option<String>,
751 /// List of available tool names (normalized from the CSV form in frontmatter).
752 #[serde(default)]
753 pub tools: Vec<String>,
754 /// Frontmatter `description`. A short one-line description.
755 #[serde(default)]
756 pub description: Option<String>,
757 /// C-C-specific / future-proof fields (permissionMode / memory / abtest / ...).
758 /// Shape is the leftover keys of the agent.md frontmatter dumped as a JSON object.
759 #[serde(default)]
760 pub extras: Value,
761 /// Content hash (blake3 32-byte hex) of the agent body (= `system_prompt`).
762 ///
763 /// # Purpose
764 ///
765 /// When the Enhance loop receives a Patch that replaces
766 /// `/agents/N/profile/system_prompt`, the post-hook in `patch_applier.lua`
767 /// recomputes this field (= new blake3 of the body) and updates it automatically.
768 /// This is the field that structurally prevents a Blueprint carrying a stale hash
769 /// from being committed.
770 ///
771 /// - `None` = hash not computed (= manually built agent, or a Blueprint predating this field)
772 /// - `Some(hex)` = latest hash at agent-profiles seed time or after PatchApplier
773 ///
774 /// Planned to be used as the cache-index key in `AgentStore`.
775 #[serde(default)]
776 pub version_hash: Option<String>,
777 /// Claude Code SubAgent definition name this agent binds to at spawn
778 /// time (e.g. "mse-worker-coder"). Why: the Blueprint is the single
779 /// source of truth for the declaration↔executor binding — an external
780 /// registry would duplicate what `tools` already declares and drift.
781 /// `None` is valid for agents whose operator backend never dispatches
782 /// a SubAgent (direct-LLM operators); WS thin-path operators require
783 /// it at compile time (see `Operator::requires_worker_binding`).
784 #[serde(default, skip_serializing_if = "Option::is_none")]
785 pub worker_binding: Option<String>,
786}
787
788/// SoT of the **Worker IMPL axis**. A closed enum managed inside Swarm and extended by
789/// variant addition through **explicit maintenance**. String lookup / escape hatches are
790/// deliberately not adopted.
791///
792/// This enum **expresses Worker IMPL directly**; dispatching to a host Spawner adapter is
793/// resolved by an internal Resolver on the compiler side (= callers see only the Worker
794/// IMPL viewpoint).
795///
796/// # Internal Resolver mapping (= currently a fixed 1:1, carry: priority list form)
797///
798/// | AgentKind | Host Spawner adapter |
799/// |---|---|
800/// | `Lua` | `InProcSpawner` (mlua VM eval) |
801/// | `RustFn` | `InProcSpawner` (Rust closure) |
802/// | `AgentBlock` | `InProcSpawner` (agent-block-core SDK in-process) |
803/// | `Subprocess` | `ProcessSpawner` (child process launch) |
804/// | `Operator` | `OperatorSpawner` (interactive role / Human-MainAI delegation) |
805#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, JsonSchema)]
806#[serde(rename_all = "snake_case")]
807pub enum AgentKind {
808 /// Lua script eval through the mlua VM (= factory-side registry looked up by `spec.fn_id`).
809 Lua,
810 /// Rust closure (= factory-side registry looked up by `spec.fn_id`).
811 RustFn,
812 /// Headless LLM agent via the agent-block-core SDK (in-process).
813 AgentBlock,
814 /// Child-process launch (= `spec.program` + `args`, via the ProcessSpawner path).
815 Subprocess,
816 /// Interactive Operator role (= MainAI / Human delegation, `spec.operator_ref`).
817 Operator,
818}
819
820// ──────────────────────────────────────────────────────────────────────────
821// VerdictContract / VerdictChannel (GH #50 — opt-in cond↔output-shape lint)
822// ──────────────────────────────────────────────────────────────────────────
823
824/// Opt-in per-agent declaration of the step OUTPUT shape a downstream
825/// `Branch`/`Loop` `cond` is allowed to structurally compare against — see
826/// the `blueprint-authoring.md` guide's "Returning verdicts to drive BP
827/// flow" section for the Pattern A/B shapes this mirrors. Consumed by the
828/// `mlua-swarm` core crate's `Compiler::compile` (a register-time,
829/// read-only lint over `Branch`/`Loop` `Eq`/`Ne`/`In` conds — no `flow`
830/// rewriting, no new `Expr` forms) and, as a follow-up, by the server's
831/// submit-time producer gate. `None` on [`AgentDef::verdict`] (the
832/// default) means neither enforcement point runs for that agent — the
833/// pre-GH-#50 behavior, byte-for-byte.
834#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
835#[serde(deny_unknown_fields)]
836pub struct VerdictContract {
837 /// Which OUTPUT channel carries the verdict token — see
838 /// [`VerdictChannel`].
839 pub channel: VerdictChannel,
840 /// Closed set of the verdict tokens this agent may emit through the
841 /// declared `channel` (e.g. `["PASS", "BLOCKED"]`). A `Branch`/`Loop`
842 /// cond's `Lit` operand(s) compared against this agent's declared
843 /// channel must be members of this set.
844 pub values: Vec<String>,
845}
846
847/// Which step OUTPUT channel a [`VerdictContract`] addresses — the two
848/// canonical submit shapes documented in the `blueprint-authoring.md`
849/// guide's "Returning verdicts to drive BP flow" section.
850#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
851#[serde(rename_all = "lowercase")]
852pub enum VerdictChannel {
853 /// Pattern A — the plain step OUTPUT body IS the verdict scalar; a cond
854 /// addresses it as the bare step output (`$.<step>`).
855 Body,
856 /// Pattern B — the verdict is staged as the named part `"verdict"`
857 /// alongside a separate plain-body report; a cond addresses it as
858 /// `$.<step>.parts.verdict` (equivalently `$.<step>.parts["verdict"]`
859 /// — both forms normalize to the same canonical [`Path`](mlua_flow_ir::Path) `Display`).
860 Part,
861}
862
863// ──────────────────────────────────────────────────────────────────────────
864// Runner / RunnerDef / WorkerModel / resolve_runner (GH #46 Milestone 2)
865// ──────────────────────────────────────────────────────────────────────────
866
867/// The execution shell an agent's Worker IMPL runs inside — holding tool
868/// grant, model selection, and runtime capabilities. Tier 1 of the GH #46
869/// 3-tier Worker model (Runner / Agent / Context).
870///
871/// Runner here is broader than the ADK / OpenAI Agents SDK Runner (a loop
872/// driver): it is the execution shell holding tool grant, model
873/// selection, and runtime capabilities. Loop driving itself is the
874/// backend's job (Claude Code harness / AgentBlock runtime).
875///
876/// Resolved per-agent by [`resolve_runner`]'s 5-step cascade; wiring the
877/// resolved value into the launch path is Milestone 3 — this Milestone
878/// only declares the shape and the pure resolver.
879#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
880#[serde(tag = "backend", rename_all = "snake_case", deny_unknown_fields)]
881pub enum Runner {
882 /// WS backend: Claude Code subagent wrapper. `variant` is the
883 /// wrapper's subagent_type; `tools` mirrors the wrapper frontmatter =
884 /// enforced grant.
885 WsClaudeCode {
886 /// The wrapper's `subagent_type` (= `WorkerBinding.variant` in the
887 /// `mlua-swarm` core crate).
888 variant: String,
889 /// Declared (informational) tool list — mirrors the wrapper
890 /// frontmatter; the actual grant is enforced by the wrapper file
891 /// itself, not by this list.
892 #[serde(default, skip_serializing_if = "Vec::is_empty")]
893 tools: Vec<String>,
894 },
895 /// In-process backend: agent-block runtime. `tools` is the effective
896 /// (enforced) tool set for the in-process registry.
897 AgentBlockInProcess {
898 /// Effective (enforced) tool set passed to the agent-block
899 /// runtime's registry — unlike `WsClaudeCode::tools`, this list is
900 /// not merely informational.
901 #[serde(default, skip_serializing_if = "Vec::is_empty")]
902 tools: Vec<String>,
903 },
904}
905
906/// One [`Blueprint::runners`] registry entry — a named [`Runner`]
907/// declaration referenced by `AgentDef.runner_ref` /
908/// [`Blueprint::default_runner`]. Same registry shape as [`MetaDef`] (GH
909/// #21 Phase 2).
910#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
911#[serde(deny_unknown_fields)]
912pub struct RunnerDef {
913 /// Registry key, referenced by `AgentDef.runner_ref` /
914 /// `Blueprint.default_runner`.
915 pub name: String,
916 /// The declared Runner.
917 pub runner: Runner,
918}
919
920/// Canonical GH #46 Worker unit: a resolved [`Runner`] paired with the
921/// [`AgentDef`] it backs. The Milestone 4 adapter is the consumer that
922/// turns this into a runtime spawn; this crate only declares the shape
923/// (no execution logic lives here — see the crate doc's IN-immutability
924/// discipline).
925#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
926#[serde(deny_unknown_fields)]
927pub struct WorkerModel {
928 /// The resolved Runner.
929 pub runner: Runner,
930 /// The agent this Runner backs.
931 pub agent: AgentDef,
932}
933
934/// Everything [`resolve_runner`] can fail with: an `AgentDef.runner_ref`
935/// / `Blueprint.default_runner` reference that names no entry in
936/// `Blueprint.runners`.
937#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
938pub enum RunnerResolveError {
939 /// `AgentDef.runner_ref` names a [`RunnerDef::name`] absent from
940 /// `Blueprint.runners`.
941 #[error(
942 "agent '{agent}' runner_ref '{ref_name}' does not match any RunnerDef.name in \
943 Blueprint.runners (defined: {available:?})"
944 )]
945 UnknownRunnerRef {
946 /// The agent whose `runner_ref` didn't resolve.
947 agent: String,
948 /// The `runner_ref` value that was looked up.
949 ref_name: String,
950 /// The `RunnerDef.name`s that *are* declared, for the error message.
951 available: Vec<String>,
952 },
953 /// `Blueprint.default_runner` names a [`RunnerDef::name`] absent from
954 /// `Blueprint.runners`.
955 #[error(
956 "default_runner '{ref_name}' does not match any RunnerDef.name in Blueprint.runners \
957 (defined: {available:?})"
958 )]
959 UnknownDefaultRunner {
960 /// The `default_runner` value that was looked up.
961 ref_name: String,
962 /// The `RunnerDef.name`s that *are* declared, for the error message.
963 available: Vec<String>,
964 },
965}
966
967/// Resolve `agent`'s effective [`Runner`] against `bp`, in cascade order
968/// (highest priority first):
969///
970/// 1. `agent.runner` (inline declaration) — wins unconditionally.
971/// 2. `agent.runner_ref`, resolved against `bp.runners` (an unresolved
972/// name is [`RunnerResolveError::UnknownRunnerRef`]).
973/// 3. Legacy fallback (agent-level): `agent.profile.worker_binding =
974/// Some(variant)` becomes `Runner::WsClaudeCode { variant,
975/// tools: profile.tools.clone() }` — the same synthesis
976/// `crate::service::task_launch::derive_worker_bindings` (in the
977/// `mlua-swarm` core crate) performs at launch time today.
978/// 4. `bp.default_runner`, resolved against `bp.runners` (an unresolved
979/// name is [`RunnerResolveError::UnknownDefaultRunner`]).
980/// 5. `Ok(None)` — no Runner declared through any tier.
981///
982/// **Legacy (agent-level) beats `default_runner` (BP-global)**: tier 3
983/// outranks tier 4, the same "agent-level wins over BP-global" rule the
984/// ctx cascade (`AgentInline > MetaRef > BpGlobal`, see
985/// `mlua-swarm`'s `core::explain::CtxTier`) already follows.
986///
987/// Pure and read-only: this Milestone does not wire the result into the
988/// launch / compile path (Milestone 3 scope) — it only declares the
989/// resolver.
990pub fn resolve_runner(
991 bp: &Blueprint,
992 agent: &AgentDef,
993) -> Result<Option<Runner>, RunnerResolveError> {
994 // 1. inline — wins unconditionally.
995 if let Some(runner) = &agent.runner {
996 return Ok(Some(runner.clone()));
997 }
998
999 // 2. runner_ref → bp.runners lookup.
1000 if let Some(ref_name) = &agent.runner_ref {
1001 return match bp.runners.iter().find(|def| &def.name == ref_name) {
1002 Some(def) => Ok(Some(def.runner.clone())),
1003 None => Err(RunnerResolveError::UnknownRunnerRef {
1004 agent: agent.name.clone(),
1005 ref_name: ref_name.clone(),
1006 available: bp.runners.iter().map(|d| d.name.clone()).collect(),
1007 }),
1008 };
1009 }
1010
1011 // 3. legacy fallback (agent-level `profile.worker_binding`) — outranks
1012 // `bp.default_runner` (tier 4).
1013 if let Some(variant) = agent
1014 .profile
1015 .as_ref()
1016 .and_then(|p| p.worker_binding.as_ref())
1017 {
1018 let tools = agent
1019 .profile
1020 .as_ref()
1021 .map(|p| p.tools.clone())
1022 .unwrap_or_default();
1023 return Ok(Some(Runner::WsClaudeCode {
1024 variant: variant.clone(),
1025 tools,
1026 }));
1027 }
1028
1029 // 4. bp.default_runner → bp.runners lookup.
1030 if let Some(ref_name) = &bp.default_runner {
1031 return match bp.runners.iter().find(|def| &def.name == ref_name) {
1032 Some(def) => Ok(Some(def.runner.clone())),
1033 None => Err(RunnerResolveError::UnknownDefaultRunner {
1034 ref_name: ref_name.clone(),
1035 available: bp.runners.iter().map(|d| d.name.clone()).collect(),
1036 }),
1037 };
1038 }
1039
1040 // 5. nothing declared through any tier.
1041 Ok(None)
1042}
1043
1044// ──────────────────────────────────────────────────────────────────────────
1045// OperatorDef / OperatorKind
1046// ──────────────────────────────────────────────────────────────────────────
1047
1048/// Kind axis of an Operator role (= "in which mode does this Operator run").
1049/// Corresponds 1:1 with the engine's runtime `OperatorKind`. Kept as a schema
1050/// duplicate so that BPs can be authored while depending only on this crate.
1051#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
1052#[serde(rename_all = "snake_case")]
1053pub enum OperatorKind {
1054 /// MainAI (= interactive AI Operator via WS client or SDK).
1055 MainAi,
1056 /// Automate (= normal spawn path, without human interception).
1057 #[default]
1058 Automate,
1059 /// Composite (= MainAi + Automate running side by side).
1060 Composite,
1061}
1062
1063/// Design-time definition of an Operator role (first-class).
1064///
1065/// `AgentDef.spec.operator_ref` references this struct's `name` as a logical role name.
1066/// Binding to a runtime backend (WS session / SDK / pool, etc.) is established via the
1067/// attach path; the BP side only declares "under this logical name we expect an Operator
1068/// of this Kind".
1069///
1070/// `spec` is an escape hatch for kind-specific config (WS endpoint / SDK profile / pool
1071/// binding, etc.). Even when empty, declaring `name` + `kind` alone is enough for
1072/// compile-time validation to succeed (= it guarantees that agent `operator_ref` values
1073/// reference an existing definition).
1074#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
1075#[serde(deny_unknown_fields)]
1076pub struct OperatorDef {
1077 /// Logical role name (= design-time symbol referenced from `AgentDef.spec.operator_ref`).
1078 pub name: String,
1079 /// Display name for UI / docs (optional).
1080 #[serde(default)]
1081 pub display_name: Option<String>,
1082 /// Kind axis of the Operator (MainAi / Automate / Composite) — the "BP
1083 /// Agent-level" tier of the 4-tier `OperatorKind` cascade (see
1084 /// `Blueprint.default_operator_kind` for the full tier list). `None`
1085 /// when this `OperatorDef` does not declare a kind; the resolver then
1086 /// falls through to BP Global / Default Fallback for agents referencing
1087 /// this role via `AgentDef.spec.operator_ref`.
1088 #[serde(default)]
1089 pub kind: Option<OperatorKind>,
1090 /// Kind-specific config (WS endpoint / SDK profile / pool binding, etc.). Interpreted
1091 /// by the factory.
1092 #[serde(default)]
1093 pub spec: Value,
1094 /// Operator persona information (e.g. system_prompt template). Same shape as
1095 /// `AgentDef.profile`. Used as a template when the Operator itself plays a "role".
1096 /// If `None`, the agent-side profile is used instead.
1097 #[serde(default)]
1098 pub profile: Option<AgentProfile>,
1099 /// Operator-level metadata (description / version / tags).
1100 #[serde(default)]
1101 pub meta: Option<AgentMeta>,
1102}
1103
1104/// Named, multi-step-shared declarative context payload (GH #21 Phase 2).
1105///
1106/// Lives in the [`Blueprint::metas`] pool and is referenced by name from
1107/// two independent consumers: a `$step_meta.ref` envelope embedded in a
1108/// Step's evaluated `in` value (the Step tier, resolved by
1109/// `EngineDispatcher::dispatch` in the `mlua-swarm` core crate at
1110/// dispatch time — see `EngineDispatcher::with_step_metas`), and
1111/// [`AgentMeta::meta_ref`] (the Agent tier, resolved at launch time and
1112/// merged UNDER the agent's inline `AgentMeta::ctx`). The pool lets
1113/// multiple Steps and/or Agents share one declarative context object by
1114/// name instead of repeating it inline.
1115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
1116#[serde(deny_unknown_fields)]
1117pub struct MetaDef {
1118 /// Logical name (= referenced by `$step_meta.ref` and
1119 /// `AgentMeta.meta_ref`; unique within [`Blueprint::metas`]).
1120 pub name: String,
1121 /// Declarative context payload. Consumers expect a JSON `Object` so
1122 /// it can be shallow-merged with an `inline` override / an agent's
1123 /// own `ctx` (a non-`Object` value is rejected — loudly at dispatch
1124 /// time for the Step tier, defensively (warn + skip) at launch time
1125 /// for the Agent tier); the shape is otherwise free-form.
1126 pub ctx: Value,
1127}
1128
1129/// GH #27 (follow-up to #23) — Blueprint-declared override of the
1130/// `mlua-swarm` core crate's placement resolver
1131/// (`mlua_swarm::core::projection_placement::ProjectionPlacement`), which
1132/// decides where a Step's materialized OUTPUT file (submit-time sink,
1133/// server read-back, and spawn-time `ctx_projection` pointer — the "3
1134/// path" convergence point) is written on disk. Both fields are
1135/// independently optional and validated (`dir_template`) at
1136/// `Compiler::compile` time — see that resolver's `from_spec` doc for the
1137/// full rejection rules.
1138#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1139#[serde(deny_unknown_fields)]
1140pub struct ProjectionPlacementSpec {
1141 /// Which of the spawn-time `work_dir` / `project_root` to prefer as
1142 /// the materialize root, falling back to the other when the
1143 /// preferred one is absent. `"work_dir"` (default, current
1144 /// byte-compat behavior) | `"project_root"`. `None` = the default
1145 /// (`"work_dir"`).
1146 #[serde(default, skip_serializing_if = "Option::is_none")]
1147 pub root: Option<String>,
1148 /// Target directory template, relative to the resolved root, with a
1149 /// `{task_id}` placeholder substituted at materialize time. `None` =
1150 /// the default (`"workspace/tasks/{task_id}/ctx"`, current byte-compat
1151 /// behavior). Must be non-empty, contain the `{task_id}` placeholder,
1152 /// stay relative, and not contain any `..` path segment — rejected at
1153 /// `Compiler::compile` time otherwise.
1154 #[serde(default, skip_serializing_if = "Option::is_none")]
1155 pub dir_template: Option<String>,
1156}
1157
1158/// Agent / Operator level metadata (description / version / tags).
1159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
1160#[serde(deny_unknown_fields)]
1161pub struct AgentMeta {
1162 /// Short human-readable description.
1163 #[serde(default)]
1164 pub description: Option<String>,
1165 /// Free-form version label.
1166 #[serde(default)]
1167 pub version: Option<String>,
1168 /// Tag list for classification / routing.
1169 #[serde(default)]
1170 pub tags: Vec<String>,
1171 /// GH #21 Phase 1 — "BP Agent-level" tier of the agent-context supply
1172 /// axis: a declarative object merged into `ctx.meta.runtime` for this
1173 /// agent's spawns, on top of (and winning over)
1174 /// [`Blueprint::default_agent_ctx`]. See that field's doc for the
1175 /// contrast with `default_init_ctx`. `None` = this agent declares no
1176 /// per-agent context (the BP-global tier alone applies, if any).
1177 #[serde(default, skip_serializing_if = "Option::is_none")]
1178 #[schemars(with = "Option<Value>")]
1179 pub ctx: Option<Value>,
1180 /// GH #21 Phase 1 — "BP Agent-level" tier of the [`ContextPolicy`]
1181 /// cascade: outranks [`Blueprint::default_context_policy`] for this
1182 /// agent. `None` = fall through to the BP-global policy (or pass-all
1183 /// if that is also `None`).
1184 #[serde(default, skip_serializing_if = "Option::is_none")]
1185 pub context_policy: Option<ContextPolicy>,
1186 /// GH #21 Phase 2 — "BP Agent-level" tier of the [`MetaDef`] pool:
1187 /// resolves against [`Blueprint::metas`] by name. The resolved
1188 /// `ctx` sits UNDER this agent's inline [`Self::ctx`] (inline wins
1189 /// on key collision). `None` = this agent declares no shared
1190 /// `MetaDef` reference.
1191 #[serde(default, skip_serializing_if = "Option::is_none")]
1192 pub meta_ref: Option<String>,
1193 /// GH #23 — the step-projection canonical name this agent's dispatched
1194 /// Steps should be addressed by (data-plane submit / `ContextPolicy`
1195 /// filter / `StepPointer`/`StepSummary` `name` / REST `:step` path /
1196 /// materialized file stem — see `mlua-swarm` core's
1197 /// `core::step_naming::StepNaming` for the table this field feeds).
1198 /// `None` = this agent declares no projection name; the canonical
1199 /// name falls back to the Step's `ref` (the flow.ir data-plane
1200 /// producer name), matching pre-GH-#23 behavior byte-for-byte.
1201 #[serde(default, skip_serializing_if = "Option::is_none")]
1202 pub projection_name: Option<String>,
1203}
1204
1205// ──────────────────────────────────────────────────────────────────────────
1206// Compiler hints / strategy
1207// ──────────────────────────────────────────────────────────────────────────
1208
1209/// Per-agent overrides / hints. Interpreted by the Compiler / SpawnerFactory; not required.
1210#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
1211#[serde(deny_unknown_fields)]
1212pub struct CompilerHints {
1213 /// Agent name → per-agent hint (= passed to `SpawnerFactory.build`).
1214 #[serde(default)]
1215 pub per_agent: HashMap<String, Value>,
1216 /// Global hints (= e.g. parallel limit, default timeout, ...).
1217 #[serde(default)]
1218 pub global: Value,
1219}
1220
1221/// Compiler behavior rules. Controls strict / lenient handling and default fallback.
1222#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
1223#[serde(deny_unknown_fields)]
1224pub struct CompilerStrategy {
1225 /// If `true` (default), an unresolved `Step.ref` is an error; if `false`, it falls
1226 /// through to the default Spawner.
1227 #[serde(default = "default_true")]
1228 pub strict_refs: bool,
1229 /// If `true` (default), an `AgentKind` missing from the registry is an error; if
1230 /// `false`, it is skipped.
1231 #[serde(default = "default_true")]
1232 pub strict_kind: bool,
1233}
1234
1235fn default_true() -> bool {
1236 true
1237}
1238
1239impl Default for CompilerStrategy {
1240 fn default() -> Self {
1241 Self {
1242 strict_refs: true,
1243 strict_kind: true,
1244 }
1245 }
1246}
1247
1248// ──────────────────────────────────────────────────────────────────────────
1249// Blueprint metadata / origin
1250// ──────────────────────────────────────────────────────────────────────────
1251
1252/// Blueprint-level metadata (description / origin / tags / ttl / version label / alias).
1253#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
1254#[serde(deny_unknown_fields)]
1255pub struct BlueprintMetadata {
1256 /// Short human-readable description of the Blueprint.
1257 #[serde(default)]
1258 pub description: Option<String>,
1259 /// Provenance record (inline / file / algocline).
1260 #[serde(default)]
1261 pub origin: BlueprintOrigin,
1262 /// Tag list for classification / routing.
1263 #[serde(default)]
1264 pub tags: Vec<String>,
1265 /// Optional SemVer label (= match target for `TaskPipeline VersionSelector::SemVerReq`).
1266 /// Example: `"1.2.3"`. Rewritten by `EnhanceAdapter` on PATCH/MINOR/MAJOR bumps.
1267 #[serde(default, skip_serializing_if = "Option::is_none")]
1268 pub version_label: Option<String>,
1269 /// Optional LDS session alias label. The Swarm engine itself does not apply this
1270 /// (= it is free-form content); the value is expanded into the Spawn directive and
1271 /// reaches the MainAI. The MainAI is expected to establish a task session via
1272 /// `mcp__lds__session_create(root=..., alias=<this>)`, and to inject
1273 /// `LDS Session Alias: <this>` verbatim into the SubAgent dispatch prompt body.
1274 /// The SubAgent body then calls `mcp__lds__session_start(alias=<this>)` with the
1275 /// received alias. Worktree ownership is thereby unified under a single session, and
1276 /// cross-SubAgent / cross-worktree ownership blocks (= `not owned by this session`)
1277 /// cannot fire structurally.
1278 #[serde(default, skip_serializing_if = "Option::is_none")]
1279 pub project_name_alias: Option<String>,
1280 /// Optional default TTL (seconds) for tasks dispatched via this BP. Estimated by the
1281 /// Blueprint author from the flow shape (agent count × expected duration per agent).
1282 /// If `POST /v1/tasks` supplies `ttl_secs` explicitly, the body value wins; otherwise
1283 /// this metadata field is used as the default; if both are absent, the server global
1284 /// default (`default_run_ttl()` = 1800s) applies. Not needed for short chains (~5 min);
1285 /// recommended for long chains (14 agents × several minutes = 30-60 min).
1286 #[serde(default, skip_serializing_if = "Option::is_none")]
1287 pub default_run_ttl_secs: Option<u64>,
1288 /// GH #50 follow-up (issue `33bc825b`): promote `VerdictValueUnhandled`
1289 /// compile-time lint to a hard error. When `false` (or absent), a
1290 /// declared `AgentDef.verdict.values` entry that no downstream cond
1291 /// references is only surfaced via `tracing::warn!` (informational);
1292 /// when `true`, `Compiler::compile` rejects the Blueprint with
1293 /// `CompileError::VerdictValueUnhandled`. Opt-in so existing Blueprints
1294 /// that intentionally leave some verdict values as silent-pass
1295 /// informational tokens keep compiling unchanged.
1296 #[serde(default, skip_serializing_if = "Option::is_none")]
1297 pub strict_verdict_handling: Option<bool>,
1298}
1299
1300/// Provenance record of a Blueprint.
1301#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
1302#[serde(tag = "kind", rename_all = "snake_case")]
1303pub enum BlueprintOrigin {
1304 /// Inline construction, e.g. via a Rust struct literal or test code.
1305 #[default]
1306 Inline,
1307 /// Loaded from a file.
1308 File {
1309 /// Source file path.
1310 path: String,
1311 },
1312 /// Emitted by an algocline strategy (traced by `session_id`).
1313 Algo {
1314 /// Algocline session identifier.
1315 session_id: String,
1316 },
1317}
1318
1319#[cfg(test)]
1320mod tests {
1321 use super::*;
1322
1323 #[test]
1324 fn schema_version_default_parses() {
1325 let v = default_schema_version();
1326 assert_eq!(v.to_string(), "0.1.0");
1327 }
1328
1329 #[test]
1330 fn current_schema_version_const_matches() {
1331 assert_eq!(CURRENT_SCHEMA_VERSION, "0.1.0");
1332 }
1333
1334 #[test]
1335 fn blueprint_json_schema_exports_key_properties() {
1336 let schema = schemars::schema_for!(Blueprint);
1337 let v = serde_json::to_value(&schema).expect("schema serializes");
1338 let props = v["properties"].as_object().expect("object schema");
1339 for key in [
1340 "schema_version",
1341 "id",
1342 "flow",
1343 "agents",
1344 "operators",
1345 "metas",
1346 "hints",
1347 "strategy",
1348 "metadata",
1349 "spawner_hints",
1350 "default_agent_kind",
1351 "default_operator_kind",
1352 "default_init_ctx",
1353 "default_agent_ctx",
1354 "default_context_policy",
1355 "projection_placement",
1356 "audits",
1357 "runners",
1358 "default_runner",
1359 "check_policy",
1360 ] {
1361 assert!(props.contains_key(key), "missing property: {key}");
1362 }
1363 // semver override lands as a plain string
1364 assert_eq!(v["properties"]["schema_version"]["type"], "string");
1365 // enum variants (snake_case) survive into the schema (LLM author axis)
1366 let dump = v.to_string();
1367 assert!(dump.contains("agent_block"), "AgentKind variants in schema");
1368 assert!(dump.contains("main_ai"), "OperatorKind variants in schema");
1369 // nested defs are referenced (AgentDef reachable from agents[])
1370 assert!(dump.contains("AgentDef"), "AgentDef definition in schema");
1371 }
1372
1373 #[test]
1374 fn agent_profile_worker_binding_roundtrips_when_some() {
1375 let profile = AgentProfile {
1376 worker_binding: Some("mse-worker-coder".to_string()),
1377 ..Default::default()
1378 };
1379 let json = serde_json::to_value(&profile).expect("serializes");
1380 assert_eq!(json["worker_binding"], "mse-worker-coder");
1381 let back: AgentProfile = serde_json::from_value(json).expect("deserializes");
1382 assert_eq!(back.worker_binding.as_deref(), Some("mse-worker-coder"));
1383 }
1384
1385 #[test]
1386 fn agent_profile_worker_binding_omitted_when_none() {
1387 let profile = AgentProfile::default();
1388 let json = serde_json::to_value(&profile).expect("serializes");
1389 // `skip_serializing_if = "Option::is_none"` — the key must not appear at all.
1390 assert!(
1391 json.as_object().unwrap().get("worker_binding").is_none(),
1392 "worker_binding key must be absent when None: {json}"
1393 );
1394 let back: AgentProfile = serde_json::from_value(json).expect("deserializes");
1395 assert_eq!(back.worker_binding, None);
1396 }
1397
1398 // ──────────────────────────────────────────────────────────────
1399 // issue #19 ST3: `Blueprint.default_init_ctx`
1400 // ──────────────────────────────────────────────────────────────
1401
1402 fn minimal_bp(default_init_ctx: Option<Value>) -> Blueprint {
1403 Blueprint {
1404 schema_version: current_schema_version(),
1405 id: "bp-init-ctx-ut".into(),
1406 flow: FlowNode::Seq { children: vec![] },
1407 agents: vec![],
1408 operators: vec![],
1409 metas: vec![],
1410 hints: Default::default(),
1411 strategy: Default::default(),
1412 metadata: Default::default(),
1413 spawner_hints: Default::default(),
1414 default_agent_kind: AgentKind::Operator,
1415 default_operator_kind: None,
1416 default_init_ctx,
1417 default_agent_ctx: None,
1418 default_context_policy: None,
1419 projection_placement: None,
1420 audits: vec![],
1421 degradation_policy: None,
1422 runners: vec![],
1423 default_runner: None,
1424 check_policy: None,
1425 blueprint_ref_includes: Vec::new(),
1426 }
1427 }
1428
1429 #[test]
1430 fn blueprint_default_init_ctx_roundtrips_when_some() {
1431 let bp = minimal_bp(Some(serde_json::json!({ "seeded": true })));
1432 let json = serde_json::to_string(&bp).expect("serializes");
1433 let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
1434 assert_eq!(
1435 back.default_init_ctx,
1436 Some(serde_json::json!({ "seeded": true }))
1437 );
1438 assert_eq!(bp, back);
1439 }
1440
1441 #[test]
1442 fn blueprint_default_init_ctx_omitted_when_none() {
1443 let bp = minimal_bp(None);
1444 let json = serde_json::to_value(&bp).expect("serializes");
1445 // `skip_serializing_if = "Option::is_none"` — the key must not appear at all
1446 // (pre-#19 Blueprints round-trip byte-identical through this path).
1447 assert!(
1448 json.as_object().unwrap().get("default_init_ctx").is_none(),
1449 "default_init_ctx key must be absent when None: {json}"
1450 );
1451 let back: Blueprint = serde_json::from_value(json).expect("deserializes");
1452 assert_eq!(back.default_init_ctx, None);
1453 assert_eq!(bp, back);
1454 }
1455
1456 #[test]
1457 fn blueprint_json_schema_exports_default_init_ctx_as_nullable_value() {
1458 let schema = schemars::schema_for!(Blueprint);
1459 let v = serde_json::to_value(&schema).expect("schema serializes");
1460 assert!(
1461 v["properties"]["default_init_ctx"].is_object(),
1462 "default_init_ctx must appear in the exported schema: {v}"
1463 );
1464 }
1465
1466 // ──────────────────────────────────────────────────────────────
1467 // issue #21 Phase 1: `Blueprint.default_agent_ctx` /
1468 // `default_context_policy`, `AgentMeta.ctx` / `context_policy`,
1469 // `ContextPolicy`
1470 // ──────────────────────────────────────────────────────────────
1471
1472 #[test]
1473 fn blueprint_default_agent_ctx_and_context_policy_roundtrip_when_some() {
1474 let mut bp = minimal_bp(None);
1475 bp.default_agent_ctx = Some(serde_json::json!({ "org_conventions": "x" }));
1476 bp.default_context_policy = Some(ContextPolicy {
1477 include: Some(vec!["project_root".to_string()]),
1478 exclude: vec!["work_dir".to_string()],
1479 ..Default::default()
1480 });
1481 let json = serde_json::to_string(&bp).expect("serializes");
1482 let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
1483 assert_eq!(bp, back);
1484 assert_eq!(
1485 back.default_agent_ctx,
1486 Some(serde_json::json!({ "org_conventions": "x" }))
1487 );
1488 assert_eq!(
1489 back.default_context_policy,
1490 Some(ContextPolicy {
1491 include: Some(vec!["project_root".to_string()]),
1492 exclude: vec!["work_dir".to_string()],
1493 ..Default::default()
1494 })
1495 );
1496 }
1497
1498 #[test]
1499 fn blueprint_default_agent_ctx_and_context_policy_omitted_when_none() {
1500 let bp = minimal_bp(None);
1501 let json = serde_json::to_value(&bp).expect("serializes");
1502 let obj = json.as_object().unwrap();
1503 assert!(
1504 obj.get("default_agent_ctx").is_none(),
1505 "default_agent_ctx key must be absent when None: {json}"
1506 );
1507 assert!(
1508 obj.get("default_context_policy").is_none(),
1509 "default_context_policy key must be absent when None: {json}"
1510 );
1511 let back: Blueprint = serde_json::from_value(json).expect("deserializes");
1512 assert_eq!(back.default_agent_ctx, None);
1513 assert_eq!(back.default_context_policy, None);
1514 assert_eq!(bp, back);
1515 }
1516
1517 #[test]
1518 fn blueprint_json_schema_exports_agent_ctx_and_context_policy() {
1519 let schema = schemars::schema_for!(Blueprint);
1520 let v = serde_json::to_value(&schema).expect("schema serializes");
1521 assert!(
1522 v["properties"]["default_agent_ctx"].is_object(),
1523 "default_agent_ctx must appear in the exported schema: {v}"
1524 );
1525 assert!(
1526 v["properties"]["default_context_policy"].is_object(),
1527 "default_context_policy must appear in the exported schema: {v}"
1528 );
1529 }
1530
1531 // ──────────────────────────────────────────────────────────────
1532 // GH #27 (follow-up to #23): `Blueprint.projection_placement` /
1533 // `ProjectionPlacementSpec`
1534 // ──────────────────────────────────────────────────────────────
1535
1536 #[test]
1537 fn blueprint_projection_placement_roundtrips_when_some() {
1538 let mut bp = minimal_bp(None);
1539 bp.projection_placement = Some(ProjectionPlacementSpec {
1540 root: Some("project_root".to_string()),
1541 dir_template: Some("custom/{task_id}/out".to_string()),
1542 });
1543 let json = serde_json::to_string(&bp).expect("serializes");
1544 let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
1545 assert_eq!(bp, back);
1546 assert_eq!(
1547 back.projection_placement,
1548 Some(ProjectionPlacementSpec {
1549 root: Some("project_root".to_string()),
1550 dir_template: Some("custom/{task_id}/out".to_string()),
1551 })
1552 );
1553 }
1554
1555 #[test]
1556 fn blueprint_projection_placement_omitted_when_none() {
1557 let bp = minimal_bp(None);
1558 let json = serde_json::to_value(&bp).expect("serializes");
1559 assert!(
1560 json.as_object()
1561 .unwrap()
1562 .get("projection_placement")
1563 .is_none(),
1564 "projection_placement key must be absent when None: {json}"
1565 );
1566 let back: Blueprint = serde_json::from_value(json).expect("deserializes");
1567 assert_eq!(back.projection_placement, None);
1568 assert_eq!(bp, back);
1569 }
1570
1571 #[test]
1572 fn blueprint_json_schema_exports_projection_placement() {
1573 let schema = schemars::schema_for!(Blueprint);
1574 let v = serde_json::to_value(&schema).expect("schema serializes");
1575 assert!(
1576 v["properties"]["projection_placement"].is_object(),
1577 "projection_placement must appear in the exported schema: {v}"
1578 );
1579 }
1580
1581 #[test]
1582 fn agent_meta_ctx_and_context_policy_roundtrip_when_some() {
1583 let meta = AgentMeta {
1584 ctx: Some(serde_json::json!({ "k": "v" })),
1585 context_policy: Some(ContextPolicy {
1586 include: None,
1587 exclude: vec!["run_id".to_string()],
1588 ..Default::default()
1589 }),
1590 ..Default::default()
1591 };
1592 let json = serde_json::to_value(&meta).expect("serializes");
1593 let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
1594 assert_eq!(back, meta);
1595 }
1596
1597 #[test]
1598 fn agent_meta_ctx_and_context_policy_omitted_when_none() {
1599 let meta = AgentMeta::default();
1600 let json = serde_json::to_value(&meta).expect("serializes");
1601 let obj = json.as_object().unwrap();
1602 assert!(
1603 obj.get("ctx").is_none(),
1604 "ctx key must be absent when None: {json}"
1605 );
1606 assert!(
1607 obj.get("context_policy").is_none(),
1608 "context_policy key must be absent when None: {json}"
1609 );
1610 }
1611
1612 #[test]
1613 fn agent_meta_json_schema_exports_ctx_context_policy_and_meta_ref() {
1614 let schema = schemars::schema_for!(AgentMeta);
1615 let v = serde_json::to_value(&schema).expect("schema serializes");
1616 let props = v["properties"].as_object().expect("object schema");
1617 for key in [
1618 "description",
1619 "version",
1620 "tags",
1621 "ctx",
1622 "context_policy",
1623 "meta_ref",
1624 "projection_name",
1625 ] {
1626 assert!(props.contains_key(key), "missing property: {key}");
1627 }
1628 }
1629
1630 // ──────────────────────────────────────────────────────────────
1631 // issue #21 Phase 2: `MetaDef`, `Blueprint.metas`, `AgentMeta.meta_ref`
1632 // ──────────────────────────────────────────────────────────────
1633
1634 #[test]
1635 fn meta_def_roundtrips_through_json() {
1636 let def = MetaDef {
1637 name: "heavy-scan".to_string(),
1638 ctx: serde_json::json!({ "work_dir": "/x" }),
1639 };
1640 let json = serde_json::to_value(&def).expect("serializes");
1641 let back: MetaDef = serde_json::from_value(json).expect("deserializes");
1642 assert_eq!(back, def);
1643 }
1644
1645 #[test]
1646 fn blueprint_metas_omitted_when_empty() {
1647 let bp = minimal_bp(None);
1648 let json = serde_json::to_value(&bp).expect("serializes");
1649 assert!(
1650 json.as_object().unwrap().get("metas").is_none(),
1651 "metas key must be absent when empty: {json}"
1652 );
1653 let back: Blueprint = serde_json::from_value(json).expect("deserializes");
1654 assert!(back.metas.is_empty());
1655 assert_eq!(bp, back);
1656 }
1657
1658 #[test]
1659 fn blueprint_metas_roundtrips_when_non_empty() {
1660 let mut bp = minimal_bp(None);
1661 bp.metas = vec![MetaDef {
1662 name: "heavy-scan".to_string(),
1663 ctx: serde_json::json!({ "work_dir": "/x" }),
1664 }];
1665 let json = serde_json::to_string(&bp).expect("serializes");
1666 let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
1667 assert_eq!(bp, back);
1668 assert_eq!(back.metas.len(), 1);
1669 assert_eq!(back.metas[0].name, "heavy-scan");
1670 }
1671
1672 #[test]
1673 fn blueprint_json_schema_exports_metas() {
1674 let schema = schemars::schema_for!(Blueprint);
1675 let v = serde_json::to_value(&schema).expect("schema serializes");
1676 assert!(
1677 v["properties"]["metas"].is_object(),
1678 "metas must appear in the exported schema: {v}"
1679 );
1680 let dump = v.to_string();
1681 assert!(dump.contains("MetaDef"), "MetaDef definition in schema");
1682 }
1683
1684 #[test]
1685 fn agent_meta_meta_ref_roundtrips_when_some() {
1686 let meta = AgentMeta {
1687 meta_ref: Some("heavy-scan".to_string()),
1688 ..Default::default()
1689 };
1690 let json = serde_json::to_value(&meta).expect("serializes");
1691 assert_eq!(json["meta_ref"], "heavy-scan");
1692 let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
1693 assert_eq!(back, meta);
1694 }
1695
1696 #[test]
1697 fn agent_meta_meta_ref_omitted_when_none() {
1698 let meta = AgentMeta::default();
1699 let json = serde_json::to_value(&meta).expect("serializes");
1700 assert!(
1701 json.as_object().unwrap().get("meta_ref").is_none(),
1702 "meta_ref key must be absent when None: {json}"
1703 );
1704 let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
1705 assert_eq!(back.meta_ref, None);
1706 }
1707
1708 // ──────────────────────────────────────────────────────────────
1709 // GH #23: `AgentMeta.projection_name`
1710 // ──────────────────────────────────────────────────────────────
1711
1712 #[test]
1713 fn agent_meta_projection_name_roundtrips_when_some() {
1714 let meta = AgentMeta {
1715 projection_name: Some("plan".to_string()),
1716 ..Default::default()
1717 };
1718 let json = serde_json::to_value(&meta).expect("serializes");
1719 assert_eq!(json["projection_name"], "plan");
1720 let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
1721 assert_eq!(back, meta);
1722 }
1723
1724 #[test]
1725 fn agent_meta_projection_name_omitted_when_none() {
1726 let meta = AgentMeta::default();
1727 let json = serde_json::to_value(&meta).expect("serializes");
1728 assert!(
1729 json.as_object().unwrap().get("projection_name").is_none(),
1730 "projection_name key must be absent when None: {json}"
1731 );
1732 let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
1733 assert_eq!(back.projection_name, None);
1734 assert_eq!(back, meta);
1735 }
1736
1737 #[test]
1738 fn agent_meta_rejects_unknown_field_with_projection_name_present() {
1739 // `deny_unknown_fields` must still reject an unrelated stray key
1740 // even when `projection_name` is present alongside it (regression
1741 // guard: adding the field must not accidentally loosen the
1742 // contract for the rest of the struct).
1743 let json = serde_json::json!({
1744 "projection_name": "plan",
1745 "not_a_real_field": true
1746 });
1747 let err = serde_json::from_value::<AgentMeta>(json).unwrap_err();
1748 assert!(
1749 err.to_string().contains("not_a_real_field")
1750 || err.to_string().contains("unknown field"),
1751 "expected an unknown-field rejection, got: {err}"
1752 );
1753 }
1754
1755 #[test]
1756 fn context_policy_default_allows_everything() {
1757 let policy = ContextPolicy::default();
1758 assert!(policy.allows("project_root"));
1759 assert!(policy.allows("anything"));
1760 }
1761
1762 #[test]
1763 fn context_policy_include_only_allows_listed_names() {
1764 let policy = ContextPolicy {
1765 include: Some(vec!["project_root".to_string()]),
1766 exclude: vec![],
1767 ..Default::default()
1768 };
1769 assert!(policy.allows("project_root"));
1770 assert!(!policy.allows("work_dir"));
1771 }
1772
1773 #[test]
1774 fn context_policy_exclude_wins_over_include() {
1775 let policy = ContextPolicy {
1776 include: Some(vec!["project_root".to_string()]),
1777 exclude: vec!["project_root".to_string()],
1778 ..Default::default()
1779 };
1780 assert!(!policy.allows("project_root"));
1781 }
1782
1783 #[test]
1784 fn context_policy_roundtrips_through_json() {
1785 let policy = ContextPolicy {
1786 include: Some(vec!["a".to_string(), "b".to_string()]),
1787 exclude: vec!["c".to_string()],
1788 ..Default::default()
1789 };
1790 let json = serde_json::to_value(&policy).expect("serializes");
1791 let back: ContextPolicy = serde_json::from_value(json).expect("deserializes");
1792 assert_eq!(back, policy);
1793 }
1794
1795 #[test]
1796 fn context_policy_default_roundtrips_as_empty_object() {
1797 let policy = ContextPolicy::default();
1798 let json = serde_json::to_value(&policy).expect("serializes");
1799 assert_eq!(
1800 json,
1801 serde_json::json!({
1802 "include": null,
1803 "exclude": [],
1804 "steps": null,
1805 "steps_exclude": [],
1806 })
1807 );
1808 let back: ContextPolicy = serde_json::from_value(json).expect("deserializes");
1809 assert_eq!(back, policy);
1810 }
1811
1812 // ──────────────────────────────────────────────────────────────
1813 // ST5 (`projection-adapter`): `ContextPolicy.steps` / `steps_exclude`
1814 // ──────────────────────────────────────────────────────────────
1815
1816 #[test]
1817 fn context_policy_steps_default_allows_every_step() {
1818 let policy = ContextPolicy::default();
1819 assert!(policy.allows_step("planner"));
1820 assert!(policy.allows_step("anything"));
1821 }
1822
1823 #[test]
1824 fn context_policy_steps_include_only_allows_listed_names() {
1825 let policy = ContextPolicy {
1826 steps: Some(vec!["planner".to_string()]),
1827 ..Default::default()
1828 };
1829 assert!(policy.allows_step("planner"));
1830 assert!(!policy.allows_step("coder"));
1831 }
1832
1833 #[test]
1834 fn context_policy_steps_empty_list_allows_none() {
1835 let policy = ContextPolicy {
1836 steps: Some(vec![]),
1837 ..Default::default()
1838 };
1839 assert!(!policy.allows_step("planner"));
1840 }
1841
1842 #[test]
1843 fn context_policy_steps_exclude_wins_over_steps() {
1844 let policy = ContextPolicy {
1845 steps: Some(vec!["planner".to_string()]),
1846 steps_exclude: vec!["planner".to_string()],
1847 ..Default::default()
1848 };
1849 assert!(!policy.allows_step("planner"));
1850 }
1851
1852 #[test]
1853 fn context_policy_steps_roundtrips_through_json() {
1854 let policy = ContextPolicy {
1855 steps: Some(vec!["planner".to_string(), "coder".to_string()]),
1856 steps_exclude: vec!["reviewer".to_string()],
1857 ..Default::default()
1858 };
1859 let json = serde_json::to_value(&policy).expect("serializes");
1860 let back: ContextPolicy = serde_json::from_value(json).expect("deserializes");
1861 assert_eq!(back, policy);
1862 }
1863
1864 // ──────────────────────────────────────────────────────────────
1865 // GH #34: `AuditDef`, `AuditMode`, `Blueprint.audits`
1866 // ──────────────────────────────────────────────────────────────
1867
1868 #[test]
1869 fn blueprint_audits_omitted_when_empty() {
1870 let bp = minimal_bp(None);
1871 let json = serde_json::to_value(&bp).expect("serializes");
1872 assert!(
1873 json.as_object().unwrap().get("audits").is_none(),
1874 "audits key must be absent when empty: {json}"
1875 );
1876 let back: Blueprint = serde_json::from_value(json).expect("deserializes");
1877 assert!(back.audits.is_empty());
1878 assert_eq!(bp, back);
1879 }
1880
1881 #[test]
1882 fn blueprint_audits_roundtrips_when_non_empty() {
1883 let mut bp = minimal_bp(None);
1884 bp.audits = vec![AuditDef {
1885 agent: "auditor".to_string(),
1886 steps: Some(vec!["worker".to_string()]),
1887 mode: AuditMode::Sync,
1888 }];
1889 let json = serde_json::to_string(&bp).expect("serializes");
1890 let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
1891 assert_eq!(bp, back);
1892 assert_eq!(back.audits.len(), 1);
1893 assert_eq!(back.audits[0].agent, "auditor");
1894 assert_eq!(back.audits[0].mode, AuditMode::Sync);
1895 }
1896
1897 #[test]
1898 fn audit_def_steps_none_and_mode_default_when_omitted() {
1899 let json = serde_json::json!({ "agent": "auditor" });
1900 let def: AuditDef = serde_json::from_value(json).expect("deserializes");
1901 assert_eq!(def.steps, None);
1902 assert_eq!(def.mode, AuditMode::Async);
1903 }
1904
1905 #[test]
1906 fn audit_def_rejects_unknown_field() {
1907 let json = serde_json::json!({ "agent": "auditor", "not_a_real_field": true });
1908 let err = serde_json::from_value::<AuditDef>(json).unwrap_err();
1909 assert!(
1910 err.to_string().contains("not_a_real_field")
1911 || err.to_string().contains("unknown field"),
1912 "expected an unknown-field rejection, got: {err}"
1913 );
1914 }
1915
1916 #[test]
1917 fn audit_mode_serializes_snake_case() {
1918 assert_eq!(
1919 serde_json::to_value(AuditMode::Async).unwrap(),
1920 serde_json::json!("async")
1921 );
1922 assert_eq!(
1923 serde_json::to_value(AuditMode::Sync).unwrap(),
1924 serde_json::json!("sync")
1925 );
1926 }
1927
1928 #[test]
1929 fn blueprint_json_schema_exports_audits_and_audit_def() {
1930 let schema = schemars::schema_for!(Blueprint);
1931 let v = serde_json::to_value(&schema).expect("schema serializes");
1932 assert!(
1933 v["properties"]["audits"].is_object(),
1934 "audits must appear in the exported schema: {v}"
1935 );
1936 let dump = v.to_string();
1937 assert!(dump.contains("AuditDef"), "AuditDef definition in schema");
1938 }
1939
1940 // ──────────────────────────────────────────────────────────────
1941 // GH #32: `Blueprint.degradation_policy`, `DegradationPolicy`
1942 // ──────────────────────────────────────────────────────────────
1943
1944 #[test]
1945 fn blueprint_without_degradation_policy_deserializes_to_none() {
1946 let json = serde_json::json!({
1947 "schema_version": current_schema_version(),
1948 "id": "no-degradation-policy-ut",
1949 "flow": { "kind": "seq", "children": [] },
1950 });
1951 let bp: Blueprint = serde_json::from_value(json).expect("deserializes");
1952 assert_eq!(bp.degradation_policy, None);
1953 }
1954
1955 #[test]
1956 fn blueprint_degradation_policy_omitted_when_none() {
1957 let bp = minimal_bp(None);
1958 let json = serde_json::to_value(&bp).expect("serializes");
1959 assert!(
1960 json.as_object()
1961 .unwrap()
1962 .get("degradation_policy")
1963 .is_none(),
1964 "degradation_policy key must be absent when None: {json}"
1965 );
1966 }
1967
1968 #[test]
1969 fn blueprint_degradation_policy_warn_and_fail_roundtrip() {
1970 for (label, expected) in [
1971 ("warn", DegradationPolicy::Warn),
1972 ("fail", DegradationPolicy::Fail),
1973 ] {
1974 let mut bp = minimal_bp(None);
1975 bp.degradation_policy = Some(expected);
1976 let json = serde_json::to_string(&bp).expect("serializes");
1977 assert!(json.contains(&format!("\"degradation_policy\":\"{label}\"")));
1978 let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
1979 assert_eq!(back.degradation_policy, Some(expected));
1980 }
1981 }
1982
1983 #[test]
1984 fn degradation_policy_rejects_unknown_variant() {
1985 let json = serde_json::json!({
1986 "schema_version": current_schema_version(),
1987 "id": "degradation-policy-unknown-variant-ut",
1988 "flow": { "kind": "seq", "children": [] },
1989 "degradation_policy": "ignore",
1990 });
1991 let err = serde_json::from_value::<Blueprint>(json).unwrap_err();
1992 assert!(
1993 err.to_string().contains("unknown variant"),
1994 "expected an unknown-variant rejection, got: {err}"
1995 );
1996 }
1997
1998 // ──────────────────────────────────────────────────────────────
1999 // GH #46 Milestone 2: `Runner`, `RunnerDef`, `WorkerModel`,
2000 // `Blueprint.runners` / `default_runner`, `AgentDef.runner` /
2001 // `runner_ref`, `resolve_runner`
2002 // ──────────────────────────────────────────────────────────────
2003
2004 fn agent_with_runner(
2005 name: &str,
2006 profile: Option<AgentProfile>,
2007 runner: Option<Runner>,
2008 runner_ref: Option<String>,
2009 ) -> AgentDef {
2010 AgentDef {
2011 name: name.to_string(),
2012 kind: AgentKind::RustFn,
2013 spec: serde_json::json!({ "fn_id": name }),
2014 profile,
2015 meta: None,
2016 runner,
2017 runner_ref,
2018 verdict: None,
2019 }
2020 }
2021
2022 fn ws_runner(variant: &str, tools: Vec<&str>) -> Runner {
2023 Runner::WsClaudeCode {
2024 variant: variant.to_string(),
2025 tools: tools.into_iter().map(str::to_string).collect(),
2026 }
2027 }
2028
2029 fn agent_block_runner(tools: Vec<&str>) -> Runner {
2030 Runner::AgentBlockInProcess {
2031 tools: tools.into_iter().map(str::to_string).collect(),
2032 }
2033 }
2034
2035 // ─── round-trip byte-compat ─────────────────────────────────────
2036
2037 #[test]
2038 fn blueprint_without_runners_or_default_runner_deserializes_to_defaults() {
2039 let json = serde_json::json!({
2040 "schema_version": current_schema_version(),
2041 "id": "no-runners-ut",
2042 "flow": { "kind": "seq", "children": [] },
2043 });
2044 let bp: Blueprint = serde_json::from_value(json).expect("deserializes");
2045 assert!(bp.runners.is_empty());
2046 assert_eq!(bp.default_runner, None);
2047 }
2048
2049 #[test]
2050 fn blueprint_runners_omitted_when_empty() {
2051 let bp = minimal_bp(None);
2052 let json = serde_json::to_value(&bp).expect("serializes");
2053 assert!(
2054 json.as_object().unwrap().get("runners").is_none(),
2055 "runners key must be absent when empty: {json}"
2056 );
2057 let back: Blueprint = serde_json::from_value(json).expect("deserializes");
2058 assert!(back.runners.is_empty());
2059 assert_eq!(bp, back);
2060 }
2061
2062 #[test]
2063 fn blueprint_runners_roundtrips_when_non_empty() {
2064 let mut bp = minimal_bp(None);
2065 bp.runners = vec![RunnerDef {
2066 name: "claude-worker".to_string(),
2067 runner: ws_runner("mse-worker-coder", vec!["Read", "Grep"]),
2068 }];
2069 let json = serde_json::to_string(&bp).expect("serializes");
2070 let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
2071 assert_eq!(bp, back);
2072 assert_eq!(back.runners.len(), 1);
2073 assert_eq!(back.runners[0].name, "claude-worker");
2074 }
2075
2076 #[test]
2077 fn blueprint_default_runner_roundtrips_when_some() {
2078 let mut bp = minimal_bp(None);
2079 bp.default_runner = Some("claude-worker".to_string());
2080 let json = serde_json::to_value(&bp).expect("serializes");
2081 assert_eq!(json["default_runner"], "claude-worker");
2082 let back: Blueprint = serde_json::from_value(json).expect("deserializes");
2083 assert_eq!(back, bp);
2084 }
2085
2086 #[test]
2087 fn blueprint_default_runner_omitted_when_none() {
2088 let bp = minimal_bp(None);
2089 let json = serde_json::to_value(&bp).expect("serializes");
2090 assert!(
2091 json.as_object().unwrap().get("default_runner").is_none(),
2092 "default_runner key must be absent when None: {json}"
2093 );
2094 let back: Blueprint = serde_json::from_value(json).expect("deserializes");
2095 assert_eq!(back, bp);
2096 }
2097
2098 #[test]
2099 fn blueprint_json_schema_exports_runners_and_default_runner() {
2100 let schema = schemars::schema_for!(Blueprint);
2101 let v = serde_json::to_value(&schema).expect("schema serializes");
2102 assert!(
2103 v["properties"]["runners"].is_object(),
2104 "runners must appear in the exported schema: {v}"
2105 );
2106 assert!(
2107 v["properties"]["default_runner"].is_object(),
2108 "default_runner must appear in the exported schema: {v}"
2109 );
2110 let dump = v.to_string();
2111 assert!(dump.contains("RunnerDef"), "RunnerDef definition in schema");
2112 assert!(dump.contains("Runner"), "Runner definition in schema");
2113 }
2114
2115 #[test]
2116 fn agent_def_runner_and_runner_ref_omitted_when_none() {
2117 let agent = agent_with_runner("scout", None, None, None);
2118 let json = serde_json::to_value(&agent).expect("serializes");
2119 let obj = json.as_object().unwrap();
2120 assert!(
2121 obj.get("runner").is_none(),
2122 "runner key must be absent when None: {json}"
2123 );
2124 assert!(
2125 obj.get("runner_ref").is_none(),
2126 "runner_ref key must be absent when None: {json}"
2127 );
2128 let back: AgentDef = serde_json::from_value(json).expect("deserializes");
2129 assert_eq!(back, agent);
2130 }
2131
2132 #[test]
2133 fn agent_def_runner_inline_roundtrips_when_some() {
2134 let agent = agent_with_runner("coder", None, Some(agent_block_runner(vec!["Bash"])), None);
2135 let json = serde_json::to_string(&agent).expect("serializes");
2136 let back: AgentDef = serde_json::from_str(&json).expect("deserializes");
2137 assert_eq!(back, agent);
2138 }
2139
2140 #[test]
2141 fn agent_def_runner_ref_roundtrips_when_some() {
2142 let agent = agent_with_runner("coder", None, None, Some("claude-worker".to_string()));
2143 let json = serde_json::to_value(&agent).expect("serializes");
2144 assert_eq!(json["runner_ref"], "claude-worker");
2145 let back: AgentDef = serde_json::from_value(json).expect("deserializes");
2146 assert_eq!(back, agent);
2147 }
2148
2149 #[test]
2150 fn agent_def_json_schema_exports_runner_and_runner_ref() {
2151 let schema = schemars::schema_for!(AgentDef);
2152 let v = serde_json::to_value(&schema).expect("schema serializes");
2153 let props = v["properties"].as_object().expect("object schema");
2154 for key in ["runner", "runner_ref"] {
2155 assert!(props.contains_key(key), "missing property: {key}");
2156 }
2157 }
2158
2159 #[test]
2160 fn runner_ws_claude_code_roundtrips_through_json_and_tags_backend() {
2161 let runner = ws_runner("mse-worker-coder", vec!["Read", "Grep"]);
2162 let json = serde_json::to_value(&runner).expect("serializes");
2163 assert_eq!(json["backend"], "ws_claude_code");
2164 assert_eq!(json["variant"], "mse-worker-coder");
2165 assert_eq!(json["tools"], serde_json::json!(["Read", "Grep"]));
2166 let back: Runner = serde_json::from_value(json).expect("deserializes");
2167 assert_eq!(back, runner);
2168 }
2169
2170 #[test]
2171 fn runner_agent_block_in_process_roundtrips_through_json_and_tags_backend() {
2172 let runner = agent_block_runner(vec!["Bash"]);
2173 let json = serde_json::to_value(&runner).expect("serializes");
2174 assert_eq!(json["backend"], "agent_block_in_process");
2175 assert_eq!(json["tools"], serde_json::json!(["Bash"]));
2176 let back: Runner = serde_json::from_value(json).expect("deserializes");
2177 assert_eq!(back, runner);
2178 }
2179
2180 #[test]
2181 fn runner_tools_omitted_when_empty() {
2182 let runner = ws_runner("mse-worker-coder", vec![]);
2183 let json = serde_json::to_value(&runner).expect("serializes");
2184 assert!(
2185 json.as_object().unwrap().get("tools").is_none(),
2186 "tools key must be absent when empty: {json}"
2187 );
2188 let back: Runner = serde_json::from_value(json).expect("deserializes");
2189 assert_eq!(back, runner);
2190 }
2191
2192 #[test]
2193 fn runner_rejects_unknown_field() {
2194 let json = serde_json::json!({
2195 "backend": "ws_claude_code",
2196 "variant": "x",
2197 "not_a_real_field": true,
2198 });
2199 let err = serde_json::from_value::<Runner>(json).unwrap_err();
2200 assert!(
2201 err.to_string().contains("not_a_real_field")
2202 || err.to_string().contains("unknown field"),
2203 "expected an unknown-field rejection, got: {err}"
2204 );
2205 }
2206
2207 #[test]
2208 fn runner_def_roundtrips_through_json() {
2209 let def = RunnerDef {
2210 name: "claude-worker".to_string(),
2211 runner: ws_runner("mse-worker-coder", vec!["Read"]),
2212 };
2213 let json = serde_json::to_value(&def).expect("serializes");
2214 let back: RunnerDef = serde_json::from_value(json).expect("deserializes");
2215 assert_eq!(back, def);
2216 }
2217
2218 #[test]
2219 fn worker_model_roundtrips_through_json() {
2220 let model = WorkerModel {
2221 runner: agent_block_runner(vec!["Bash"]),
2222 agent: agent_with_runner("coder", None, None, None),
2223 };
2224 let json = serde_json::to_value(&model).expect("serializes");
2225 let back: WorkerModel = serde_json::from_value(json).expect("deserializes");
2226 assert_eq!(back, model);
2227 }
2228
2229 // ─── resolve_runner cascade precedence ─────────────────────────
2230
2231 #[test]
2232 fn resolve_runner_inline_wins_over_everything() {
2233 let inline = agent_block_runner(vec!["Bash"]);
2234 let profile = AgentProfile {
2235 worker_binding: Some("legacy-variant".to_string()),
2236 tools: vec!["Read".to_string()],
2237 ..Default::default()
2238 };
2239 let agent = agent_with_runner(
2240 "coder",
2241 Some(profile),
2242 Some(inline.clone()),
2243 Some("registry-entry".to_string()),
2244 );
2245 let mut bp = minimal_bp(None);
2246 bp.default_runner = Some("registry-entry".to_string());
2247 bp.runners = vec![RunnerDef {
2248 name: "registry-entry".to_string(),
2249 runner: ws_runner("other-variant", vec![]),
2250 }];
2251 bp.agents = vec![agent.clone()];
2252
2253 let resolved = resolve_runner(&bp, &agent).expect("resolves");
2254 assert_eq!(resolved, Some(inline));
2255 }
2256
2257 #[test]
2258 fn resolve_runner_runner_ref_wins_over_legacy_fallback() {
2259 let profile = AgentProfile {
2260 worker_binding: Some("legacy-variant".to_string()),
2261 tools: vec!["Read".to_string()],
2262 ..Default::default()
2263 };
2264 let registry_runner = ws_runner("registry-variant", vec!["Grep"]);
2265 let agent = agent_with_runner(
2266 "coder",
2267 Some(profile),
2268 None,
2269 Some("registry-entry".to_string()),
2270 );
2271 let mut bp = minimal_bp(None);
2272 bp.runners = vec![RunnerDef {
2273 name: "registry-entry".to_string(),
2274 runner: registry_runner.clone(),
2275 }];
2276 bp.agents = vec![agent.clone()];
2277
2278 let resolved = resolve_runner(&bp, &agent).expect("resolves");
2279 assert_eq!(resolved, Some(registry_runner));
2280 }
2281
2282 #[test]
2283 fn resolve_runner_legacy_fallback_wins_over_default_runner() {
2284 let profile = AgentProfile {
2285 worker_binding: Some("legacy-variant".to_string()),
2286 tools: vec!["Read".to_string(), "Grep".to_string()],
2287 ..Default::default()
2288 };
2289 let agent = agent_with_runner("coder", Some(profile), None, None);
2290 let mut bp = minimal_bp(None);
2291 bp.default_runner = Some("registry-entry".to_string());
2292 bp.runners = vec![RunnerDef {
2293 name: "registry-entry".to_string(),
2294 runner: agent_block_runner(vec!["Bash"]),
2295 }];
2296 bp.agents = vec![agent.clone()];
2297
2298 let resolved = resolve_runner(&bp, &agent).expect("resolves");
2299 assert_eq!(
2300 resolved,
2301 Some(ws_runner("legacy-variant", vec!["Read", "Grep"]))
2302 );
2303 }
2304
2305 #[test]
2306 fn resolve_runner_default_runner_alone_when_no_agent_level_declaration() {
2307 let agent = agent_with_runner("coder", None, None, None);
2308 let mut bp = minimal_bp(None);
2309 bp.default_runner = Some("registry-entry".to_string());
2310 bp.runners = vec![RunnerDef {
2311 name: "registry-entry".to_string(),
2312 runner: agent_block_runner(vec!["Bash"]),
2313 }];
2314 bp.agents = vec![agent.clone()];
2315
2316 let resolved = resolve_runner(&bp, &agent).expect("resolves");
2317 assert_eq!(resolved, Some(agent_block_runner(vec!["Bash"])));
2318 }
2319
2320 #[test]
2321 fn resolve_runner_none_when_nothing_declared_through_any_tier() {
2322 let agent = agent_with_runner("coder", None, None, None);
2323 let bp = minimal_bp(None);
2324
2325 let resolved = resolve_runner(&bp, &agent).expect("resolves");
2326 assert_eq!(resolved, None);
2327 }
2328
2329 #[test]
2330 fn resolve_runner_unknown_runner_ref_errs() {
2331 let agent = agent_with_runner("coder", None, None, Some("no-such-entry".to_string()));
2332 let mut bp = minimal_bp(None);
2333 bp.runners = vec![RunnerDef {
2334 name: "registry-entry".to_string(),
2335 runner: agent_block_runner(vec![]),
2336 }];
2337 bp.agents = vec![agent.clone()];
2338
2339 let err = resolve_runner(&bp, &agent).expect_err("unresolved runner_ref");
2340 assert_eq!(
2341 err,
2342 RunnerResolveError::UnknownRunnerRef {
2343 agent: "coder".to_string(),
2344 ref_name: "no-such-entry".to_string(),
2345 available: vec!["registry-entry".to_string()],
2346 }
2347 );
2348 }
2349
2350 #[test]
2351 fn resolve_runner_unknown_default_runner_errs() {
2352 let agent = agent_with_runner("coder", None, None, None);
2353 let mut bp = minimal_bp(None);
2354 bp.default_runner = Some("no-such-entry".to_string());
2355 bp.runners = vec![RunnerDef {
2356 name: "registry-entry".to_string(),
2357 runner: agent_block_runner(vec![]),
2358 }];
2359 bp.agents = vec![agent.clone()];
2360
2361 let err = resolve_runner(&bp, &agent).expect_err("unresolved default_runner");
2362 assert_eq!(
2363 err,
2364 RunnerResolveError::UnknownDefaultRunner {
2365 ref_name: "no-such-entry".to_string(),
2366 available: vec!["registry-entry".to_string()],
2367 }
2368 );
2369 }
2370
2371 // ──────────────────────────────────────────────────────────────
2372 // GH #50: `AgentDef.verdict` / `VerdictContract` / `VerdictChannel`
2373 // ──────────────────────────────────────────────────────────────
2374
2375 #[test]
2376 fn verdict_contract_roundtrips_body_channel() {
2377 let json = serde_json::json!({"channel": "body", "values": ["PASS", "BLOCKED"]});
2378 let contract: VerdictContract = serde_json::from_value(json.clone()).expect("deserializes");
2379 assert_eq!(contract.channel, VerdictChannel::Body);
2380 assert_eq!(
2381 contract.values,
2382 vec!["PASS".to_string(), "BLOCKED".to_string()]
2383 );
2384 assert_eq!(serde_json::to_value(&contract).expect("serializes"), json);
2385 }
2386
2387 #[test]
2388 fn verdict_contract_roundtrips_part_channel() {
2389 let json = serde_json::json!({"channel": "part", "values": ["ALLOW"]});
2390 let contract: VerdictContract = serde_json::from_value(json.clone()).expect("deserializes");
2391 assert_eq!(contract.channel, VerdictChannel::Part);
2392 assert_eq!(serde_json::to_value(&contract).expect("serializes"), json);
2393 }
2394
2395 #[test]
2396 fn agent_def_verdict_omitted_when_none() {
2397 let agent = agent_with_runner("gate", None, None, None);
2398 let json = serde_json::to_value(&agent).expect("serializes");
2399 assert!(
2400 json.as_object().unwrap().get("verdict").is_none(),
2401 "verdict key must be absent when None: {json}"
2402 );
2403 let back: AgentDef = serde_json::from_value(json).expect("deserializes");
2404 assert_eq!(back.verdict, None);
2405 }
2406
2407 #[test]
2408 fn agent_def_verdict_roundtrips_when_some() {
2409 let mut agent = agent_with_runner("gate", None, None, None);
2410 agent.verdict = Some(VerdictContract {
2411 channel: VerdictChannel::Body,
2412 values: vec!["PASS".to_string(), "BLOCKED".to_string()],
2413 });
2414 let json = serde_json::to_value(&agent).expect("serializes");
2415 let back: AgentDef = serde_json::from_value(json).expect("deserializes");
2416 assert_eq!(back.verdict, agent.verdict);
2417 }
2418
2419 /// Acceptance criterion #2: the `02-verdict-loop.json` sample (no
2420 /// `verdict` field on any of its agents) must still deserialize
2421 /// unchanged under the new `#[serde(deny_unknown_fields)]`-constrained
2422 /// `AgentDef` — `verdict` is `#[serde(default)]`, so its absence is not
2423 /// an error.
2424 #[test]
2425 fn existing_verdict_loop_sample_deserializes_with_verdict_omitted() {
2426 const SAMPLE: &str =
2427 include_str!("../../mlua-swarm-cli/src/mcp/resources/samples/02-verdict-loop.json");
2428 let bp: Blueprint = serde_json::from_str(SAMPLE).expect("sample deserializes");
2429 assert_eq!(bp.agents.len(), 6);
2430 assert!(
2431 bp.agents.iter().all(|a| a.verdict.is_none()),
2432 "no agent in the sample declares a verdict contract"
2433 );
2434 }
2435
2436 // ──────────────────────────────────────────────────────────────
2437 // CheckPolicy enum relocation + Blueprint.check_policy
2438 // (T1: schema round-trip / omit→None / invalid→error)
2439 // ──────────────────────────────────────────────────────────────
2440
2441 /// The wire form is snake_case and byte-identical to the pre-relocation
2442 /// enum (`"silent"` / `"warn"` / `"strict"`), round-tripping in both
2443 /// directions — the relocation must not change the serde surface.
2444 #[test]
2445 fn check_policy_wire_form_round_trips() {
2446 for (variant, wire) in [
2447 (CheckPolicy::Silent, "silent"),
2448 (CheckPolicy::Warn, "warn"),
2449 (CheckPolicy::Strict, "strict"),
2450 ] {
2451 let json = serde_json::to_value(variant).expect("serializes");
2452 assert_eq!(json, serde_json::json!(wire), "wire form for {variant:?}");
2453 let back: CheckPolicy = serde_json::from_value(json).expect("deserializes");
2454 assert_eq!(back, variant, "round-trip for {variant:?}");
2455 }
2456 }
2457
2458 /// The default is `Warn` (preserves the pre-CheckPolicy fail-open
2459 /// behaviour of every submit-time projection sink).
2460 #[test]
2461 fn check_policy_default_is_warn() {
2462 assert_eq!(CheckPolicy::default(), CheckPolicy::Warn);
2463 }
2464
2465 /// A Blueprint that declares `check_policy: "strict"` parses to
2466 /// `Some(Strict)` and re-serializes with the same snake_case literal.
2467 #[test]
2468 fn blueprint_check_policy_strict_round_trips() {
2469 let json = serde_json::json!({
2470 "schema_version": current_schema_version(),
2471 "id": "check-policy-strict-ut",
2472 "flow": { "kind": "seq", "children": [] },
2473 "check_policy": "strict",
2474 });
2475 let bp: Blueprint = serde_json::from_value(json).expect("deserializes");
2476 assert_eq!(bp.check_policy, Some(CheckPolicy::Strict));
2477 let re = serde_json::to_string(&bp).expect("serializes");
2478 assert!(
2479 re.contains("\"check_policy\":\"strict\""),
2480 "re-serialized BP must preserve the snake_case wire literal: {re}"
2481 );
2482 }
2483
2484 /// An omitted `check_policy` parses to `None` and is skipped on
2485 /// serialize (backward-compat with every pre-cascade Blueprint).
2486 #[test]
2487 fn blueprint_check_policy_omitted_is_none() {
2488 let json = serde_json::json!({
2489 "schema_version": current_schema_version(),
2490 "id": "check-policy-omitted-ut",
2491 "flow": { "kind": "seq", "children": [] },
2492 });
2493 let bp: Blueprint = serde_json::from_value(json).expect("deserializes");
2494 assert_eq!(bp.check_policy, None);
2495
2496 let out = serde_json::to_value(&bp).expect("serializes");
2497 assert!(
2498 out.as_object().unwrap().get("check_policy").is_none(),
2499 "check_policy key must be absent when None: {out}"
2500 );
2501 }
2502
2503 /// An invalid `check_policy` value is a hard parse error (not silently
2504 /// dropped) — the enum is closed to the three snake_case variants. This
2505 /// also confirms `deny_unknown_fields` is not the gate here: the field
2506 /// IS known, only its value is invalid.
2507 #[test]
2508 fn blueprint_check_policy_invalid_value_errors() {
2509 let json = serde_json::json!({
2510 "schema_version": current_schema_version(),
2511 "id": "check-policy-invalid-ut",
2512 "flow": { "kind": "seq", "children": [] },
2513 "check_policy": "loud",
2514 });
2515 let err = serde_json::from_value::<Blueprint>(json)
2516 .expect_err("an unknown check_policy value must be rejected");
2517 let msg = err.to_string();
2518 assert!(
2519 msg.contains("check_policy") || msg.contains("loud") || msg.contains("variant"),
2520 "error should point at the bad check_policy value: {msg}"
2521 );
2522 }
2523}