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//! subprocesses: vec![],
76//! check_policy: None,
77//! blueprint_ref_includes: vec![],
78//! };
79//!
80//! assert_eq!(bp.id.as_str(), "hello");
81//! assert_eq!(bp.agents.len(), 1);
82//! assert_eq!(bp.strategy.strict_refs, true);
83//! ```
84//!
85//! Round-trip a [`Blueprint`] through JSON (= confirms `serde` derives and the
86//! `deny_unknown_fields` contract):
87//!
88//! ```
89//! use mlua_swarm_schema::{AgentKind, Blueprint, BlueprintMetadata};
90//! use mlua_flow_ir::{Expr, Node};
91//! use serde_json::json;
92//!
93//! let bp = Blueprint {
94//! schema_version: mlua_swarm_schema::current_schema_version(),
95//! id: "roundtrip".into(),
96//! flow: Node::Seq { children: vec![] },
97//! agents: vec![],
98//! operators: vec![],
99//! metas: vec![],
100//! hints: Default::default(),
101//! strategy: Default::default(),
102//! metadata: BlueprintMetadata {
103//! description: Some("roundtrip smoke".into()),
104//! default_run_ttl_secs: Some(1800),
105//! ..Default::default()
106//! },
107//! spawner_hints: Default::default(),
108//! default_agent_kind: AgentKind::Operator,
109//! default_operator_kind: None,
110//! default_init_ctx: None,
111//! default_agent_ctx: None,
112//! default_context_policy: None,
113//! projection_placement: None,
114//! audits: vec![],
115//! degradation_policy: None,
116//! runners: vec![],
117//! default_runner: None,
118//! subprocesses: vec![],
119//! check_policy: None,
120//! blueprint_ref_includes: vec![],
121//! };
122//!
123//! let json = serde_json::to_string(&bp).unwrap();
124//! let back: Blueprint = serde_json::from_str(&json).unwrap();
125//! assert_eq!(bp, back);
126//! assert_eq!(back.metadata.default_run_ttl_secs, Some(1800));
127//! ```
128
129#![warn(missing_docs)]
130
131use mlua_flow_ir::Node as FlowNode;
132use schemars::JsonSchema;
133use serde::{Deserialize, Serialize};
134use serde_json::Value;
135use std::collections::HashMap;
136
137// ──────────────────────────────────────────────────────────────────────────
138// Versioning
139// ──────────────────────────────────────────────────────────────────────────
140
141/// Current Blueprint schema version. Tied to this crate's semver.
142pub const CURRENT_SCHEMA_VERSION: &str = "0.1.0";
143
144fn default_schema_version() -> semver::Version {
145 current_schema_version()
146}
147
148/// Blueprint construction helper: returns the semver of the current schema version.
149/// Callers can write `schema_version: current_schema_version(),`.
150pub fn current_schema_version() -> semver::Version {
151 semver::Version::parse(CURRENT_SCHEMA_VERSION)
152 .expect("CURRENT_SCHEMA_VERSION must be valid semver")
153}
154
155// ──────────────────────────────────────────────────────────────────────────
156// BlueprintId (human-facing ID newtype)
157// ──────────────────────────────────────────────────────────────────────────
158
159/// Identifier for a Blueprint series — the domain name (`coding`,
160/// `design`, `testing`, etc.). Default: [`BlueprintId::main`].
161///
162/// One representation across the workspace (issue #14): this type is
163/// shared by the schema's [`Blueprint::id`] and the engine's store-layer
164/// keys (`mlua-swarm` re-exports it at the old
165/// `blueprint::store::types::BlueprintId` path). The value is
166/// user-supplied — there is no prefix convention to validate, unlike the
167/// engine's minted `T-` / `R-` / `ST-` ids — so construction is
168/// infallible; the inner string is private so call sites go through
169/// [`BlueprintId::new`] and the accessors. `#[serde(transparent)]` keeps
170/// both the JSON wire shape and the generated JSON Schema a plain string.
171#[derive(
172 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
173)]
174#[serde(transparent)]
175pub struct BlueprintId(String);
176
177impl BlueprintId {
178 /// The default series name used when a caller doesn't pick one.
179 pub const MAIN: &'static str = "main";
180
181 /// Shorthand for `BlueprintId::new(BlueprintId::MAIN)`.
182 pub fn main() -> Self {
183 Self(Self::MAIN.to_string())
184 }
185
186 /// Wrap any string-like value as a `BlueprintId` (user-supplied key;
187 /// nothing to validate).
188 pub fn new(s: impl Into<String>) -> Self {
189 Self(s.into())
190 }
191
192 /// Borrow the inner series name.
193 pub fn as_str(&self) -> &str {
194 &self.0
195 }
196
197 /// Consume the id and return the inner series name.
198 pub fn into_string(self) -> String {
199 self.0
200 }
201}
202
203impl std::fmt::Display for BlueprintId {
204 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205 f.write_str(&self.0)
206 }
207}
208
209impl From<String> for BlueprintId {
210 fn from(s: String) -> Self {
211 Self(s)
212 }
213}
214
215impl From<&str> for BlueprintId {
216 fn from(s: &str) -> Self {
217 Self(s.to_string())
218 }
219}
220
221#[cfg(test)]
222mod blueprint_id_tests {
223 use super::*;
224
225 /// issue #14 convergence guard: `Blueprint.id` becoming a newtype must
226 /// not change the generated JSON Schema — the property stays an inline
227 /// plain string (no `$ref`), byte-compatible with the `String` era.
228 #[test]
229 fn blueprint_id_field_schema_stays_a_plain_inline_string() {
230 let schema = schemars::schema_for!(Blueprint);
231 let v = serde_json::to_value(&schema).expect("schema serializes");
232 let id = &v["properties"]["id"];
233 assert_eq!(id["type"], "string", "id must stay a plain string: {id}");
234 assert!(id.get("$ref").is_none(), "id must not become a $ref: {id}");
235 }
236
237 /// The JSON wire shape of the newtype is the bare string.
238 #[test]
239 fn blueprint_id_serde_is_transparent() {
240 let id = BlueprintId::new("coding");
241 assert_eq!(
242 serde_json::to_value(&id).unwrap(),
243 serde_json::json!("coding")
244 );
245 let back: BlueprintId = serde_json::from_value(serde_json::json!("coding")).unwrap();
246 assert_eq!(back, id);
247 }
248}
249
250// ──────────────────────────────────────────────────────────────────────────
251// Blueprint (top-level package)
252// ──────────────────────────────────────────────────────────────────────────
253
254/// Unified package of flow.ir + Swarm extension layers. The entry-point type of Swarm.
255#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
256#[serde(deny_unknown_fields)]
257pub struct Blueprint {
258 /// Schema version (= tied to this crate's semver). Default = `CURRENT_SCHEMA_VERSION`.
259 /// Serialized as a semver string (e.g. `"0.1.0"`).
260 #[serde(default = "default_schema_version")]
261 #[schemars(with = "String")]
262 pub schema_version: semver::Version,
263 /// Blueprint identifier (= unique key within the caller's namespace).
264 #[schemars(with = "String")]
265 pub id: BlueprintId,
266 /// Embeds the flow.ir Node verbatim (= keeps flow.ir side unpolluted).
267 /// Opaque in the JSON Schema (the Node shape is owned by the `mlua-flow-ir`
268 /// crate, a separate repo; see its docs for the Node / Expr grammar).
269 #[schemars(with = "Value")]
270 pub flow: FlowNode,
271 /// Swarm extension layer: agent → backend mapping.
272 #[serde(default)]
273 pub agents: Vec<AgentDef>,
274 /// Swarm extension layer: **design-time definition** of Operator roles (first-class).
275 ///
276 /// `AgentDef.spec.operator_ref` references an `OperatorDef.name` (logical role name) in
277 /// this vec. Embedding runtime-generated IDs such as sid into the BP is forbidden
278 /// (= collapses the design-time vs runtime boundary). Runtime backend bindings are
279 /// established via the attach / register path; the BP side holds only logical names.
280 ///
281 /// Every `kind = Operator` agent must have its `spec.operator_ref` present in this
282 /// list — the compiler validates it at `compile()` time. May be `[]` only when the
283 /// Blueprint declares no Operator agents.
284 #[serde(default)]
285 pub operators: Vec<OperatorDef>,
286 /// GH #21 Phase 2 — named, BP-scoped pool of [`MetaDef`] entries. Two
287 /// independent consumers resolve names against this pool: a
288 /// `$step_meta.ref` envelope embedded in a Step's evaluated `in`
289 /// value (the Step tier — resolved by `EngineDispatcher` in the
290 /// `mlua-swarm` core crate at dispatch time), and
291 /// [`AgentMeta::meta_ref`] (the Agent tier — resolved at launch
292 /// time). The pool lets multiple Steps and/or Agents share one
293 /// declarative context object by name instead of repeating it
294 /// inline. `[]` = no named `MetaDef`s declared (pre-#21-Phase-2
295 /// Blueprints unaffected).
296 #[serde(default, skip_serializing_if = "Vec::is_empty")]
297 pub metas: Vec<MetaDef>,
298 /// Swarm extension layer: per-agent hints (interpreted by the Compiler).
299 #[serde(default)]
300 pub hints: CompilerHints,
301 /// Swarm extension layer: Compiler behavior strategy (strict / lenient).
302 #[serde(default)]
303 pub strategy: CompilerStrategy,
304 /// Blueprint metadata (description / origin / tags / ttl / version label / alias).
305 #[serde(default)]
306 pub metadata: BlueprintMetadata,
307 /// Swarm extension layer: hint keys of the layers to wrap around the SpawnerStack.
308 /// Resolved by the LayerRegistry at engine bind time (= unregistered keys are silently
309 /// skipped). Flow / Blueprint do not hold middleware implementations (e.g. MainAIMiddleware)
310 /// directly; they only declare required capabilities as string keys (= implementations
311 /// live in the engine-side LayerRegistry).
312 #[serde(default)]
313 pub spawner_hints: SpawnerHints,
314 /// BP-wide default `AgentKind` (= fallback when `AgentDef.kind` is omitted).
315 /// Four-layer cascade: (1) Schema impl Default = Operator, (2) CLI
316 /// `--default-agent-kind`, (3) this field (BP JSON literal), (4) `AgentDef.kind`
317 /// (per-agent literal). (5) `CompilerHints.kind_override` allows runtime override.
318 /// All default resolution flows through this path.
319 #[serde(default = "default_global_agent_kind")]
320 pub default_agent_kind: AgentKind,
321 /// BP-wide default `OperatorKind` (= the "BP Global" tier of the 4-tier
322 /// `OperatorKind` cascade). `None` when the Blueprint author does not
323 /// declare a default; the caller-side resolver then falls through to
324 /// the hardcoded `OperatorKind::default()` (Automate).
325 ///
326 /// # 4-tier cascade (highest to lowest priority)
327 ///
328 /// 1. Runtime Agent-level (per-agent override supplied at task-launch time)
329 /// 2. Runtime Global (the launch-time `operator_kind` request)
330 /// 3. BP Agent-level (`OperatorDef.kind`, resolved via `AgentDef.spec.operator_ref`)
331 /// 4. BP Global (this field)
332 /// 5. Default Fallback (`OperatorKind::default()` = Automate)
333 ///
334 /// The collapse itself is implemented once on the engine side and consumed
335 /// per-agent when resolving operator info.
336 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub default_operator_kind: Option<OperatorKind>,
338 /// Blueprint-level default initial `ctx` for flow-ir eval.
339 /// `TaskLaunchService::launch` shallow-merges this with the
340 /// Task-level `init_ctx` (Task wins on key collision when both
341 /// are `Object`; if Task's `init_ctx` is not an `Object`, it
342 /// full-replaces the default). `None` — no default is merged;
343 /// backward-compat with pre-#19 Blueprints.
344 #[serde(default, skip_serializing_if = "Option::is_none")]
345 #[schemars(with = "Option<Value>")]
346 pub default_init_ctx: Option<Value>,
347 /// GH #21 Phase 1 — "BP Global" tier of the agent-context supply axis:
348 /// a declarative object merged into `ctx.meta.runtime` (and, for
349 /// unnamed keys, `AgentContextView.extra`) targeting every agent's
350 /// runtime materialization. Contrast with [`Self::default_init_ctx`]:
351 /// that field seeds the flow-ir eval `ctx` once at flow start, while
352 /// this one is consumed per-spawn by
353 /// `AgentContextMiddleware`/`AgentContextView` (Contract C, GH #20) —
354 /// a pure flow-ir eval seed vs. an Agent/LLM-boundary runtime default.
355 /// `None` = no BP-global default (pre-#21 Blueprints unaffected).
356 #[serde(default, skip_serializing_if = "Option::is_none")]
357 #[schemars(with = "Option<Value>")]
358 pub default_agent_ctx: Option<Value>,
359 /// GH #21 Phase 1 — "BP Global" tier of the [`ContextPolicy`] cascade:
360 /// the default filter applied to the materialized `AgentContextView`
361 /// when the targeted agent declares no `AgentMeta.context_policy` of
362 /// its own. `None` = pass-all (the pre-#21 behavior).
363 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub default_context_policy: Option<ContextPolicy>,
365 /// GH #27 (follow-up to #23) — Blueprint-declared override of the
366 /// `mlua-swarm` core crate's projection placement resolver (root
367 /// preference + target directory template for materialized step
368 /// OUTPUT files). `None` = the resolver's byte-compat default (root =
369 /// `work_dir` falling back to `project_root`; dir_template =
370 /// `"workspace/tasks/{task_id}/ctx"`) — every pre-#27 Blueprint is
371 /// unaffected. See [`ProjectionPlacementSpec`]'s doc for field detail.
372 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub projection_placement: Option<ProjectionPlacementSpec>,
374 /// GH #34 — Blueprint-declared after-run audit hooks: the engine
375 /// auto-kicks each listed [`AuditDef`]'s agent once a matching Step
376 /// settles, and persists its findings as an `OutputEvent::Artifact`
377 /// named `"audit:<step_ref>"` on the AUDITED step's own output tail
378 /// (see `mlua-swarm` core's `AfterRunAuditMiddleware` for the
379 /// dispatch mechanics). `audits[].agent` is validated at
380 /// `Compiler::compile` time against `Blueprint.agents[].name`
381 /// (mirrors the `operator_ref` validation). `[]` (the default) = no
382 /// audit hooks declared — every pre-#34 Blueprint is unaffected,
383 /// byte-for-byte.
384 ///
385 /// **Binding invariant**: an audit's verdict, findings, or even its
386 /// own failure NEVER change the audited step's outcome or gate the
387 /// flow — audits are purely observational.
388 #[serde(default, skip_serializing_if = "Vec::is_empty")]
389 pub audits: Vec<AuditDef>,
390 /// GH #32 — Blueprint-declared policy for worker-reported degradations
391 /// (see `mlua-swarm` core's `RunRecord.degradations` /
392 /// `DegradationEntry`). `None` (the default) is schema-only for now:
393 /// [`DegradationPolicy::Warn`] and [`DegradationPolicy::Fail`] carry the
394 /// same observational behavior at this point — degradations are always
395 /// persisted, never gate the flow. Engine enforcement of `Fail`
396 /// (terminating a Run on any reported degradation) is a follow-up; this
397 /// field only declares author intent today. Every pre-#32 Blueprint is
398 /// unaffected.
399 #[serde(default, skip_serializing_if = "Option::is_none")]
400 pub degradation_policy: Option<DegradationPolicy>,
401 /// GH #46 M2 — named registry of [`RunnerDef`] entries (Tier 1 of the
402 /// 3-tier Worker model: Runner / Agent / Context). Referenced by
403 /// `AgentDef.runner_ref` and [`Self::default_runner`] by name.
404 /// Same registry shape as [`Self::metas`] (GH #21 Phase 2). `[]` (the
405 /// default) = no Runner registry declared — every pre-#46 Blueprint
406 /// is unaffected, byte-for-byte.
407 #[serde(default, skip_serializing_if = "Vec::is_empty")]
408 pub runners: Vec<RunnerDef>,
409 /// GH #46 M2 — the "BP Global" tier of the [`resolve_runner`] cascade:
410 /// a [`RunnerDef::name`] reference into [`Self::runners`] (inline
411 /// `Runner` values are not accepted here — registry names only,
412 /// mirroring [`Self::default_agent_ctx`]'s design). Ranks BELOW an
413 /// agent's own inline `runner` / `runner_ref` / legacy
414 /// `profile.worker_binding` declaration (see [`resolve_runner`]'s
415 /// cascade doc for the full precedence). `None` = no BP-wide default
416 /// declared — every pre-#46 Blueprint is unaffected.
417 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub default_runner: Option<String>,
419 /// GH #83 — named registry of [`SubprocessDef`] CLI invocation
420 /// templates, referenced by `Runner::Subprocess { template }` by
421 /// name. Same registry shape as [`Self::metas`] / [`Self::runners`].
422 /// `[]` (the default) = no templates declared — every pre-#83
423 /// Blueprint is unaffected, byte-for-byte.
424 #[serde(default, skip_serializing_if = "Vec::is_empty")]
425 pub subprocesses: Vec<SubprocessDef>,
426 /// "Blueprint" tier (tier 2) of the `check_policy`
427 /// cascade: `launch request > blueprint > server config` (highest to
428 /// lowest priority). The launch entry point resolves
429 /// `launch.check_policy.or(blueprint.check_policy)` exactly once and
430 /// threads the result into every spawned step's `TaskSpec.check_policy`;
431 /// `None` here (the default) is a no declaration — resolution falls
432 /// through to the launch-request tier and, absent that, to the
433 /// server-wide `EngineCfg.check_policy` default. Every pre-cascade
434 /// Blueprint is unaffected, byte-for-byte. See [`CheckPolicy`] for the
435 /// three fail-open reaction modes.
436 #[serde(default, skip_serializing_if = "Option::is_none")]
437 pub check_policy: Option<CheckPolicy>,
438 /// Authoring-time include list consumed by the compile-side linker
439 /// (tier 2 of the include cascade — see `mlua-swarm-compile`'s
440 /// `ResolveConfig`). Each entry is a directory path resolved
441 /// relative to the bp.lua parent that `$agent_md` / `$file` refs
442 /// will search after the parent dir itself. Bare list; the schema
443 /// carries the field only so `deny_unknown_fields` won't reject a
444 /// bp.lua that declares it. `[]` (the default) — no in-bp includes;
445 /// every pre-cascade Blueprint is unaffected.
446 #[serde(default, skip_serializing_if = "Vec::is_empty")]
447 #[schemars(with = "Vec<String>")]
448 pub blueprint_ref_includes: Vec<std::path::PathBuf>,
449}
450
451/// How a submit-time projection sink reacts when a fail-open condition
452/// is encountered.
453///
454/// This is the Swarm IF SoT type for the `check_policy` axis; the
455/// `mlua-swarm` core crate re-exports it as `crate::core::config::CheckPolicy`
456/// so every existing path (`EngineCfg.check_policy`, `TaskSpec.check_policy`,
457/// `apply_check_policy`) keeps its old type path unchanged.
458///
459/// Fail-open conditions include: `work_dir` / `project_root` unresolved,
460/// `OutputStore` write error, `FileProjectionAdapter::materialize_submission`
461/// error, and state lookup error. Each call site inside the engine's
462/// `materialize_final_submission` / `materialize_artifact_submission`
463/// currently logs a `tracing::warn!` and returns without materializing the
464/// file / dual-write; `CheckPolicy` is the first-class knob that lets a
465/// caller opt into a different reaction without changing that behaviour by
466/// default.
467///
468/// The three modes are (a) [`CheckPolicy::Silent`] — no log, no error,
469/// operation continues; (b) [`CheckPolicy::Warn`] — log warn (existing
470/// message literal preserved), no error, operation continues (the
471/// default = pre-existing behaviour); (c) [`CheckPolicy::Strict`] — log
472/// the same warn AND return `EngineError::CheckPolicyStrict` (in the core
473/// crate) so the caller can fail the step / launch fast. When Strict
474/// returns an error, the underlying `OutputStore` may already have
475/// appended (dual-write side-effect is not rolled back) — this "state
476/// dirty on fail" semantics is intentional: the append happens **before**
477/// the fail-open branch runs, so Strict surfaces the mismatch instead of
478/// hiding it.
479///
480/// The wire form is snake_case (`"silent"` / `"warn"` / `"strict"`); the
481/// default is [`CheckPolicy::Warn`].
482#[derive(Copy, Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
483#[serde(rename_all = "snake_case")]
484pub enum CheckPolicy {
485 /// Skip both the log warn and the error path — completely silent.
486 /// The operation continues (fail-open is still in effect).
487 Silent,
488 /// Log a `tracing::warn!` with the call site's existing message and
489 /// continue (fail-open). Default — byte-identical to the
490 /// pre-`CheckPolicy` behaviour of every submit-time projection sink
491 /// code path.
492 #[default]
493 Warn,
494 /// Log the same warn AND return `EngineError::CheckPolicyStrict` (the
495 /// core crate's error variant). A caller that has opted in can fail the
496 /// step / launch fast instead of proceeding with a partially-realized
497 /// submission. This mode also drives a launch-time pre-dispatch
498 /// validation in `TaskLaunchService::launch` (the `mlua-swarm` core
499 /// crate): a launch whose effective policy resolves to `Strict` and
500 /// that supplies neither `project_root` nor `work_dir` is rejected
501 /// with `TaskLaunchError::PreDispatch` before any step is dispatched,
502 /// rather than dispatching a step that would deterministically hit
503 /// this same error at its first submit-time file materialize.
504 Strict,
505}
506
507/// GH #32 — Blueprint-declared policy for worker-reported degradations. See
508/// [`Blueprint::degradation_policy`] for the (currently schema-only)
509/// enforcement contract.
510#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
511#[serde(rename_all = "snake_case")]
512pub enum DegradationPolicy {
513 /// Observational only (today's only enforced behavior, regardless of
514 /// which variant is declared): degradations are persisted to
515 /// `RunRecord.degradations` and surfaced via `mse_doctor` /
516 /// `GET /v1/runs/:id`, but never change the Run's outcome.
517 Warn,
518 /// Declares intent to terminate the Run on any reported degradation.
519 /// Not yet enforced by the engine — schema-only until the follow-up
520 /// lands.
521 Fail,
522}
523
524/// GH #34 — one Blueprint-declared after-run audit hook. See
525/// [`Blueprint::audits`] for the persistence / invariant contract.
526#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
527#[serde(deny_unknown_fields)]
528pub struct AuditDef {
529 /// Name of the audit agent (must match a [`Blueprint::agents`] entry's
530 /// `name`) the engine dispatches after a matched step settles.
531 /// Validated at `Compiler::compile` time (mirrors
532 /// `AgentDef.spec.operator_ref`'s `operator_ref` validation) — an
533 /// unresolved name rejects compilation.
534 pub agent: String,
535 /// Step names this audit applies to, matched against the step's agent
536 /// ref name. `None`, or a list containing the literal `"*"`, means
537 /// "every step". `Some(vec![])` (an explicit empty list) audits no
538 /// step. `None` is the default.
539 #[serde(default, skip_serializing_if = "Option::is_none")]
540 pub steps: Option<Vec<String>>,
541 /// Dispatch timing for this audit's agent (see [`AuditMode`]).
542 /// Defaults to [`AuditMode::Async`].
543 #[serde(default)]
544 pub mode: AuditMode,
545}
546
547/// GH #34 — dispatch timing for an [`AuditDef`]'s audit agent. Neither
548/// variant ever changes the audited step's outcome (see
549/// [`Blueprint::audits`]'s binding invariant).
550#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
551#[serde(rename_all = "snake_case")]
552pub enum AuditMode {
553 /// Fire-and-forget: the audit runs in the background after the
554 /// audited step settles; the audited step's own spawn signal returns
555 /// immediately, without waiting for the audit to finish.
556 #[default]
557 Async,
558 /// Awaited before the audited step's spawn signal is returned to the
559 /// engine — still never alters that signal or the step's recorded
560 /// outcome.
561 Sync,
562}
563
564/// Receptacle for a Blueprint-driven filter over the materialized
565/// `AgentContextView` (GH #20/#21). Declared BP-side via
566/// [`Blueprint::default_context_policy`] (BP-global) or
567/// `AgentMeta::context_policy` (per-agent, outranks the BP-global tier) —
568/// resolved and applied by `AgentContextMiddleware` in the `mlua-swarm`
569/// core crate (this crate stays execution-free; see the crate doc).
570/// Default (`include: None, exclude: vec![]`) is pass-all — [`Self::allows`]
571/// returns `true` for every field name.
572#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
573#[serde(deny_unknown_fields)]
574pub struct ContextPolicy {
575 /// Field names to keep. `None` means "keep everything" (pass-all).
576 /// Matched against the `AgentContextView` named-field strings
577 /// (`"project_root"` / `"work_dir"` / `"task_metadata"` / `"run_id"` /
578 /// `"project_name_alias"`) and `extra` keys by their own key string.
579 /// Identity fields (`task_id` / `agent` / `attempt`) are never
580 /// filtered regardless of `include`.
581 #[serde(default)]
582 pub include: Option<Vec<String>>,
583 /// Field names to drop, applied AFTER `include` (exclude wins when a
584 /// name appears in both). Same name-matching rule as `include`.
585 #[serde(default)]
586 pub exclude: Vec<String>,
587 /// Which preceding steps' OUTPUT pointers a worker's fetch payload may
588 /// see (`WorkerPayload.context.steps`, ST5 of the `projection-adapter`
589 /// design). `None` = pass-all (every submitted step, the pre-ST5
590 /// `ctx_step_dir` behavior); `Some(list)` = only the named steps;
591 /// `Some(vec![])` = none. Evaluated by [`Self::allows_step`], a sibling
592 /// of [`Self::allows`] with the same include/exclude precedence rule
593 /// but a separate namespace (step names vs. `AgentContextView` field /
594 /// `extra` key names never collide).
595 #[serde(default)]
596 pub steps: Option<Vec<String>>,
597 /// Step names to drop, applied AFTER `steps` (exclude wins when a name
598 /// appears in both). Same name-matching rule as `steps`.
599 #[serde(default)]
600 pub steps_exclude: Vec<String>,
601}
602
603impl ContextPolicy {
604 /// Whether `name` survives this policy: `false` if `exclude` lists it;
605 /// otherwise `true` when `include` is `None` (pass-all) or lists
606 /// `name`. Shared by both the schema crate (tests) and the `mlua-swarm`
607 /// core crate's `AgentContextView::apply_policy`, so the include/exclude
608 /// evaluation rule has exactly one implementation.
609 pub fn allows(&self, name: &str) -> bool {
610 if self.exclude.iter().any(|excluded| excluded == name) {
611 return false;
612 }
613 match &self.include {
614 Some(list) => list.iter().any(|included| included == name),
615 None => true,
616 }
617 }
618
619 /// Whether the preceding step named `name` survives this policy for the
620 /// worker fetch payload's `context.steps` pointer list: `false` if
621 /// `steps_exclude` lists it; otherwise `true` when `steps` is `None`
622 /// (pass-all) or lists `name`. Same precedence rule as [`Self::allows`],
623 /// evaluated against the separate `steps` / `steps_exclude` fields.
624 pub fn allows_step(&self, name: &str) -> bool {
625 if self.steps_exclude.iter().any(|excluded| excluded == name) {
626 return false;
627 }
628 match &self.steps {
629 Some(list) => list.iter().any(|included| included == name),
630 None => true,
631 }
632 }
633}
634
635/// Global default `AgentKind` at the Schema impl Default layer. Bottom of the 4-layer cascade.
636pub fn default_global_agent_kind() -> AgentKind {
637 AgentKind::Operator
638}
639
640/// Set of **capability hint keys** for the SpawnerLayer required by a Blueprint.
641///
642/// # Design rationale (= for the person who will reconstruct this later)
643///
644/// A Blueprint is a pure layer of flow.ir + agent name binding and holds no middleware
645/// **implementation**. Nevertheless there are cases where the caller must be told the BP
646/// needs certain **capabilities** — e.g. "MainAI hook required", "Operator delegate path
647/// required", operator role mode switching, presence/absence of senior escalation, and
648/// so on.
649///
650/// `spawner_hints.layers` is the place where those capabilities are declared as **string
651/// keys**. The engine-side `LayerRegistry` (= consumer crate) resolves key → factory and
652/// wraps the compiled routes with a `SpawnerStack`. The Blueprint does not import the
653/// concrete `MainAIMiddleware` type; it exposes intent through strings such as `"main_ai"`
654/// (= separates the pure Flow layer from implementation details).
655///
656/// # Canonical hint keys
657///
658/// - `"main_ai"` → `MainAIMiddleware` (= fires SpawnHook before/after when kind is MainAi/Composite)
659/// - `"senior_escalation"` → `SeniorEscalationMiddleware` (= fires SeniorBridge.ask on worker ok=false)
660/// - `"operator_delegate"` → `OperatorDelegateMiddleware` (= delegates the entire spawn to an external Operator.execute)
661///
662/// # Behavior of unregistered keys
663///
664/// If the engine-side LayerRegistry has no matching factory, the key is **silently skipped**
665/// (= lenient default). This preserves Blueprint portability (= an unsupported capability in
666/// another deployment falls back gracefully).
667#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)]
668#[serde(deny_unknown_fields)]
669pub struct SpawnerHints {
670 /// Ordered list of layer hint keys to wrap around the SpawnerStack.
671 #[serde(default)]
672 pub layers: Vec<String>,
673}
674
675// ──────────────────────────────────────────────────────────────────────────
676// AgentDef / AgentKind / AgentProfile / AgentMeta
677// ──────────────────────────────────────────────────────────────────────────
678
679/// Maps an agent name to a Worker IMPL kind and its configuration. Referenced from flow.ir
680/// `Step.ref` by name.
681///
682/// # Design
683///
684/// `AgentDef.kind` directly expresses the **Worker IMPL axis** (= not the old Spawner axis).
685/// Dispatching to a host Spawner adapter (`InProcSpawner` / `ProcessSpawner` /
686/// `OperatorSpawner`) is done by an internal Resolver on the compiler side. The design goal
687/// is "do not make the caller aware of which Spawner hosts the Worker IMPL"; the caller
688/// (Blueprint author) sees only the WorkerIMPL viewpoint.
689///
690/// A Spawner-axis hint (= "which adapter would you prefer running this Worker on", as a
691/// priority list) will be added via a future `spawner_hint: Vec<Spawner>` field as a carry.
692/// The current internal Resolver is a fixed 1:1 mapping, so the field is unnecessary today.
693#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
694#[serde(deny_unknown_fields)]
695pub struct AgentDef {
696 /// Agent name (= referenced from flow.ir `Step.ref`).
697 pub name: String,
698 /// Worker IMPL kind (= see [`AgentKind`]).
699 pub kind: AgentKind,
700 /// Free-form schema per kind. Interpreted by the SpawnerFactory.
701 #[serde(default)]
702 pub spec: Value,
703 /// Agent persona information (system_prompt / model / tools, etc.). Orthogonal to the
704 /// backend kind and is a first-class field. Expected to be populated by
705 /// `agent_md_loader` from the frontmatter + body of an `agent.md`. `None` = an agent
706 /// without a profile (= backend built solely from `spec`).
707 #[serde(default)]
708 pub profile: Option<AgentProfile>,
709 /// Agent-level metadata (description / version / tags).
710 #[serde(default)]
711 pub meta: Option<AgentMeta>,
712 /// GH #46 M2 — inline [`Runner`] declaration: the highest-priority
713 /// tier of the [`resolve_runner`] cascade. `None` = this agent
714 /// declares no inline Runner (falls through to [`Self::runner_ref`],
715 /// then the legacy `profile.worker_binding` fallback, then
716 /// `Blueprint.default_runner`).
717 #[serde(default, skip_serializing_if = "Option::is_none")]
718 pub runner: Option<Runner>,
719 /// GH #46 M2 — a [`RunnerDef::name`] reference into
720 /// `Blueprint.runners` (second-priority tier of [`resolve_runner`]).
721 /// `None` = this agent declares no Runner registry reference.
722 #[serde(default, skip_serializing_if = "Option::is_none")]
723 pub runner_ref: Option<String>,
724 /// GH #50 — opt-in declaration of which OUTPUT channel this agent's
725 /// verdict token lives on, and the closed set of tokens it may emit
726 /// through that channel (see [`VerdictContract`]). Consumed by the
727 /// `mlua-swarm` core crate's `Compiler::compile` to lint
728 /// `Branch`/`Loop` `Eq`/`Ne`/`In` conds against this agent's output at
729 /// register time; a follow-up submit-time producer gate is a separate
730 /// enforcement point. `None` (the default) — this agent declares no
731 /// contract; a cond comparing its output to a literal is unchanged (at
732 /// most a `tracing::warn!`, never rejected) — every pre-GH-#50
733 /// Blueprint is unaffected, byte-for-byte.
734 #[serde(default, skip_serializing_if = "Option::is_none")]
735 pub verdict: Option<VerdictContract>,
736}
737
738/// Agent persona information. Orthogonal to the backend kind (Shell / InProc / Operator).
739///
740/// Populated by `agent_md_loader::load_dir` from the frontmatter and Markdown body of
741/// `agents/*.md` in agent-profiles. The backend (e.g. AgentBlockOperator) receives this
742/// struct at construction / dispatch time and consumes `system_prompt` as the LLM API
743/// system message and `model` / `tools` as configuration.
744///
745/// C-C-specific fields (`permissionMode` / `memory` / `abtest`, etc.) are dumped into
746/// `extras: Value`, and consumers that need them read them out. This is the escape hatch
747/// that keeps the schema future-proof rather than making it strict.
748#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
749#[serde(deny_unknown_fields)]
750pub struct AgentProfile {
751 /// Markdown body (= system prompt content).
752 #[serde(default)]
753 pub system_prompt: String,
754 /// LLM model identifier (e.g. `"sonnet"` / `"haiku"` / `"opus"`).
755 #[serde(default)]
756 pub model: Option<String>,
757 /// Reasoning effort (e.g. `"low"` / `"medium"` / `"high"`).
758 #[serde(default)]
759 pub effort: Option<String>,
760 /// List of available tool names (normalized from the CSV form in frontmatter).
761 #[serde(default)]
762 pub tools: Vec<String>,
763 /// Frontmatter `description`. A short one-line description.
764 #[serde(default)]
765 pub description: Option<String>,
766 /// C-C-specific / future-proof fields (permissionMode / memory / abtest / ...).
767 /// Shape is the leftover keys of the agent.md frontmatter dumped as a JSON object.
768 #[serde(default)]
769 pub extras: Value,
770 /// Content hash (blake3 32-byte hex) of the agent body (= `system_prompt`).
771 ///
772 /// # Purpose
773 ///
774 /// When the Enhance loop receives a Patch that replaces
775 /// `/agents/N/profile/system_prompt`, the post-hook in `patch_applier.lua`
776 /// recomputes this field (= new blake3 of the body) and updates it automatically.
777 /// This is the field that structurally prevents a Blueprint carrying a stale hash
778 /// from being committed.
779 ///
780 /// - `None` = hash not computed (= manually built agent, or a Blueprint predating this field)
781 /// - `Some(hex)` = latest hash at agent-profiles seed time or after PatchApplier
782 ///
783 /// Planned to be used as the cache-index key in `AgentStore`.
784 #[serde(default)]
785 pub version_hash: Option<String>,
786 /// Claude Code SubAgent definition name this agent binds to at spawn
787 /// time (e.g. "mse-worker-coder"). Why: the Blueprint is the single
788 /// source of truth for the declaration↔executor binding — an external
789 /// registry would duplicate what `tools` already declares and drift.
790 /// `None` is valid for agents whose operator backend never dispatches
791 /// a SubAgent (direct-LLM operators); WS thin-path operators require
792 /// it at compile time (see `Operator::requires_worker_binding`).
793 #[serde(default, skip_serializing_if = "Option::is_none")]
794 pub worker_binding: Option<String>,
795}
796
797/// SoT of the **Worker IMPL axis**. A closed enum managed inside Swarm and extended by
798/// variant addition through **explicit maintenance**. String lookup / escape hatches are
799/// deliberately not adopted.
800///
801/// This enum **expresses Worker IMPL directly**; dispatching to a host Spawner adapter is
802/// resolved by an internal Resolver on the compiler side (= callers see only the Worker
803/// IMPL viewpoint).
804///
805/// # Internal Resolver mapping (= currently a fixed 1:1, carry: priority list form)
806///
807/// | AgentKind | Host Spawner adapter |
808/// |---|---|
809/// | `Lua` | `InProcSpawner` (mlua VM eval) |
810/// | `RustFn` | `InProcSpawner` (Rust closure) |
811/// | `AgentBlock` | `InProcSpawner` (agent-block-core SDK in-process) |
812/// | `Subprocess` | `ProcessSpawner` (child process launch) |
813/// | `Operator` | `OperatorSpawner` (interactive role / Human-MainAI delegation) |
814#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, JsonSchema)]
815#[serde(rename_all = "snake_case")]
816pub enum AgentKind {
817 /// Lua script eval through the mlua VM (= factory-side registry looked up by `spec.fn_id`).
818 Lua,
819 /// Rust closure (= factory-side registry looked up by `spec.fn_id`).
820 RustFn,
821 /// Headless LLM agent via the agent-block-core SDK (in-process).
822 AgentBlock,
823 /// Child-process launch (= `spec.program` + `args`, via the ProcessSpawner path).
824 Subprocess,
825 /// Interactive Operator role (= MainAI / Human delegation, `spec.operator_ref`).
826 Operator,
827}
828
829// ──────────────────────────────────────────────────────────────────────────
830// VerdictContract / VerdictChannel (GH #50 — opt-in cond↔output-shape lint)
831// ──────────────────────────────────────────────────────────────────────────
832
833/// Opt-in per-agent declaration of the step OUTPUT shape a downstream
834/// `Branch`/`Loop` `cond` is allowed to structurally compare against — see
835/// the `blueprint-authoring.md` guide's "Returning verdicts to drive BP
836/// flow" section for the Pattern A/B shapes this mirrors. Consumed by the
837/// `mlua-swarm` core crate's `Compiler::compile` (a register-time,
838/// read-only lint over `Branch`/`Loop` `Eq`/`Ne`/`In` conds — no `flow`
839/// rewriting, no new `Expr` forms) and, as a follow-up, by the server's
840/// submit-time producer gate. `None` on [`AgentDef::verdict`] (the
841/// default) means neither enforcement point runs for that agent — the
842/// pre-GH-#50 behavior, byte-for-byte.
843#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
844#[serde(deny_unknown_fields)]
845pub struct VerdictContract {
846 /// Which OUTPUT channel carries the verdict token — see
847 /// [`VerdictChannel`].
848 pub channel: VerdictChannel,
849 /// Closed set of the verdict tokens this agent may emit through the
850 /// declared `channel` (e.g. `["PASS", "BLOCKED"]`). A `Branch`/`Loop`
851 /// cond's `Lit` operand(s) compared against this agent's declared
852 /// channel must be members of this set.
853 pub values: Vec<String>,
854}
855
856/// Which step OUTPUT channel a [`VerdictContract`] addresses — the two
857/// canonical submit shapes documented in the `blueprint-authoring.md`
858/// guide's "Returning verdicts to drive BP flow" section.
859#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
860#[serde(rename_all = "lowercase")]
861pub enum VerdictChannel {
862 /// Pattern A — the plain step OUTPUT body IS the verdict scalar; a cond
863 /// addresses it as the bare step output (`$.<step>`).
864 Body,
865 /// Pattern B — the verdict is staged as the named part `"verdict"`
866 /// alongside a separate plain-body report; a cond addresses it as
867 /// `$.<step>.parts.verdict` (equivalently `$.<step>.parts["verdict"]`
868 /// — both forms normalize to the same canonical [`Path`](mlua_flow_ir::Path) `Display`).
869 Part,
870}
871
872// ──────────────────────────────────────────────────────────────────────────
873// Runner / RunnerDef / WorkerModel / resolve_runner (GH #46 Milestone 2)
874// ──────────────────────────────────────────────────────────────────────────
875
876/// The execution shell an agent's Worker IMPL runs inside — holding tool
877/// grant, model selection, and runtime capabilities. Tier 1 of the GH #46
878/// 3-tier Worker model (Runner / Agent / Context).
879///
880/// Runner here is broader than the ADK / OpenAI Agents SDK Runner (a loop
881/// driver): it is the execution shell holding tool grant, model
882/// selection, and runtime capabilities. Loop driving itself is the
883/// backend's job (Claude Code harness / AgentBlock runtime).
884///
885/// Resolved per-agent by [`resolve_runner`]'s 5-step cascade; wiring the
886/// resolved value into the launch path is Milestone 3 — this Milestone
887/// only declares the shape and the pure resolver.
888#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
889#[serde(tag = "backend", rename_all = "snake_case", deny_unknown_fields)]
890pub enum Runner {
891 /// Platform-neutral WebSocket Operator backend. The joined execution
892 /// environment may be Claude Code, Codex, or another MainAI/plugin that
893 /// implements the common binding and spawn contracts.
894 WsOperator {
895 /// Provider-defined launch variant selected by the execution environment.
896 variant: String,
897 /// Minimum tool grant the provider must enforce.
898 #[serde(default, skip_serializing_if = "Vec::is_empty")]
899 tools: Vec<String>,
900 },
901 /// WS backend: Claude Code subagent wrapper. `variant` is the
902 /// wrapper's subagent_type; `tools` mirrors the wrapper frontmatter =
903 /// enforced grant.
904 ///
905 /// Kept as a compatibility backend for existing Blueprints. New
906 /// platform-neutral declarations should use [`Self::WsOperator`].
907 WsClaudeCode {
908 /// The wrapper's `subagent_type` (= `WorkerBinding.variant` in the
909 /// `mlua-swarm` core crate).
910 variant: String,
911 /// Declared (informational) tool list — mirrors the wrapper
912 /// frontmatter; the actual grant is enforced by the wrapper file
913 /// itself, not by this list.
914 #[serde(default, skip_serializing_if = "Vec::is_empty")]
915 tools: Vec<String>,
916 },
917 /// In-process backend: agent-block runtime. `tools` is the effective
918 /// (enforced) tool set for the in-process registry.
919 AgentBlockInProcess {
920 /// Effective (enforced) tool set passed to the agent-block
921 /// runtime's registry — unlike WebSocket Runner tool requests, this
922 /// list is not merely informational.
923 #[serde(default, skip_serializing_if = "Vec::is_empty")]
924 tools: Vec<String>,
925 },
926 /// GH #83 — Subprocess EmbedAgent backend: the step runs headless
927 /// through the `ProcessSpawner` path, with the invocation described by
928 /// a [`SubprocessDef`] template looked up by name in
929 /// [`Blueprint::subprocesses`]. Name symmetry with
930 /// `AgentKind::Subprocess` is deliberate (1:1 — this variant is the
931 /// Runner-axis face of the same Worker IMPL kind).
932 ///
933 /// Per-agent overrides live HERE (not on `SubprocessDef`) so the
934 /// template struct stays flat and shareable across agents.
935 Subprocess {
936 /// [`SubprocessDef::name`] reference into
937 /// [`Blueprint::subprocesses`].
938 template: String,
939 /// Per-agent overrides applied on top of the referenced template
940 /// and the agent profile. Empty (all defaults) is omitted on the
941 /// wire.
942 #[serde(default, skip_serializing_if = "SubprocessOverrides::is_empty")]
943 overrides: SubprocessOverrides,
944 },
945}
946
947/// Per-agent overrides for [`Runner::Subprocess`] — values that take
948/// precedence over the agent's `profile.model` / `profile.tools` and the
949/// spawn-time `{work_dir}` placeholder source when rendering the
950/// [`SubprocessDef`] template. Lives on the Runner variant (not on
951/// `SubprocessDef`) so the template itself stays flat (no per-agent
952/// state, no variant axis).
953#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
954#[serde(deny_unknown_fields)]
955pub struct SubprocessOverrides {
956 /// Overrides the `{model}` placeholder value (wins over
957 /// `profile.model`).
958 #[serde(default, skip_serializing_if = "Option::is_none")]
959 pub model: Option<String>,
960 /// Overrides the `{tools_csv}` placeholder value (wins over
961 /// `profile.tools`).
962 #[serde(default, skip_serializing_if = "Vec::is_empty")]
963 pub tools: Vec<String>,
964 /// Overrides the child process working directory (wins over the
965 /// template's `cwd` and the spawn-time `{work_dir}` source).
966 #[serde(default, skip_serializing_if = "Option::is_none")]
967 pub cwd: Option<String>,
968}
969
970impl SubprocessOverrides {
971 /// `true` when every field is at its default — used by
972 /// `skip_serializing_if` so an empty overrides block stays off the
973 /// wire (pre-#83 byte-compatibility for the `Runner` enum).
974 pub fn is_empty(&self) -> bool {
975 self.model.is_none() && self.tools.is_empty() && self.cwd.is_none()
976 }
977}
978
979/// GH #83 — one declarative CLI invocation template: how a materialized
980/// worker payload (system prompt + task + model/tools/cwd) is rendered
981/// into a child-process invocation, and how its stdout is normalized back
982/// into the worker-result shape.
983///
984/// Deliberately a **flat struct** — no internal variant/kind
985/// discriminator. Adding support for a new CLI backend means adding one
986/// more named entry to [`Blueprint::subprocesses`], never a new enum arm
987/// or spawner branch (the `AgentKind` closed enum already owns the kind
988/// axis; nesting a second "backend" hierarchy under it is the exact
989/// complexity this shape refuses).
990///
991/// `argv` / `stdin` / `env` values / `cwd` may contain `{placeholder}`
992/// tokens drawn from a closed, logic-free set (`{system}` /
993/// `{system_file}` / `{prompt}` / `{model}` / `{tools_csv}` /
994/// `{work_dir}` / `{task_id}` / `{attempt}`). Rendering is pure string
995/// substitution — no conditionals, no loops, no expression language; the
996/// engine-side consumer validates tokens against the closed set at
997/// compile time. This crate stores the templates as plain strings only
998/// (IN-immutability: no execution logic here).
999#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1000#[serde(deny_unknown_fields)]
1001pub struct SubprocessDef {
1002 /// Registry key, referenced by `Runner::Subprocess { template }`.
1003 pub name: String,
1004 /// Program + arguments. `argv[0]` is the binary; every element may
1005 /// carry placeholder tokens.
1006 pub argv: Vec<String>,
1007 /// Rendered and piped to the child's stdin when `Some`; `None` = no
1008 /// stdin write (EOF immediately).
1009 #[serde(default, skip_serializing_if = "Option::is_none")]
1010 pub stdin: Option<String>,
1011 /// Extra environment variables (appended to the engine's `MSE_*`
1012 /// token exports). Values may carry placeholder tokens. `BTreeMap`
1013 /// for a deterministic wire order.
1014 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
1015 pub env: std::collections::BTreeMap<String, String>,
1016 /// Child working directory (may carry placeholder tokens). `None` =
1017 /// the spawn-time `{work_dir}` source decides (or the engine default
1018 /// when no source exists).
1019 #[serde(default, skip_serializing_if = "Option::is_none")]
1020 pub cwd: Option<String>,
1021 /// stdout normalization declaration. `None` = the engine's historical
1022 /// JSON-or-raw behavior, byte-for-byte.
1023 #[serde(default, skip_serializing_if = "Option::is_none")]
1024 pub output: Option<SubprocessOutput>,
1025 /// Streaming wire protocol for stdout (`"ndjson_lines"` /
1026 /// `"sse_events"` / `"length_prefixed"` — same vocabulary as the
1027 /// spec-based Subprocess path). `None` = plain mode.
1028 #[serde(default, skip_serializing_if = "Option::is_none")]
1029 pub stream_mode: Option<String>,
1030}
1031
1032/// GH #83 — declarative stdout → worker-result normalization for a
1033/// [`SubprocessDef`] (plain mode only; streaming modes keep their event
1034/// protocol untouched).
1035#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1036#[serde(deny_unknown_fields)]
1037pub struct SubprocessOutput {
1038 /// Expected stdout format. `Some("json")` = stdout MUST parse as
1039 /// JSON (an unparsable stdout is a failed step). `None` = the
1040 /// historical lenient JSON-or-raw wrap.
1041 #[serde(default, skip_serializing_if = "Option::is_none")]
1042 pub format: Option<String>,
1043 /// JSON Pointer (RFC 6901) selecting the worker-result value out of
1044 /// the parsed stdout (e.g. `"/result"`). `None` = the whole parsed
1045 /// value.
1046 #[serde(default, skip_serializing_if = "Option::is_none")]
1047 pub result_ptr: Option<String>,
1048 /// Where the ok/failure signal comes from: `"exit_code"` (default
1049 /// behavior) or a JSON Pointer into the parsed stdout whose value
1050 /// must be boolean `true` for ok.
1051 #[serde(default, skip_serializing_if = "Option::is_none")]
1052 pub ok_from: Option<String>,
1053 /// Declarative stdout → per-step worker-stats mapping (token usage
1054 /// / model / num_turns), applied by the engine after a successful
1055 /// JSON parse. `None` = no stats extraction (the engine still
1056 /// records exit code + declared model as baseline stats).
1057 #[serde(default, skip_serializing_if = "Option::is_none")]
1058 pub stats: Option<SubprocessStats>,
1059}
1060
1061/// Declarative stats extraction for a [`SubprocessOutput`] — JSON
1062/// Pointers (RFC 6901) into the parsed stdout, mirroring the
1063/// `result_ptr` idiom. Lets a declared CLI backend (e.g. `claude -p
1064/// --output-format json`, `codex exec --json`) surface token usage
1065/// without any engine-side backend branch. Same IN-immutability
1066/// discipline as the rest of this crate: pointers only, no logic.
1067#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1068#[serde(deny_unknown_fields)]
1069pub struct SubprocessStats {
1070 /// JSON Pointer selecting a usage OBJECT out of the parsed stdout.
1071 /// The engine reads `input_tokens`/`output_tokens` (falling back to
1072 /// the OpenAI-style `prompt_tokens`/`completion_tokens` spelling)
1073 /// and an optional `total_tokens` from it.
1074 #[serde(default, skip_serializing_if = "Option::is_none")]
1075 pub usage_ptr: Option<String>,
1076 /// JSON Pointer selecting the model name STRING that actually
1077 /// served the run (overrides the template's declared `{model}`
1078 /// value in the recorded stats when present).
1079 #[serde(default, skip_serializing_if = "Option::is_none")]
1080 pub model_ptr: Option<String>,
1081 /// JSON Pointer selecting the number of LLM turns (a JSON number).
1082 #[serde(default, skip_serializing_if = "Option::is_none")]
1083 pub num_turns_ptr: Option<String>,
1084}
1085
1086/// One [`Blueprint::runners`] registry entry — a named [`Runner`]
1087/// declaration referenced by `AgentDef.runner_ref` /
1088/// [`Blueprint::default_runner`]. Same registry shape as [`MetaDef`] (GH
1089/// #21 Phase 2).
1090#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1091#[serde(deny_unknown_fields)]
1092pub struct RunnerDef {
1093 /// Registry key, referenced by `AgentDef.runner_ref` /
1094 /// `Blueprint.default_runner`.
1095 pub name: String,
1096 /// The declared Runner.
1097 pub runner: Runner,
1098}
1099
1100/// Canonical GH #46 Worker unit: a resolved [`Runner`] paired with the
1101/// [`AgentDef`] it backs. The Milestone 4 adapter is the consumer that
1102/// turns this into a runtime spawn; this crate only declares the shape
1103/// (no execution logic lives here — see the crate doc's IN-immutability
1104/// discipline).
1105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1106#[serde(deny_unknown_fields)]
1107pub struct WorkerModel {
1108 /// The resolved Runner.
1109 pub runner: Runner,
1110 /// The agent this Runner backs.
1111 pub agent: AgentDef,
1112}
1113
1114/// Everything [`resolve_runner`] can fail with: an `AgentDef.runner_ref`
1115/// / `Blueprint.default_runner` reference that names no entry in
1116/// `Blueprint.runners`.
1117#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1118pub enum RunnerResolveError {
1119 /// `AgentDef.runner_ref` names a [`RunnerDef::name`] absent from
1120 /// `Blueprint.runners`.
1121 #[error(
1122 "agent '{agent}' runner_ref '{ref_name}' does not match any RunnerDef.name in \
1123 Blueprint.runners (defined: {available:?})"
1124 )]
1125 UnknownRunnerRef {
1126 /// The agent whose `runner_ref` didn't resolve.
1127 agent: String,
1128 /// The `runner_ref` value that was looked up.
1129 ref_name: String,
1130 /// The `RunnerDef.name`s that *are* declared, for the error message.
1131 available: Vec<String>,
1132 },
1133 /// `Blueprint.default_runner` names a [`RunnerDef::name`] absent from
1134 /// `Blueprint.runners`.
1135 #[error(
1136 "default_runner '{ref_name}' does not match any RunnerDef.name in Blueprint.runners \
1137 (defined: {available:?})"
1138 )]
1139 UnknownDefaultRunner {
1140 /// The `default_runner` value that was looked up.
1141 ref_name: String,
1142 /// The `RunnerDef.name`s that *are* declared, for the error message.
1143 available: Vec<String>,
1144 },
1145}
1146
1147/// Resolve `agent`'s effective [`Runner`] against `bp`, in cascade order
1148/// (highest priority first):
1149///
1150/// 1. `agent.runner` (inline declaration) — wins unconditionally.
1151/// 2. `agent.runner_ref`, resolved against `bp.runners` (an unresolved
1152/// name is [`RunnerResolveError::UnknownRunnerRef`]).
1153/// 3. Legacy fallback (agent-level): `agent.profile.worker_binding =
1154/// Some(variant)` becomes `Runner::WsClaudeCode { variant,
1155/// tools: profile.tools.clone() }` — the same synthesis
1156/// `crate::service::task_launch::derive_worker_bindings` (in the
1157/// `mlua-swarm` core crate) performs at launch time today.
1158/// 4. `bp.default_runner`, resolved against `bp.runners` (an unresolved
1159/// name is [`RunnerResolveError::UnknownDefaultRunner`]).
1160/// 5. `Ok(None)` — no Runner declared through any tier.
1161///
1162/// **Legacy (agent-level) beats `default_runner` (BP-global)**: tier 3
1163/// outranks tier 4, the same "agent-level wins over BP-global" rule the
1164/// ctx cascade (`AgentInline > MetaRef > BpGlobal`, see
1165/// `mlua-swarm`'s `core::explain::CtxTier`) already follows.
1166///
1167/// Pure and read-only: this Milestone does not wire the result into the
1168/// launch / compile path (Milestone 3 scope) — it only declares the
1169/// resolver.
1170pub fn resolve_runner(
1171 bp: &Blueprint,
1172 agent: &AgentDef,
1173) -> Result<Option<Runner>, RunnerResolveError> {
1174 // 1. inline — wins unconditionally.
1175 if let Some(runner) = &agent.runner {
1176 return Ok(Some(runner.clone()));
1177 }
1178
1179 // 2. runner_ref → bp.runners lookup.
1180 if let Some(ref_name) = &agent.runner_ref {
1181 return match bp.runners.iter().find(|def| &def.name == ref_name) {
1182 Some(def) => Ok(Some(def.runner.clone())),
1183 None => Err(RunnerResolveError::UnknownRunnerRef {
1184 agent: agent.name.clone(),
1185 ref_name: ref_name.clone(),
1186 available: bp.runners.iter().map(|d| d.name.clone()).collect(),
1187 }),
1188 };
1189 }
1190
1191 // 3. legacy fallback (agent-level `profile.worker_binding`) — outranks
1192 // `bp.default_runner` (tier 4).
1193 if let Some(variant) = agent
1194 .profile
1195 .as_ref()
1196 .and_then(|p| p.worker_binding.as_ref())
1197 {
1198 let tools = agent
1199 .profile
1200 .as_ref()
1201 .map(|p| p.tools.clone())
1202 .unwrap_or_default();
1203 return Ok(Some(Runner::WsClaudeCode {
1204 variant: variant.clone(),
1205 tools,
1206 }));
1207 }
1208
1209 // 4. bp.default_runner → bp.runners lookup.
1210 if let Some(ref_name) = &bp.default_runner {
1211 return match bp.runners.iter().find(|def| &def.name == ref_name) {
1212 Some(def) => Ok(Some(def.runner.clone())),
1213 None => Err(RunnerResolveError::UnknownDefaultRunner {
1214 ref_name: ref_name.clone(),
1215 available: bp.runners.iter().map(|d| d.name.clone()).collect(),
1216 }),
1217 };
1218 }
1219
1220 // 5. nothing declared through any tier.
1221 Ok(None)
1222}
1223
1224/// Which declaration tier supplied a [`BoundAgent`]'s resolved Runner.
1225/// Kept in the immutable snapshot so explain surfaces can distinguish a
1226/// first-class binding from the Claude Code compatibility fallback.
1227#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1228#[serde(rename_all = "snake_case")]
1229pub enum RunnerResolutionSource {
1230 /// `AgentDef.runner`.
1231 AgentInline,
1232 /// `AgentDef.runner_ref` resolved through `Blueprint.runners`.
1233 AgentRef,
1234 /// Deprecated `AgentProfile.worker_binding` compatibility path.
1235 LegacyWorkerBinding,
1236 /// `Blueprint.default_runner` resolved through `Blueprint.runners`.
1237 BlueprintDefault,
1238 /// No Runner applies to this in-process or otherwise unbound agent.
1239 None,
1240}
1241
1242/// Strongly typed identity of one immutable [`BoundAgent`] snapshot.
1243/// Transparent serde keeps the public JSON wire form a plain string.
1244#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, JsonSchema)]
1245#[serde(transparent)]
1246pub struct BindingDigest(String);
1247
1248impl BindingDigest {
1249 /// Compute the canonical `sha256:<lowercase-hex>` digest of `bytes`.
1250 pub fn sha256(bytes: impl AsRef<[u8]>) -> Self {
1251 use sha2::Digest as _;
1252 Self(format!(
1253 "sha256:{}",
1254 hex::encode(sha2::Sha256::digest(bytes.as_ref()))
1255 ))
1256 }
1257
1258 /// Borrow the stable wire representation.
1259 pub fn as_str(&self) -> &str {
1260 &self.0
1261 }
1262}
1263
1264impl std::fmt::Display for BindingDigest {
1265 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1266 f.write_str(&self.0)
1267 }
1268}
1269
1270impl std::str::FromStr for BindingDigest {
1271 type Err = BindingDigestParseError;
1272
1273 fn from_str(value: &str) -> Result<Self, Self::Err> {
1274 let Some(hex_part) = value.strip_prefix("sha256:") else {
1275 return Err(BindingDigestParseError::InvalidFormat(value.to_string()));
1276 };
1277 let canonical = hex_part.len() == 64
1278 && hex_part
1279 .bytes()
1280 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
1281 if !canonical {
1282 return Err(BindingDigestParseError::InvalidFormat(value.to_string()));
1283 }
1284 Ok(Self(value.to_string()))
1285 }
1286}
1287
1288impl<'de> Deserialize<'de> for BindingDigest {
1289 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1290 where
1291 D: serde::Deserializer<'de>,
1292 {
1293 use std::str::FromStr as _;
1294 let value = String::deserialize(deserializer)?;
1295 Self::from_str(&value).map_err(serde::de::Error::custom)
1296 }
1297}
1298
1299/// Rejection returned when an external binding digest is not in canonical
1300/// `sha256:<64 lowercase hex>` form.
1301#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1302pub enum BindingDigestParseError {
1303 /// Unsupported algorithm prefix, wrong length, uppercase, or non-hex.
1304 #[error("invalid binding digest '{0}'; expected sha256:<64 lowercase hex>")]
1305 InvalidFormat(String),
1306}
1307
1308/// Platform-neutral request sent to an [`AgentBindingProvider`](https://docs.rs/mlua-swarm)
1309/// before a Run is dispatched.
1310///
1311/// The request contains only Swarm declarations. A provider may resolve
1312/// platform aliases or inspect its own execution environment, but Swarm
1313/// validates the returned [`BindReceipt`] before accepting it.
1314#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1315#[serde(deny_unknown_fields)]
1316pub struct BindRequest {
1317 /// Logical agent name; the receipt correlation key.
1318 pub agent: String,
1319 /// Digest of the declaration-only [`BoundAgent`] snapshot.
1320 pub request_digest: BindingDigest,
1321 /// Runner backend family Core resolved for this agent.
1322 pub backend: BindingBackend,
1323 /// Provider-specific routing key. For Operator-backed runners this is
1324 /// the logical `operator_ref`, never a runtime session id.
1325 #[serde(default, skip_serializing_if = "Option::is_none")]
1326 pub binding_target: Option<String>,
1327 /// Requested model name or tier from [`AgentProfile::model`].
1328 #[serde(default, skip_serializing_if = "Option::is_none")]
1329 pub requested_model: Option<String>,
1330 /// Minimum tool grant declared by the resolved [`Runner`].
1331 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1332 pub requested_tools: Vec<String>,
1333 /// Platform launch variant requested by the resolved [`Runner`].
1334 #[serde(default, skip_serializing_if = "Option::is_none")]
1335 pub launch_variant: Option<String>,
1336}
1337
1338/// Backend family a binding provider must resolve.
1339#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1340#[serde(rename_all = "snake_case")]
1341pub enum BindingBackend {
1342 /// Platform-neutral Operator/MainAI WebSocket execution.
1343 WsOperator,
1344 /// Claude Code wrapper dispatched through an Operator WebSocket.
1345 WsClaudeCode,
1346 /// AgentBlock registry enforced in the Server process.
1347 AgentBlockInProcess,
1348}
1349
1350/// Provider report describing the effective runtime binding for one agent.
1351/// This value is untrusted until Swarm validates it against [`BindRequest`].
1352#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1353#[serde(deny_unknown_fields)]
1354pub struct BindReceipt {
1355 /// Logical agent name copied from the request.
1356 pub agent: String,
1357 /// Declaration digest copied from the request. Core rejects stale or
1358 /// cross-request receipts even when the logical agent name matches.
1359 pub request_digest: BindingDigest,
1360 /// Stable provider implementation identifier.
1361 pub provider_id: String,
1362 /// Provider or adapter revision used to resolve the binding.
1363 #[serde(default, skip_serializing_if = "Option::is_none")]
1364 pub provider_revision: Option<String>,
1365 /// Effective model after platform alias/tier resolution.
1366 #[serde(default, skip_serializing_if = "Option::is_none")]
1367 pub resolved_model: Option<String>,
1368 /// Effective tool grant enforced by the execution environment.
1369 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1370 pub effective_tools: Vec<String>,
1371 /// Effective platform launch variant.
1372 #[serde(default, skip_serializing_if = "Option::is_none")]
1373 pub launch_variant: Option<String>,
1374 /// Optional digest of the provider-observed capability snapshot. This is
1375 /// a drift/lint correlation key, not independent security evidence.
1376 #[serde(
1377 default,
1378 alias = "evidence_digest",
1379 skip_serializing_if = "Option::is_none"
1380 )]
1381 pub capability_snapshot_digest: Option<BindingDigest>,
1382}
1383
1384/// One provider outcome for a single [`BindRequest`].
1385///
1386/// A provider reports exactly one outcome per requested agent. `Bound`
1387/// carries an (untrusted) [`BindReceipt`] Core still validates; `Unbound`
1388/// records that the execution environment currently offers no capability for
1389/// the request (e.g. the role has not joined, or the manifest declares no
1390/// matching launch variant). Whether an `Unbound` outcome fails the launch
1391/// or is merely observed is decided by
1392/// [`CompilerStrategy::strict_binding`] — not by the provider.
1393#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1394#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)]
1395pub enum BindOutcome {
1396 /// The provider resolved a receipt for the agent. Still untrusted until
1397 /// Core validates it against the originating [`BindRequest`].
1398 Bound {
1399 /// Provider-reported binding, validated by Core before acceptance.
1400 receipt: BindReceipt,
1401 },
1402 /// The provider offers no capability for the request right now. The
1403 /// `reason` is human-facing diagnostic text only; it never enters the
1404 /// [`BoundAgent`] snapshot or its digest lineage.
1405 Unbound {
1406 /// Logical agent name copied from the request.
1407 agent: String,
1408 /// Why the provider could not bind the agent.
1409 reason: String,
1410 },
1411}
1412
1413/// Core-validated capability statement pinned into a [`BoundAgent`].
1414///
1415/// It deliberately omits the logical agent name because the containing
1416/// snapshot already supplies that identity.
1417#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1418#[serde(deny_unknown_fields)]
1419pub struct BindingAttestation {
1420 /// Declaration-only digest the provider attested.
1421 pub request_digest: BindingDigest,
1422 /// Stable provider implementation identifier.
1423 pub provider_id: String,
1424 /// Provider or adapter revision used to resolve the binding.
1425 #[serde(default, skip_serializing_if = "Option::is_none")]
1426 pub provider_revision: Option<String>,
1427 /// Effective model after platform alias/tier resolution.
1428 #[serde(default, skip_serializing_if = "Option::is_none")]
1429 pub resolved_model: Option<String>,
1430 /// Effective tool grant, canonicalized by Swarm.
1431 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1432 pub effective_tools: Vec<String>,
1433 /// Effective platform launch variant.
1434 #[serde(default, skip_serializing_if = "Option::is_none")]
1435 pub launch_variant: Option<String>,
1436 /// Optional digest of the provider-observed capability snapshot.
1437 #[serde(
1438 default,
1439 alias = "evidence_digest",
1440 skip_serializing_if = "Option::is_none"
1441 )]
1442 pub capability_snapshot_digest: Option<BindingDigest>,
1443}
1444
1445/// One effective capability advertised by an execution-environment
1446/// provider. Operator manifests normally publish one entry per wrapper
1447/// variant.
1448#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1449#[serde(deny_unknown_fields)]
1450pub struct AgentProviderCapability {
1451 /// Platform launch variant this capability serves. `None` is reserved
1452 /// for backends without a variant axis.
1453 #[serde(default, skip_serializing_if = "Option::is_none")]
1454 pub launch_variant: Option<String>,
1455 /// Effective model selected by the provider.
1456 #[serde(default, skip_serializing_if = "Option::is_none")]
1457 pub resolved_model: Option<String>,
1458 /// Effective tool grant enforced by the provider.
1459 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1460 pub effective_tools: Vec<String>,
1461 /// Optional digest of the provider-observed capability snapshot.
1462 #[serde(
1463 default,
1464 alias = "evidence_digest",
1465 skip_serializing_if = "Option::is_none"
1466 )]
1467 pub capability_snapshot_digest: Option<BindingDigest>,
1468}
1469
1470/// Capability manifest supplied by an Operator/MainAI or an official
1471/// execution-platform plugin when joining the Server.
1472#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1473#[serde(deny_unknown_fields)]
1474pub struct AgentProviderManifest {
1475 /// Stable provider implementation identifier.
1476 pub provider_id: String,
1477 /// Provider or adapter revision used to inspect capabilities.
1478 #[serde(default, skip_serializing_if = "Option::is_none")]
1479 pub provider_revision: Option<String>,
1480 /// Effective capabilities available through this provider instance.
1481 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1482 pub capabilities: Vec<AgentProviderCapability>,
1483}
1484
1485/// Immutable, Run-scoped result of binding the Runner / Agent / Context
1486/// layers for one logical agent.
1487///
1488/// This is derived state, not a fourth authoring source of truth. The full
1489/// [`AgentDef`] is retained deliberately: resume/replay must not re-read a
1490/// changed role prompt or result contract from a mutable Blueprint registry.
1491/// Capability attestation is adapter-owned and is therefore not guessed here;
1492/// the resolved [`Runner`] remains a declaration until an adapter records its
1493/// requested/effective comparison.
1494#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1495#[serde(deny_unknown_fields)]
1496pub struct BoundAgent {
1497 /// Logical agent definition pinned for the Run.
1498 pub agent: AgentDef,
1499 /// Runner selected by [`resolve_runner`], if this agent needs one.
1500 #[serde(default, skip_serializing_if = "Option::is_none")]
1501 pub runner: Option<Runner>,
1502 /// Effective static Context policy (`AgentMeta.context_policy` wins over
1503 /// `Blueprint.default_context_policy`). Runtime context values are not
1504 /// embedded here.
1505 #[serde(default, skip_serializing_if = "Option::is_none")]
1506 pub context_policy: Option<ContextPolicy>,
1507 /// Declaration tier that supplied `runner`.
1508 pub runner_source: RunnerResolutionSource,
1509 /// Effective capability statement accepted from the injected binding
1510 /// provider. `None` preserves the declaration-only compatibility path.
1511 #[serde(default, skip_serializing_if = "Option::is_none")]
1512 pub attestation: Option<BindingAttestation>,
1513 /// SHA-256 over the other fields of this snapshot, prefixed with
1514 /// `sha256:`. This is replay identity and an observability correlation
1515 /// key, not a signature.
1516 pub binding_digest: BindingDigest,
1517}
1518
1519/// Failure while constructing immutable [`BoundAgent`] snapshots.
1520#[derive(Debug, thiserror::Error)]
1521pub enum BoundAgentResolveError {
1522 /// A Runner reference did not resolve.
1523 #[error(transparent)]
1524 Runner(#[from] RunnerResolveError),
1525 /// The snapshot input could not be serialized for deterministic hashing.
1526 #[error("bound agent '{agent}' could not be serialized for digest: {source}")]
1527 Digest {
1528 /// Logical agent name.
1529 agent: String,
1530 /// Serialization failure.
1531 source: serde_json::Error,
1532 },
1533 /// Strict binding rejected the deprecated Claude Code compatibility
1534 /// declaration instead of silently accepting it.
1535 #[error(
1536 "agent '{agent}' uses deprecated profile.worker_binding; strict binding requires runner or runner_ref"
1537 )]
1538 LegacyWorkerBindingDisabled {
1539 /// Logical agent that must be migrated.
1540 agent: String,
1541 },
1542}
1543
1544#[derive(Serialize)]
1545struct BoundAgentDigestInput<'a> {
1546 agent: &'a AgentDef,
1547 runner: &'a Option<Runner>,
1548 context_policy: &'a Option<ContextPolicy>,
1549 runner_source: RunnerResolutionSource,
1550 attestation: &'a Option<BindingAttestation>,
1551}
1552
1553impl BoundAgent {
1554 /// Replace the effective capability attestation and recompute replay
1555 /// identity over the complete immutable snapshot.
1556 pub fn set_attestation(
1557 &mut self,
1558 attestation: BindingAttestation,
1559 ) -> Result<(), BoundAgentResolveError> {
1560 self.attestation = Some(attestation);
1561 self.recompute_binding_digest()
1562 }
1563
1564 /// Recompute `binding_digest` after a trusted snapshot mutation.
1565 pub fn recompute_binding_digest(&mut self) -> Result<(), BoundAgentResolveError> {
1566 let digest_input = BoundAgentDigestInput {
1567 agent: &self.agent,
1568 runner: &self.runner,
1569 context_policy: &self.context_policy,
1570 runner_source: self.runner_source,
1571 attestation: &self.attestation,
1572 };
1573 let bytes =
1574 serde_json::to_vec(&digest_input).map_err(|source| BoundAgentResolveError::Digest {
1575 agent: self.agent.name.clone(),
1576 source,
1577 })?;
1578 self.binding_digest = BindingDigest::sha256(bytes);
1579 Ok(())
1580 }
1581}
1582
1583/// Resolve every `Blueprint.agents` entry into an immutable Run snapshot.
1584/// Output order follows `Blueprint.agents`, making persistence and explain
1585/// responses stable without a second sort.
1586pub fn resolve_bound_agents(bp: &Blueprint) -> Result<Vec<BoundAgent>, BoundAgentResolveError> {
1587 resolve_bound_agents_with_legacy(bp, true)
1588}
1589
1590/// Strict counterpart to [`resolve_bound_agents`]: rejects the deprecated
1591/// `profile.worker_binding` fallback. This is the migration gate for callers
1592/// that require every binding to use the platform-neutral Runner contract.
1593pub fn resolve_bound_agents_strict(
1594 bp: &Blueprint,
1595) -> Result<Vec<BoundAgent>, BoundAgentResolveError> {
1596 resolve_bound_agents_with_legacy(bp, false)
1597}
1598
1599fn resolve_bound_agents_with_legacy(
1600 bp: &Blueprint,
1601 allow_legacy: bool,
1602) -> Result<Vec<BoundAgent>, BoundAgentResolveError> {
1603 bp.agents
1604 .iter()
1605 .map(|agent| {
1606 let runner = resolve_runner(bp, agent)?;
1607 let runner_source = if agent.runner.is_some() {
1608 RunnerResolutionSource::AgentInline
1609 } else if agent.runner_ref.is_some() {
1610 RunnerResolutionSource::AgentRef
1611 } else if agent
1612 .profile
1613 .as_ref()
1614 .and_then(|p| p.worker_binding.as_ref())
1615 .is_some()
1616 {
1617 RunnerResolutionSource::LegacyWorkerBinding
1618 } else if bp.default_runner.is_some() {
1619 RunnerResolutionSource::BlueprintDefault
1620 } else {
1621 RunnerResolutionSource::None
1622 };
1623 if !allow_legacy && runner_source == RunnerResolutionSource::LegacyWorkerBinding {
1624 return Err(BoundAgentResolveError::LegacyWorkerBindingDisabled {
1625 agent: agent.name.clone(),
1626 });
1627 }
1628 let context_policy = agent
1629 .meta
1630 .as_ref()
1631 .and_then(|m| m.context_policy.clone())
1632 .or_else(|| bp.default_context_policy.clone());
1633 let digest_input = BoundAgentDigestInput {
1634 agent,
1635 runner: &runner,
1636 context_policy: &context_policy,
1637 runner_source,
1638 attestation: &None,
1639 };
1640 let bytes = serde_json::to_vec(&digest_input).map_err(|source| {
1641 BoundAgentResolveError::Digest {
1642 agent: agent.name.clone(),
1643 source,
1644 }
1645 })?;
1646 let binding_digest = BindingDigest::sha256(bytes);
1647 Ok(BoundAgent {
1648 agent: agent.clone(),
1649 runner,
1650 context_policy,
1651 runner_source,
1652 attestation: None,
1653 binding_digest,
1654 })
1655 })
1656 .collect()
1657}
1658
1659// ──────────────────────────────────────────────────────────────────────────
1660// OperatorDef / OperatorKind
1661// ──────────────────────────────────────────────────────────────────────────
1662
1663/// Kind axis of an Operator role (= "in which mode does this Operator run").
1664/// Corresponds 1:1 with the engine's runtime `OperatorKind`. Kept as a schema
1665/// duplicate so that BPs can be authored while depending only on this crate.
1666#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
1667#[serde(rename_all = "snake_case")]
1668pub enum OperatorKind {
1669 /// MainAI (= interactive AI Operator via WS client or SDK).
1670 MainAi,
1671 /// Automate (= normal spawn path, without human interception).
1672 #[default]
1673 Automate,
1674 /// Composite (= MainAi + Automate running side by side).
1675 Composite,
1676}
1677
1678/// Design-time definition of an Operator role (first-class).
1679///
1680/// `AgentDef.spec.operator_ref` references this struct's `name` as a logical role name.
1681/// Binding to a runtime backend (WS session / SDK / pool, etc.) is established via the
1682/// attach path; the BP side only declares "under this logical name we expect an Operator
1683/// of this Kind".
1684///
1685/// `spec` is an escape hatch for kind-specific config (WS endpoint / SDK profile / pool
1686/// binding, etc.). Even when empty, declaring `name` + `kind` alone is enough for
1687/// compile-time validation to succeed (= it guarantees that agent `operator_ref` values
1688/// reference an existing definition).
1689#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
1690#[serde(deny_unknown_fields)]
1691pub struct OperatorDef {
1692 /// Logical role name (= design-time symbol referenced from `AgentDef.spec.operator_ref`).
1693 pub name: String,
1694 /// Display name for UI / docs (optional).
1695 #[serde(default)]
1696 pub display_name: Option<String>,
1697 /// Kind axis of the Operator (MainAi / Automate / Composite) — the "BP
1698 /// Agent-level" tier of the 4-tier `OperatorKind` cascade (see
1699 /// `Blueprint.default_operator_kind` for the full tier list). `None`
1700 /// when this `OperatorDef` does not declare a kind; the resolver then
1701 /// falls through to BP Global / Default Fallback for agents referencing
1702 /// this role via `AgentDef.spec.operator_ref`.
1703 #[serde(default)]
1704 pub kind: Option<OperatorKind>,
1705 /// Kind-specific config (WS endpoint / SDK profile / pool binding, etc.). Interpreted
1706 /// by the factory.
1707 #[serde(default)]
1708 pub spec: Value,
1709 /// Operator persona information (e.g. system_prompt template). Same shape as
1710 /// `AgentDef.profile`. Used as a template when the Operator itself plays a "role".
1711 /// If `None`, the agent-side profile is used instead.
1712 #[serde(default)]
1713 pub profile: Option<AgentProfile>,
1714 /// Operator-level metadata (description / version / tags).
1715 #[serde(default)]
1716 pub meta: Option<AgentMeta>,
1717}
1718
1719/// Named, multi-step-shared declarative context payload (GH #21 Phase 2).
1720///
1721/// Lives in the [`Blueprint::metas`] pool and is referenced by name from
1722/// two independent consumers: a `$step_meta.ref` envelope embedded in a
1723/// Step's evaluated `in` value (the Step tier, resolved by
1724/// `EngineDispatcher::dispatch` in the `mlua-swarm` core crate at
1725/// dispatch time — see `EngineDispatcher::with_step_metas`), and
1726/// [`AgentMeta::meta_ref`] (the Agent tier, resolved at launch time and
1727/// merged UNDER the agent's inline `AgentMeta::ctx`). The pool lets
1728/// multiple Steps and/or Agents share one declarative context object by
1729/// name instead of repeating it inline.
1730#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
1731#[serde(deny_unknown_fields)]
1732pub struct MetaDef {
1733 /// Logical name (= referenced by `$step_meta.ref` and
1734 /// `AgentMeta.meta_ref`; unique within [`Blueprint::metas`]).
1735 pub name: String,
1736 /// Declarative context payload. Consumers expect a JSON `Object` so
1737 /// it can be shallow-merged with an `inline` override / an agent's
1738 /// own `ctx` (a non-`Object` value is rejected — loudly at dispatch
1739 /// time for the Step tier, defensively (warn + skip) at launch time
1740 /// for the Agent tier); the shape is otherwise free-form.
1741 pub ctx: Value,
1742}
1743
1744/// GH #27 (follow-up to #23) — Blueprint-declared override of the
1745/// `mlua-swarm` core crate's placement resolver
1746/// (`mlua_swarm::core::projection_placement::ProjectionPlacement`), which
1747/// decides where a Step's materialized OUTPUT file (submit-time sink,
1748/// server read-back, and spawn-time `ctx_projection` pointer — the "3
1749/// path" convergence point) is written on disk. Both fields are
1750/// independently optional and validated (`dir_template`) at
1751/// `Compiler::compile` time — see that resolver's `from_spec` doc for the
1752/// full rejection rules.
1753#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1754#[serde(deny_unknown_fields)]
1755pub struct ProjectionPlacementSpec {
1756 /// Which of the spawn-time `work_dir` / `project_root` to prefer as
1757 /// the materialize root, falling back to the other when the
1758 /// preferred one is absent. `"work_dir"` (default, current
1759 /// byte-compat behavior) | `"project_root"`. `None` = the default
1760 /// (`"work_dir"`).
1761 #[serde(default, skip_serializing_if = "Option::is_none")]
1762 pub root: Option<String>,
1763 /// Target directory template, relative to the resolved root, with a
1764 /// `{task_id}` placeholder substituted at materialize time. `None` =
1765 /// the default (`"workspace/tasks/{task_id}/ctx"`, current byte-compat
1766 /// behavior). Must be non-empty, contain the `{task_id}` placeholder,
1767 /// stay relative, and not contain any `..` path segment — rejected at
1768 /// `Compiler::compile` time otherwise.
1769 #[serde(default, skip_serializing_if = "Option::is_none")]
1770 pub dir_template: Option<String>,
1771}
1772
1773/// Agent / Operator level metadata (description / version / tags).
1774#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
1775#[serde(deny_unknown_fields)]
1776pub struct AgentMeta {
1777 /// Short human-readable description.
1778 #[serde(default)]
1779 pub description: Option<String>,
1780 /// Free-form version label.
1781 #[serde(default)]
1782 pub version: Option<String>,
1783 /// Tag list for classification / routing.
1784 #[serde(default)]
1785 pub tags: Vec<String>,
1786 /// GH #21 Phase 1 — "BP Agent-level" tier of the agent-context supply
1787 /// axis: a declarative object merged into `ctx.meta.runtime` for this
1788 /// agent's spawns, on top of (and winning over)
1789 /// [`Blueprint::default_agent_ctx`]. See that field's doc for the
1790 /// contrast with `default_init_ctx`. `None` = this agent declares no
1791 /// per-agent context (the BP-global tier alone applies, if any).
1792 #[serde(default, skip_serializing_if = "Option::is_none")]
1793 #[schemars(with = "Option<Value>")]
1794 pub ctx: Option<Value>,
1795 /// GH #21 Phase 1 — "BP Agent-level" tier of the [`ContextPolicy`]
1796 /// cascade: outranks [`Blueprint::default_context_policy`] for this
1797 /// agent. `None` = fall through to the BP-global policy (or pass-all
1798 /// if that is also `None`).
1799 #[serde(default, skip_serializing_if = "Option::is_none")]
1800 pub context_policy: Option<ContextPolicy>,
1801 /// GH #21 Phase 2 — "BP Agent-level" tier of the [`MetaDef`] pool:
1802 /// resolves against [`Blueprint::metas`] by name. The resolved
1803 /// `ctx` sits UNDER this agent's inline [`Self::ctx`] (inline wins
1804 /// on key collision). `None` = this agent declares no shared
1805 /// `MetaDef` reference.
1806 #[serde(default, skip_serializing_if = "Option::is_none")]
1807 pub meta_ref: Option<String>,
1808 /// GH #23 — the step-projection canonical name this agent's dispatched
1809 /// Steps should be addressed by (data-plane submit / `ContextPolicy`
1810 /// filter / `StepPointer`/`StepSummary` `name` / REST `:step` path /
1811 /// materialized file stem — see `mlua-swarm` core's
1812 /// `core::step_naming::StepNaming` for the table this field feeds).
1813 /// `None` = this agent declares no projection name; the canonical
1814 /// name falls back to the Step's `ref` (the flow.ir data-plane
1815 /// producer name), matching pre-GH-#23 behavior byte-for-byte.
1816 #[serde(default, skip_serializing_if = "Option::is_none")]
1817 pub projection_name: Option<String>,
1818}
1819
1820// ──────────────────────────────────────────────────────────────────────────
1821// Compiler hints / strategy
1822// ──────────────────────────────────────────────────────────────────────────
1823
1824/// Per-agent overrides / hints. Interpreted by the Compiler / SpawnerFactory; not required.
1825#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
1826#[serde(deny_unknown_fields)]
1827pub struct CompilerHints {
1828 /// Agent name → per-agent hint (= passed to `SpawnerFactory.build`).
1829 #[serde(default)]
1830 pub per_agent: HashMap<String, Value>,
1831 /// Global hints (= e.g. parallel limit, default timeout, ...).
1832 #[serde(default)]
1833 pub global: Value,
1834}
1835
1836/// Compiler behavior rules. Controls strict / lenient handling and default fallback.
1837#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
1838#[serde(deny_unknown_fields)]
1839pub struct CompilerStrategy {
1840 /// If `true` (default), an unresolved `Step.ref` is an error; if `false`, it falls
1841 /// through to the default Spawner.
1842 #[serde(default = "default_true")]
1843 pub strict_refs: bool,
1844 /// If `true` (default), an `AgentKind` missing from the registry is an error; if
1845 /// `false`, it is skipped.
1846 #[serde(default = "default_true")]
1847 pub strict_kind: bool,
1848 /// If `true`, every Runner-backed agent must obtain a Core-validated
1849 /// attestation at launch (a binding provider is required, and any agent
1850 /// the provider leaves `Unbound` fails the launch). If `false` (default),
1851 /// an unattested agent runs `DeclarationOnly` and the gap is only
1852 /// observed (tracing warn + a `RunRecord.degradations` entry).
1853 ///
1854 /// This default is deliberately the opposite of `strict_refs` /
1855 /// `strict_kind` (both default `true`): those two guard *structural
1856 /// integrity* of the Blueprint itself (an unresolved ref or unknown kind
1857 /// is always a Blueprint bug), whereas binding attestation is an
1858 /// *execution-assurance opt-in* — it depends on an execution environment
1859 /// being present to attest against, which is not available for embed-only
1860 /// or manifest-less launches. Requiring it by default would break every
1861 /// launch that has no provider, so it is opt-in per Blueprint.
1862 #[serde(default)]
1863 pub strict_binding: bool,
1864}
1865
1866fn default_true() -> bool {
1867 true
1868}
1869
1870impl Default for CompilerStrategy {
1871 fn default() -> Self {
1872 Self {
1873 strict_refs: true,
1874 strict_kind: true,
1875 strict_binding: false,
1876 }
1877 }
1878}
1879
1880// ──────────────────────────────────────────────────────────────────────────
1881// Blueprint metadata / origin
1882// ──────────────────────────────────────────────────────────────────────────
1883
1884/// Blueprint-level metadata (description / origin / tags / ttl / version label / alias).
1885#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
1886#[serde(deny_unknown_fields)]
1887pub struct BlueprintMetadata {
1888 /// Short human-readable description of the Blueprint.
1889 #[serde(default)]
1890 pub description: Option<String>,
1891 /// Provenance record (inline / file / algocline).
1892 #[serde(default)]
1893 pub origin: BlueprintOrigin,
1894 /// Tag list for classification / routing.
1895 #[serde(default)]
1896 pub tags: Vec<String>,
1897 /// Optional SemVer label (= match target for `TaskPipeline VersionSelector::SemVerReq`).
1898 /// Example: `"1.2.3"`. Rewritten by `EnhanceAdapter` on PATCH/MINOR/MAJOR bumps.
1899 #[serde(default, skip_serializing_if = "Option::is_none")]
1900 pub version_label: Option<String>,
1901 /// Optional LDS session alias label. The Swarm engine itself does not apply this
1902 /// (= it is free-form content); the value is expanded into the Spawn directive and
1903 /// reaches the MainAI. The MainAI is expected to establish a task session via
1904 /// `mcp__lds__session_create(root=..., alias=<this>)`, and to inject
1905 /// `LDS Session Alias: <this>` verbatim into the SubAgent dispatch prompt body.
1906 /// The SubAgent body then calls `mcp__lds__session_start(alias=<this>)` with the
1907 /// received alias. Worktree ownership is thereby unified under a single session, and
1908 /// cross-SubAgent / cross-worktree ownership blocks (= `not owned by this session`)
1909 /// cannot fire structurally.
1910 #[serde(default, skip_serializing_if = "Option::is_none")]
1911 pub project_name_alias: Option<String>,
1912 /// Optional default TTL (seconds) for tasks dispatched via this BP. Estimated by the
1913 /// Blueprint author from the flow shape (agent count × expected duration per agent).
1914 /// If `POST /v1/tasks` supplies `ttl_secs` explicitly, the body value wins; otherwise
1915 /// this metadata field is used as the default; if both are absent, the server global
1916 /// default (`default_run_ttl()` = 1800s) applies. Not needed for short chains (~5 min);
1917 /// recommended for long chains (14 agents × several minutes = 30-60 min).
1918 #[serde(default, skip_serializing_if = "Option::is_none")]
1919 pub default_run_ttl_secs: Option<u64>,
1920 /// GH #50 follow-up (issue `33bc825b`): promote `VerdictValueUnhandled`
1921 /// compile-time lint to a hard error. When `false` (or absent), a
1922 /// declared `AgentDef.verdict.values` entry that no downstream cond
1923 /// references is only surfaced via `tracing::warn!` (informational);
1924 /// when `true`, `Compiler::compile` rejects the Blueprint with
1925 /// `CompileError::VerdictValueUnhandled`. Opt-in so existing Blueprints
1926 /// that intentionally leave some verdict values as silent-pass
1927 /// informational tokens keep compiling unchanged.
1928 #[serde(default, skip_serializing_if = "Option::is_none")]
1929 pub strict_verdict_handling: Option<bool>,
1930}
1931
1932/// Provenance record of a Blueprint.
1933#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
1934#[serde(tag = "kind", rename_all = "snake_case")]
1935pub enum BlueprintOrigin {
1936 /// Inline construction, e.g. via a Rust struct literal or test code.
1937 #[default]
1938 Inline,
1939 /// Loaded from a file.
1940 File {
1941 /// Source file path.
1942 path: String,
1943 },
1944 /// Emitted by an algocline strategy (traced by `session_id`).
1945 Algo {
1946 /// Algocline session identifier.
1947 session_id: String,
1948 },
1949}
1950
1951#[cfg(test)]
1952mod tests {
1953 use super::*;
1954
1955 #[test]
1956 fn schema_version_default_parses() {
1957 let v = default_schema_version();
1958 assert_eq!(v.to_string(), "0.1.0");
1959 }
1960
1961 #[test]
1962 fn current_schema_version_const_matches() {
1963 assert_eq!(CURRENT_SCHEMA_VERSION, "0.1.0");
1964 }
1965
1966 #[test]
1967 fn blueprint_json_schema_exports_key_properties() {
1968 let schema = schemars::schema_for!(Blueprint);
1969 let v = serde_json::to_value(&schema).expect("schema serializes");
1970 let props = v["properties"].as_object().expect("object schema");
1971 for key in [
1972 "schema_version",
1973 "id",
1974 "flow",
1975 "agents",
1976 "operators",
1977 "metas",
1978 "hints",
1979 "strategy",
1980 "metadata",
1981 "spawner_hints",
1982 "default_agent_kind",
1983 "default_operator_kind",
1984 "default_init_ctx",
1985 "default_agent_ctx",
1986 "default_context_policy",
1987 "projection_placement",
1988 "audits",
1989 "runners",
1990 "default_runner",
1991 "check_policy",
1992 ] {
1993 assert!(props.contains_key(key), "missing property: {key}");
1994 }
1995 // semver override lands as a plain string
1996 assert_eq!(v["properties"]["schema_version"]["type"], "string");
1997 // enum variants (snake_case) survive into the schema (LLM author axis)
1998 let dump = v.to_string();
1999 assert!(dump.contains("agent_block"), "AgentKind variants in schema");
2000 assert!(dump.contains("main_ai"), "OperatorKind variants in schema");
2001 // nested defs are referenced (AgentDef reachable from agents[])
2002 assert!(dump.contains("AgentDef"), "AgentDef definition in schema");
2003 }
2004
2005 #[test]
2006 fn agent_profile_worker_binding_roundtrips_when_some() {
2007 let profile = AgentProfile {
2008 worker_binding: Some("mse-worker-coder".to_string()),
2009 ..Default::default()
2010 };
2011 let json = serde_json::to_value(&profile).expect("serializes");
2012 assert_eq!(json["worker_binding"], "mse-worker-coder");
2013 let back: AgentProfile = serde_json::from_value(json).expect("deserializes");
2014 assert_eq!(back.worker_binding.as_deref(), Some("mse-worker-coder"));
2015 }
2016
2017 #[test]
2018 fn agent_profile_worker_binding_omitted_when_none() {
2019 let profile = AgentProfile::default();
2020 let json = serde_json::to_value(&profile).expect("serializes");
2021 // `skip_serializing_if = "Option::is_none"` — the key must not appear at all.
2022 assert!(
2023 json.as_object().unwrap().get("worker_binding").is_none(),
2024 "worker_binding key must be absent when None: {json}"
2025 );
2026 let back: AgentProfile = serde_json::from_value(json).expect("deserializes");
2027 assert_eq!(back.worker_binding, None);
2028 }
2029
2030 // ──────────────────────────────────────────────────────────────
2031 // issue #19 ST3: `Blueprint.default_init_ctx`
2032 // ──────────────────────────────────────────────────────────────
2033
2034 fn minimal_bp(default_init_ctx: Option<Value>) -> Blueprint {
2035 Blueprint {
2036 schema_version: current_schema_version(),
2037 id: "bp-init-ctx-ut".into(),
2038 flow: FlowNode::Seq { children: vec![] },
2039 agents: vec![],
2040 operators: vec![],
2041 metas: vec![],
2042 hints: Default::default(),
2043 strategy: Default::default(),
2044 metadata: Default::default(),
2045 spawner_hints: Default::default(),
2046 default_agent_kind: AgentKind::Operator,
2047 default_operator_kind: None,
2048 default_init_ctx,
2049 default_agent_ctx: None,
2050 default_context_policy: None,
2051 projection_placement: None,
2052 audits: vec![],
2053 degradation_policy: None,
2054 runners: vec![],
2055 default_runner: None,
2056 subprocesses: vec![],
2057 check_policy: None,
2058 blueprint_ref_includes: Vec::new(),
2059 }
2060 }
2061
2062 #[test]
2063 fn blueprint_default_init_ctx_roundtrips_when_some() {
2064 let bp = minimal_bp(Some(serde_json::json!({ "seeded": true })));
2065 let json = serde_json::to_string(&bp).expect("serializes");
2066 let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
2067 assert_eq!(
2068 back.default_init_ctx,
2069 Some(serde_json::json!({ "seeded": true }))
2070 );
2071 assert_eq!(bp, back);
2072 }
2073
2074 #[test]
2075 fn blueprint_default_init_ctx_omitted_when_none() {
2076 let bp = minimal_bp(None);
2077 let json = serde_json::to_value(&bp).expect("serializes");
2078 // `skip_serializing_if = "Option::is_none"` — the key must not appear at all
2079 // (pre-#19 Blueprints round-trip byte-identical through this path).
2080 assert!(
2081 json.as_object().unwrap().get("default_init_ctx").is_none(),
2082 "default_init_ctx key must be absent when None: {json}"
2083 );
2084 let back: Blueprint = serde_json::from_value(json).expect("deserializes");
2085 assert_eq!(back.default_init_ctx, None);
2086 assert_eq!(bp, back);
2087 }
2088
2089 #[test]
2090 fn blueprint_json_schema_exports_default_init_ctx_as_nullable_value() {
2091 let schema = schemars::schema_for!(Blueprint);
2092 let v = serde_json::to_value(&schema).expect("schema serializes");
2093 assert!(
2094 v["properties"]["default_init_ctx"].is_object(),
2095 "default_init_ctx must appear in the exported schema: {v}"
2096 );
2097 }
2098
2099 // ──────────────────────────────────────────────────────────────
2100 // issue #21 Phase 1: `Blueprint.default_agent_ctx` /
2101 // `default_context_policy`, `AgentMeta.ctx` / `context_policy`,
2102 // `ContextPolicy`
2103 // ──────────────────────────────────────────────────────────────
2104
2105 #[test]
2106 fn blueprint_default_agent_ctx_and_context_policy_roundtrip_when_some() {
2107 let mut bp = minimal_bp(None);
2108 bp.default_agent_ctx = Some(serde_json::json!({ "org_conventions": "x" }));
2109 bp.default_context_policy = Some(ContextPolicy {
2110 include: Some(vec!["project_root".to_string()]),
2111 exclude: vec!["work_dir".to_string()],
2112 ..Default::default()
2113 });
2114 let json = serde_json::to_string(&bp).expect("serializes");
2115 let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
2116 assert_eq!(bp, back);
2117 assert_eq!(
2118 back.default_agent_ctx,
2119 Some(serde_json::json!({ "org_conventions": "x" }))
2120 );
2121 assert_eq!(
2122 back.default_context_policy,
2123 Some(ContextPolicy {
2124 include: Some(vec!["project_root".to_string()]),
2125 exclude: vec!["work_dir".to_string()],
2126 ..Default::default()
2127 })
2128 );
2129 }
2130
2131 #[test]
2132 fn blueprint_default_agent_ctx_and_context_policy_omitted_when_none() {
2133 let bp = minimal_bp(None);
2134 let json = serde_json::to_value(&bp).expect("serializes");
2135 let obj = json.as_object().unwrap();
2136 assert!(
2137 obj.get("default_agent_ctx").is_none(),
2138 "default_agent_ctx key must be absent when None: {json}"
2139 );
2140 assert!(
2141 obj.get("default_context_policy").is_none(),
2142 "default_context_policy key must be absent when None: {json}"
2143 );
2144 let back: Blueprint = serde_json::from_value(json).expect("deserializes");
2145 assert_eq!(back.default_agent_ctx, None);
2146 assert_eq!(back.default_context_policy, None);
2147 assert_eq!(bp, back);
2148 }
2149
2150 #[test]
2151 fn blueprint_json_schema_exports_agent_ctx_and_context_policy() {
2152 let schema = schemars::schema_for!(Blueprint);
2153 let v = serde_json::to_value(&schema).expect("schema serializes");
2154 assert!(
2155 v["properties"]["default_agent_ctx"].is_object(),
2156 "default_agent_ctx must appear in the exported schema: {v}"
2157 );
2158 assert!(
2159 v["properties"]["default_context_policy"].is_object(),
2160 "default_context_policy must appear in the exported schema: {v}"
2161 );
2162 }
2163
2164 // ──────────────────────────────────────────────────────────────
2165 // GH #27 (follow-up to #23): `Blueprint.projection_placement` /
2166 // `ProjectionPlacementSpec`
2167 // ──────────────────────────────────────────────────────────────
2168
2169 #[test]
2170 fn blueprint_projection_placement_roundtrips_when_some() {
2171 let mut bp = minimal_bp(None);
2172 bp.projection_placement = Some(ProjectionPlacementSpec {
2173 root: Some("project_root".to_string()),
2174 dir_template: Some("custom/{task_id}/out".to_string()),
2175 });
2176 let json = serde_json::to_string(&bp).expect("serializes");
2177 let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
2178 assert_eq!(bp, back);
2179 assert_eq!(
2180 back.projection_placement,
2181 Some(ProjectionPlacementSpec {
2182 root: Some("project_root".to_string()),
2183 dir_template: Some("custom/{task_id}/out".to_string()),
2184 })
2185 );
2186 }
2187
2188 #[test]
2189 fn blueprint_projection_placement_omitted_when_none() {
2190 let bp = minimal_bp(None);
2191 let json = serde_json::to_value(&bp).expect("serializes");
2192 assert!(
2193 json.as_object()
2194 .unwrap()
2195 .get("projection_placement")
2196 .is_none(),
2197 "projection_placement key must be absent when None: {json}"
2198 );
2199 let back: Blueprint = serde_json::from_value(json).expect("deserializes");
2200 assert_eq!(back.projection_placement, None);
2201 assert_eq!(bp, back);
2202 }
2203
2204 #[test]
2205 fn blueprint_json_schema_exports_projection_placement() {
2206 let schema = schemars::schema_for!(Blueprint);
2207 let v = serde_json::to_value(&schema).expect("schema serializes");
2208 assert!(
2209 v["properties"]["projection_placement"].is_object(),
2210 "projection_placement must appear in the exported schema: {v}"
2211 );
2212 }
2213
2214 #[test]
2215 fn agent_meta_ctx_and_context_policy_roundtrip_when_some() {
2216 let meta = AgentMeta {
2217 ctx: Some(serde_json::json!({ "k": "v" })),
2218 context_policy: Some(ContextPolicy {
2219 include: None,
2220 exclude: vec!["run_id".to_string()],
2221 ..Default::default()
2222 }),
2223 ..Default::default()
2224 };
2225 let json = serde_json::to_value(&meta).expect("serializes");
2226 let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
2227 assert_eq!(back, meta);
2228 }
2229
2230 #[test]
2231 fn agent_meta_ctx_and_context_policy_omitted_when_none() {
2232 let meta = AgentMeta::default();
2233 let json = serde_json::to_value(&meta).expect("serializes");
2234 let obj = json.as_object().unwrap();
2235 assert!(
2236 obj.get("ctx").is_none(),
2237 "ctx key must be absent when None: {json}"
2238 );
2239 assert!(
2240 obj.get("context_policy").is_none(),
2241 "context_policy key must be absent when None: {json}"
2242 );
2243 }
2244
2245 #[test]
2246 fn agent_meta_json_schema_exports_ctx_context_policy_and_meta_ref() {
2247 let schema = schemars::schema_for!(AgentMeta);
2248 let v = serde_json::to_value(&schema).expect("schema serializes");
2249 let props = v["properties"].as_object().expect("object schema");
2250 for key in [
2251 "description",
2252 "version",
2253 "tags",
2254 "ctx",
2255 "context_policy",
2256 "meta_ref",
2257 "projection_name",
2258 ] {
2259 assert!(props.contains_key(key), "missing property: {key}");
2260 }
2261 }
2262
2263 // ──────────────────────────────────────────────────────────────
2264 // issue #21 Phase 2: `MetaDef`, `Blueprint.metas`, `AgentMeta.meta_ref`
2265 // ──────────────────────────────────────────────────────────────
2266
2267 #[test]
2268 fn meta_def_roundtrips_through_json() {
2269 let def = MetaDef {
2270 name: "heavy-scan".to_string(),
2271 ctx: serde_json::json!({ "work_dir": "/x" }),
2272 };
2273 let json = serde_json::to_value(&def).expect("serializes");
2274 let back: MetaDef = serde_json::from_value(json).expect("deserializes");
2275 assert_eq!(back, def);
2276 }
2277
2278 #[test]
2279 fn blueprint_metas_omitted_when_empty() {
2280 let bp = minimal_bp(None);
2281 let json = serde_json::to_value(&bp).expect("serializes");
2282 assert!(
2283 json.as_object().unwrap().get("metas").is_none(),
2284 "metas key must be absent when empty: {json}"
2285 );
2286 let back: Blueprint = serde_json::from_value(json).expect("deserializes");
2287 assert!(back.metas.is_empty());
2288 assert_eq!(bp, back);
2289 }
2290
2291 #[test]
2292 fn blueprint_metas_roundtrips_when_non_empty() {
2293 let mut bp = minimal_bp(None);
2294 bp.metas = vec![MetaDef {
2295 name: "heavy-scan".to_string(),
2296 ctx: serde_json::json!({ "work_dir": "/x" }),
2297 }];
2298 let json = serde_json::to_string(&bp).expect("serializes");
2299 let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
2300 assert_eq!(bp, back);
2301 assert_eq!(back.metas.len(), 1);
2302 assert_eq!(back.metas[0].name, "heavy-scan");
2303 }
2304
2305 #[test]
2306 fn blueprint_json_schema_exports_metas() {
2307 let schema = schemars::schema_for!(Blueprint);
2308 let v = serde_json::to_value(&schema).expect("schema serializes");
2309 assert!(
2310 v["properties"]["metas"].is_object(),
2311 "metas must appear in the exported schema: {v}"
2312 );
2313 let dump = v.to_string();
2314 assert!(dump.contains("MetaDef"), "MetaDef definition in schema");
2315 }
2316
2317 #[test]
2318 fn agent_meta_meta_ref_roundtrips_when_some() {
2319 let meta = AgentMeta {
2320 meta_ref: Some("heavy-scan".to_string()),
2321 ..Default::default()
2322 };
2323 let json = serde_json::to_value(&meta).expect("serializes");
2324 assert_eq!(json["meta_ref"], "heavy-scan");
2325 let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
2326 assert_eq!(back, meta);
2327 }
2328
2329 #[test]
2330 fn agent_meta_meta_ref_omitted_when_none() {
2331 let meta = AgentMeta::default();
2332 let json = serde_json::to_value(&meta).expect("serializes");
2333 assert!(
2334 json.as_object().unwrap().get("meta_ref").is_none(),
2335 "meta_ref key must be absent when None: {json}"
2336 );
2337 let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
2338 assert_eq!(back.meta_ref, None);
2339 }
2340
2341 // ──────────────────────────────────────────────────────────────
2342 // GH #23: `AgentMeta.projection_name`
2343 // ──────────────────────────────────────────────────────────────
2344
2345 #[test]
2346 fn agent_meta_projection_name_roundtrips_when_some() {
2347 let meta = AgentMeta {
2348 projection_name: Some("plan".to_string()),
2349 ..Default::default()
2350 };
2351 let json = serde_json::to_value(&meta).expect("serializes");
2352 assert_eq!(json["projection_name"], "plan");
2353 let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
2354 assert_eq!(back, meta);
2355 }
2356
2357 #[test]
2358 fn agent_meta_projection_name_omitted_when_none() {
2359 let meta = AgentMeta::default();
2360 let json = serde_json::to_value(&meta).expect("serializes");
2361 assert!(
2362 json.as_object().unwrap().get("projection_name").is_none(),
2363 "projection_name key must be absent when None: {json}"
2364 );
2365 let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
2366 assert_eq!(back.projection_name, None);
2367 assert_eq!(back, meta);
2368 }
2369
2370 #[test]
2371 fn agent_meta_rejects_unknown_field_with_projection_name_present() {
2372 // `deny_unknown_fields` must still reject an unrelated stray key
2373 // even when `projection_name` is present alongside it (regression
2374 // guard: adding the field must not accidentally loosen the
2375 // contract for the rest of the struct).
2376 let json = serde_json::json!({
2377 "projection_name": "plan",
2378 "not_a_real_field": true
2379 });
2380 let err = serde_json::from_value::<AgentMeta>(json).unwrap_err();
2381 assert!(
2382 err.to_string().contains("not_a_real_field")
2383 || err.to_string().contains("unknown field"),
2384 "expected an unknown-field rejection, got: {err}"
2385 );
2386 }
2387
2388 #[test]
2389 fn context_policy_default_allows_everything() {
2390 let policy = ContextPolicy::default();
2391 assert!(policy.allows("project_root"));
2392 assert!(policy.allows("anything"));
2393 }
2394
2395 #[test]
2396 fn context_policy_include_only_allows_listed_names() {
2397 let policy = ContextPolicy {
2398 include: Some(vec!["project_root".to_string()]),
2399 exclude: vec![],
2400 ..Default::default()
2401 };
2402 assert!(policy.allows("project_root"));
2403 assert!(!policy.allows("work_dir"));
2404 }
2405
2406 #[test]
2407 fn context_policy_exclude_wins_over_include() {
2408 let policy = ContextPolicy {
2409 include: Some(vec!["project_root".to_string()]),
2410 exclude: vec!["project_root".to_string()],
2411 ..Default::default()
2412 };
2413 assert!(!policy.allows("project_root"));
2414 }
2415
2416 #[test]
2417 fn context_policy_roundtrips_through_json() {
2418 let policy = ContextPolicy {
2419 include: Some(vec!["a".to_string(), "b".to_string()]),
2420 exclude: vec!["c".to_string()],
2421 ..Default::default()
2422 };
2423 let json = serde_json::to_value(&policy).expect("serializes");
2424 let back: ContextPolicy = serde_json::from_value(json).expect("deserializes");
2425 assert_eq!(back, policy);
2426 }
2427
2428 #[test]
2429 fn context_policy_default_roundtrips_as_empty_object() {
2430 let policy = ContextPolicy::default();
2431 let json = serde_json::to_value(&policy).expect("serializes");
2432 assert_eq!(
2433 json,
2434 serde_json::json!({
2435 "include": null,
2436 "exclude": [],
2437 "steps": null,
2438 "steps_exclude": [],
2439 })
2440 );
2441 let back: ContextPolicy = serde_json::from_value(json).expect("deserializes");
2442 assert_eq!(back, policy);
2443 }
2444
2445 // ──────────────────────────────────────────────────────────────
2446 // ST5 (`projection-adapter`): `ContextPolicy.steps` / `steps_exclude`
2447 // ──────────────────────────────────────────────────────────────
2448
2449 #[test]
2450 fn context_policy_steps_default_allows_every_step() {
2451 let policy = ContextPolicy::default();
2452 assert!(policy.allows_step("planner"));
2453 assert!(policy.allows_step("anything"));
2454 }
2455
2456 #[test]
2457 fn context_policy_steps_include_only_allows_listed_names() {
2458 let policy = ContextPolicy {
2459 steps: Some(vec!["planner".to_string()]),
2460 ..Default::default()
2461 };
2462 assert!(policy.allows_step("planner"));
2463 assert!(!policy.allows_step("coder"));
2464 }
2465
2466 #[test]
2467 fn context_policy_steps_empty_list_allows_none() {
2468 let policy = ContextPolicy {
2469 steps: Some(vec![]),
2470 ..Default::default()
2471 };
2472 assert!(!policy.allows_step("planner"));
2473 }
2474
2475 #[test]
2476 fn context_policy_steps_exclude_wins_over_steps() {
2477 let policy = ContextPolicy {
2478 steps: Some(vec!["planner".to_string()]),
2479 steps_exclude: vec!["planner".to_string()],
2480 ..Default::default()
2481 };
2482 assert!(!policy.allows_step("planner"));
2483 }
2484
2485 #[test]
2486 fn context_policy_steps_roundtrips_through_json() {
2487 let policy = ContextPolicy {
2488 steps: Some(vec!["planner".to_string(), "coder".to_string()]),
2489 steps_exclude: vec!["reviewer".to_string()],
2490 ..Default::default()
2491 };
2492 let json = serde_json::to_value(&policy).expect("serializes");
2493 let back: ContextPolicy = serde_json::from_value(json).expect("deserializes");
2494 assert_eq!(back, policy);
2495 }
2496
2497 // ──────────────────────────────────────────────────────────────
2498 // GH #34: `AuditDef`, `AuditMode`, `Blueprint.audits`
2499 // ──────────────────────────────────────────────────────────────
2500
2501 #[test]
2502 fn blueprint_audits_omitted_when_empty() {
2503 let bp = minimal_bp(None);
2504 let json = serde_json::to_value(&bp).expect("serializes");
2505 assert!(
2506 json.as_object().unwrap().get("audits").is_none(),
2507 "audits key must be absent when empty: {json}"
2508 );
2509 let back: Blueprint = serde_json::from_value(json).expect("deserializes");
2510 assert!(back.audits.is_empty());
2511 assert_eq!(bp, back);
2512 }
2513
2514 #[test]
2515 fn blueprint_audits_roundtrips_when_non_empty() {
2516 let mut bp = minimal_bp(None);
2517 bp.audits = vec![AuditDef {
2518 agent: "auditor".to_string(),
2519 steps: Some(vec!["worker".to_string()]),
2520 mode: AuditMode::Sync,
2521 }];
2522 let json = serde_json::to_string(&bp).expect("serializes");
2523 let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
2524 assert_eq!(bp, back);
2525 assert_eq!(back.audits.len(), 1);
2526 assert_eq!(back.audits[0].agent, "auditor");
2527 assert_eq!(back.audits[0].mode, AuditMode::Sync);
2528 }
2529
2530 #[test]
2531 fn audit_def_steps_none_and_mode_default_when_omitted() {
2532 let json = serde_json::json!({ "agent": "auditor" });
2533 let def: AuditDef = serde_json::from_value(json).expect("deserializes");
2534 assert_eq!(def.steps, None);
2535 assert_eq!(def.mode, AuditMode::Async);
2536 }
2537
2538 #[test]
2539 fn audit_def_rejects_unknown_field() {
2540 let json = serde_json::json!({ "agent": "auditor", "not_a_real_field": true });
2541 let err = serde_json::from_value::<AuditDef>(json).unwrap_err();
2542 assert!(
2543 err.to_string().contains("not_a_real_field")
2544 || err.to_string().contains("unknown field"),
2545 "expected an unknown-field rejection, got: {err}"
2546 );
2547 }
2548
2549 #[test]
2550 fn audit_mode_serializes_snake_case() {
2551 assert_eq!(
2552 serde_json::to_value(AuditMode::Async).unwrap(),
2553 serde_json::json!("async")
2554 );
2555 assert_eq!(
2556 serde_json::to_value(AuditMode::Sync).unwrap(),
2557 serde_json::json!("sync")
2558 );
2559 }
2560
2561 #[test]
2562 fn blueprint_json_schema_exports_audits_and_audit_def() {
2563 let schema = schemars::schema_for!(Blueprint);
2564 let v = serde_json::to_value(&schema).expect("schema serializes");
2565 assert!(
2566 v["properties"]["audits"].is_object(),
2567 "audits must appear in the exported schema: {v}"
2568 );
2569 let dump = v.to_string();
2570 assert!(dump.contains("AuditDef"), "AuditDef definition in schema");
2571 }
2572
2573 // ──────────────────────────────────────────────────────────────
2574 // GH #32: `Blueprint.degradation_policy`, `DegradationPolicy`
2575 // ──────────────────────────────────────────────────────────────
2576
2577 #[test]
2578 fn blueprint_without_degradation_policy_deserializes_to_none() {
2579 let json = serde_json::json!({
2580 "schema_version": current_schema_version(),
2581 "id": "no-degradation-policy-ut",
2582 "flow": { "kind": "seq", "children": [] },
2583 });
2584 let bp: Blueprint = serde_json::from_value(json).expect("deserializes");
2585 assert_eq!(bp.degradation_policy, None);
2586 }
2587
2588 #[test]
2589 fn blueprint_degradation_policy_omitted_when_none() {
2590 let bp = minimal_bp(None);
2591 let json = serde_json::to_value(&bp).expect("serializes");
2592 assert!(
2593 json.as_object()
2594 .unwrap()
2595 .get("degradation_policy")
2596 .is_none(),
2597 "degradation_policy key must be absent when None: {json}"
2598 );
2599 }
2600
2601 #[test]
2602 fn blueprint_degradation_policy_warn_and_fail_roundtrip() {
2603 for (label, expected) in [
2604 ("warn", DegradationPolicy::Warn),
2605 ("fail", DegradationPolicy::Fail),
2606 ] {
2607 let mut bp = minimal_bp(None);
2608 bp.degradation_policy = Some(expected);
2609 let json = serde_json::to_string(&bp).expect("serializes");
2610 assert!(json.contains(&format!("\"degradation_policy\":\"{label}\"")));
2611 let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
2612 assert_eq!(back.degradation_policy, Some(expected));
2613 }
2614 }
2615
2616 #[test]
2617 fn degradation_policy_rejects_unknown_variant() {
2618 let json = serde_json::json!({
2619 "schema_version": current_schema_version(),
2620 "id": "degradation-policy-unknown-variant-ut",
2621 "flow": { "kind": "seq", "children": [] },
2622 "degradation_policy": "ignore",
2623 });
2624 let err = serde_json::from_value::<Blueprint>(json).unwrap_err();
2625 assert!(
2626 err.to_string().contains("unknown variant"),
2627 "expected an unknown-variant rejection, got: {err}"
2628 );
2629 }
2630
2631 // ──────────────────────────────────────────────────────────────
2632 // GH #46 Milestone 2: `Runner`, `RunnerDef`, `WorkerModel`,
2633 // `Blueprint.runners` / `default_runner`, `AgentDef.runner` /
2634 // `runner_ref`, `resolve_runner`
2635 // ──────────────────────────────────────────────────────────────
2636
2637 fn agent_with_runner(
2638 name: &str,
2639 profile: Option<AgentProfile>,
2640 runner: Option<Runner>,
2641 runner_ref: Option<String>,
2642 ) -> AgentDef {
2643 AgentDef {
2644 name: name.to_string(),
2645 kind: AgentKind::RustFn,
2646 spec: serde_json::json!({ "fn_id": name }),
2647 profile,
2648 meta: None,
2649 runner,
2650 runner_ref,
2651 verdict: None,
2652 }
2653 }
2654
2655 fn ws_runner(variant: &str, tools: Vec<&str>) -> Runner {
2656 Runner::WsClaudeCode {
2657 variant: variant.to_string(),
2658 tools: tools.into_iter().map(str::to_string).collect(),
2659 }
2660 }
2661
2662 fn agent_block_runner(tools: Vec<&str>) -> Runner {
2663 Runner::AgentBlockInProcess {
2664 tools: tools.into_iter().map(str::to_string).collect(),
2665 }
2666 }
2667
2668 // ─── round-trip byte-compat ─────────────────────────────────────
2669
2670 #[test]
2671 fn blueprint_without_runners_or_default_runner_deserializes_to_defaults() {
2672 let json = serde_json::json!({
2673 "schema_version": current_schema_version(),
2674 "id": "no-runners-ut",
2675 "flow": { "kind": "seq", "children": [] },
2676 });
2677 let bp: Blueprint = serde_json::from_value(json).expect("deserializes");
2678 assert!(bp.runners.is_empty());
2679 assert_eq!(bp.default_runner, None);
2680 }
2681
2682 #[test]
2683 fn blueprint_runners_omitted_when_empty() {
2684 let bp = minimal_bp(None);
2685 let json = serde_json::to_value(&bp).expect("serializes");
2686 assert!(
2687 json.as_object().unwrap().get("runners").is_none(),
2688 "runners key must be absent when empty: {json}"
2689 );
2690 let back: Blueprint = serde_json::from_value(json).expect("deserializes");
2691 assert!(back.runners.is_empty());
2692 assert_eq!(bp, back);
2693 }
2694
2695 #[test]
2696 fn blueprint_runners_roundtrips_when_non_empty() {
2697 let mut bp = minimal_bp(None);
2698 bp.runners = vec![RunnerDef {
2699 name: "claude-worker".to_string(),
2700 runner: ws_runner("mse-worker-coder", vec!["Read", "Grep"]),
2701 }];
2702 let json = serde_json::to_string(&bp).expect("serializes");
2703 let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
2704 assert_eq!(bp, back);
2705 assert_eq!(back.runners.len(), 1);
2706 assert_eq!(back.runners[0].name, "claude-worker");
2707 }
2708
2709 #[test]
2710 fn blueprint_default_runner_roundtrips_when_some() {
2711 let mut bp = minimal_bp(None);
2712 bp.default_runner = Some("claude-worker".to_string());
2713 let json = serde_json::to_value(&bp).expect("serializes");
2714 assert_eq!(json["default_runner"], "claude-worker");
2715 let back: Blueprint = serde_json::from_value(json).expect("deserializes");
2716 assert_eq!(back, bp);
2717 }
2718
2719 #[test]
2720 fn blueprint_default_runner_omitted_when_none() {
2721 let bp = minimal_bp(None);
2722 let json = serde_json::to_value(&bp).expect("serializes");
2723 assert!(
2724 json.as_object().unwrap().get("default_runner").is_none(),
2725 "default_runner key must be absent when None: {json}"
2726 );
2727 let back: Blueprint = serde_json::from_value(json).expect("deserializes");
2728 assert_eq!(back, bp);
2729 }
2730
2731 #[test]
2732 fn blueprint_json_schema_exports_runners_and_default_runner() {
2733 let schema = schemars::schema_for!(Blueprint);
2734 let v = serde_json::to_value(&schema).expect("schema serializes");
2735 assert!(
2736 v["properties"]["runners"].is_object(),
2737 "runners must appear in the exported schema: {v}"
2738 );
2739 assert!(
2740 v["properties"]["default_runner"].is_object(),
2741 "default_runner must appear in the exported schema: {v}"
2742 );
2743 let dump = v.to_string();
2744 assert!(dump.contains("RunnerDef"), "RunnerDef definition in schema");
2745 assert!(dump.contains("Runner"), "Runner definition in schema");
2746 }
2747
2748 #[test]
2749 fn agent_def_runner_and_runner_ref_omitted_when_none() {
2750 let agent = agent_with_runner("scout", None, None, None);
2751 let json = serde_json::to_value(&agent).expect("serializes");
2752 let obj = json.as_object().unwrap();
2753 assert!(
2754 obj.get("runner").is_none(),
2755 "runner key must be absent when None: {json}"
2756 );
2757 assert!(
2758 obj.get("runner_ref").is_none(),
2759 "runner_ref key must be absent when None: {json}"
2760 );
2761 let back: AgentDef = serde_json::from_value(json).expect("deserializes");
2762 assert_eq!(back, agent);
2763 }
2764
2765 #[test]
2766 fn agent_def_runner_inline_roundtrips_when_some() {
2767 let agent = agent_with_runner("coder", None, Some(agent_block_runner(vec!["Bash"])), None);
2768 let json = serde_json::to_string(&agent).expect("serializes");
2769 let back: AgentDef = serde_json::from_str(&json).expect("deserializes");
2770 assert_eq!(back, agent);
2771 }
2772
2773 #[test]
2774 fn agent_def_runner_ref_roundtrips_when_some() {
2775 let agent = agent_with_runner("coder", None, None, Some("claude-worker".to_string()));
2776 let json = serde_json::to_value(&agent).expect("serializes");
2777 assert_eq!(json["runner_ref"], "claude-worker");
2778 let back: AgentDef = serde_json::from_value(json).expect("deserializes");
2779 assert_eq!(back, agent);
2780 }
2781
2782 #[test]
2783 fn agent_def_json_schema_exports_runner_and_runner_ref() {
2784 let schema = schemars::schema_for!(AgentDef);
2785 let v = serde_json::to_value(&schema).expect("schema serializes");
2786 let props = v["properties"].as_object().expect("object schema");
2787 for key in ["runner", "runner_ref"] {
2788 assert!(props.contains_key(key), "missing property: {key}");
2789 }
2790 }
2791
2792 #[test]
2793 fn runner_ws_claude_code_roundtrips_through_json_and_tags_backend() {
2794 let runner = ws_runner("mse-worker-coder", vec!["Read", "Grep"]);
2795 let json = serde_json::to_value(&runner).expect("serializes");
2796 assert_eq!(json["backend"], "ws_claude_code");
2797 assert_eq!(json["variant"], "mse-worker-coder");
2798 assert_eq!(json["tools"], serde_json::json!(["Read", "Grep"]));
2799 let back: Runner = serde_json::from_value(json).expect("deserializes");
2800 assert_eq!(back, runner);
2801 }
2802
2803 #[test]
2804 fn runner_ws_operator_roundtrips_through_json_and_tags_backend() {
2805 let runner = Runner::WsOperator {
2806 variant: "mse-worker-reviewer".to_string(),
2807 tools: vec!["Read".to_string(), "Grep".to_string()],
2808 };
2809 let json = serde_json::to_value(&runner).expect("serializes");
2810 assert_eq!(json["backend"], "ws_operator");
2811 assert_eq!(json["variant"], "mse-worker-reviewer");
2812 assert_eq!(json["tools"], serde_json::json!(["Read", "Grep"]));
2813 let back: Runner = serde_json::from_value(json).expect("deserializes");
2814 assert_eq!(back, runner);
2815 }
2816
2817 #[test]
2818 fn runner_agent_block_in_process_roundtrips_through_json_and_tags_backend() {
2819 let runner = agent_block_runner(vec!["Bash"]);
2820 let json = serde_json::to_value(&runner).expect("serializes");
2821 assert_eq!(json["backend"], "agent_block_in_process");
2822 assert_eq!(json["tools"], serde_json::json!(["Bash"]));
2823 let back: Runner = serde_json::from_value(json).expect("deserializes");
2824 assert_eq!(back, runner);
2825 }
2826
2827 #[test]
2828 fn runner_tools_omitted_when_empty() {
2829 let runner = ws_runner("mse-worker-coder", vec![]);
2830 let json = serde_json::to_value(&runner).expect("serializes");
2831 assert!(
2832 json.as_object().unwrap().get("tools").is_none(),
2833 "tools key must be absent when empty: {json}"
2834 );
2835 let back: Runner = serde_json::from_value(json).expect("deserializes");
2836 assert_eq!(back, runner);
2837 }
2838
2839 #[test]
2840 fn runner_rejects_unknown_field() {
2841 let json = serde_json::json!({
2842 "backend": "ws_claude_code",
2843 "variant": "x",
2844 "not_a_real_field": true,
2845 });
2846 let err = serde_json::from_value::<Runner>(json).unwrap_err();
2847 assert!(
2848 err.to_string().contains("not_a_real_field")
2849 || err.to_string().contains("unknown field"),
2850 "expected an unknown-field rejection, got: {err}"
2851 );
2852 }
2853
2854 #[test]
2855 fn runner_def_roundtrips_through_json() {
2856 let def = RunnerDef {
2857 name: "claude-worker".to_string(),
2858 runner: ws_runner("mse-worker-coder", vec!["Read"]),
2859 };
2860 let json = serde_json::to_value(&def).expect("serializes");
2861 let back: RunnerDef = serde_json::from_value(json).expect("deserializes");
2862 assert_eq!(back, def);
2863 }
2864
2865 // ─── GH #83: SubprocessDef / Runner::Subprocess ────────────────
2866
2867 fn sample_subprocess_def(name: &str) -> SubprocessDef {
2868 SubprocessDef {
2869 name: name.to_string(),
2870 argv: vec![
2871 "sh".to_string(),
2872 "-c".to_string(),
2873 "echo '{\"result\": \"ok\"}'".to_string(),
2874 ],
2875 stdin: Some("{prompt}".to_string()),
2876 env: std::collections::BTreeMap::from([("EXTRA".to_string(), "{task_id}".to_string())]),
2877 cwd: Some("{work_dir}".to_string()),
2878 output: Some(SubprocessOutput {
2879 format: Some("json".to_string()),
2880 result_ptr: Some("/result".to_string()),
2881 ok_from: Some("exit_code".to_string()),
2882 stats: None,
2883 }),
2884 stream_mode: None,
2885 }
2886 }
2887
2888 #[test]
2889 fn subprocess_def_roundtrips_through_json() {
2890 let def = sample_subprocess_def("echo-json");
2891 let json = serde_json::to_value(&def).expect("serializes");
2892 let back: SubprocessDef = serde_json::from_value(json).expect("deserializes");
2893 assert_eq!(back, def);
2894 }
2895
2896 #[test]
2897 fn subprocess_def_optional_fields_omitted_when_default() {
2898 let def = SubprocessDef {
2899 name: "min".to_string(),
2900 argv: vec!["cat".to_string()],
2901 stdin: None,
2902 env: Default::default(),
2903 cwd: None,
2904 output: None,
2905 stream_mode: None,
2906 };
2907 let json = serde_json::to_value(&def).expect("serializes");
2908 let obj = json.as_object().unwrap();
2909 for absent in ["stdin", "env", "cwd", "output", "stream_mode"] {
2910 assert!(
2911 !obj.contains_key(absent),
2912 "{absent} key must be absent when default: {json}"
2913 );
2914 }
2915 let back: SubprocessDef = serde_json::from_value(json).expect("deserializes");
2916 assert_eq!(back, def);
2917 }
2918
2919 #[test]
2920 fn subprocess_def_rejects_unknown_field() {
2921 let json = serde_json::json!({
2922 "name": "x",
2923 "argv": ["cat"],
2924 "not_a_real_field": true,
2925 });
2926 let err = serde_json::from_value::<SubprocessDef>(json).unwrap_err();
2927 assert!(
2928 err.to_string().contains("unknown field"),
2929 "expected an unknown-field rejection, got: {err}"
2930 );
2931 }
2932
2933 #[test]
2934 fn blueprint_subprocesses_defaults_to_empty_and_stays_off_the_wire() {
2935 // Pre-#83 BP JSON (no `subprocesses` key) deserializes to an empty registry.
2936 let bp = minimal_bp(None);
2937 let json = serde_json::to_value(&bp).expect("serializes");
2938 assert!(
2939 json.as_object().unwrap().get("subprocesses").is_none(),
2940 "subprocesses key must be absent when empty: {json}"
2941 );
2942 let back: Blueprint = serde_json::from_value(json).expect("deserializes");
2943 assert!(back.subprocesses.is_empty());
2944 }
2945
2946 #[test]
2947 fn blueprint_subprocesses_roundtrips_when_declared() {
2948 let mut bp = minimal_bp(None);
2949 bp.subprocesses = vec![sample_subprocess_def("echo-json")];
2950 let json = serde_json::to_value(&bp).expect("serializes");
2951 assert_eq!(json["subprocesses"][0]["name"], "echo-json");
2952 let back: Blueprint = serde_json::from_value(json).expect("deserializes");
2953 assert_eq!(back.subprocesses, bp.subprocesses);
2954 }
2955
2956 #[test]
2957 fn runner_subprocess_roundtrips_through_json_and_tags_backend() {
2958 // 1:1 name symmetry with AgentKind::Subprocess — tag must be "subprocess".
2959 let runner = Runner::Subprocess {
2960 template: "echo-json".to_string(),
2961 overrides: SubprocessOverrides {
2962 model: Some("small".to_string()),
2963 tools: vec!["Read".to_string()],
2964 cwd: Some("/tmp/wd".to_string()),
2965 },
2966 };
2967 let json = serde_json::to_value(&runner).expect("serializes");
2968 assert_eq!(json["backend"], "subprocess");
2969 assert_eq!(json["template"], "echo-json");
2970 assert_eq!(json["overrides"]["model"], "small");
2971 let back: Runner = serde_json::from_value(json).expect("deserializes");
2972 assert_eq!(back, runner);
2973 }
2974
2975 #[test]
2976 fn runner_subprocess_overrides_omitted_when_empty() {
2977 let runner = Runner::Subprocess {
2978 template: "echo-json".to_string(),
2979 overrides: SubprocessOverrides::default(),
2980 };
2981 let json = serde_json::to_value(&runner).expect("serializes");
2982 assert!(
2983 json.as_object().unwrap().get("overrides").is_none(),
2984 "overrides key must be absent when all-default: {json}"
2985 );
2986 let back: Runner = serde_json::from_value(json).expect("deserializes");
2987 assert_eq!(back, runner);
2988 }
2989
2990 #[test]
2991 fn resolve_runner_inline_subprocess_variant_resolves() {
2992 let inline = Runner::Subprocess {
2993 template: "echo-json".to_string(),
2994 overrides: SubprocessOverrides::default(),
2995 };
2996 let agent = agent_with_runner("headless", None, Some(inline.clone()), None);
2997 let mut bp = minimal_bp(None);
2998 bp.agents = vec![agent.clone()];
2999
3000 let resolved = resolve_runner(&bp, &agent).expect("resolves");
3001 assert_eq!(resolved, Some(inline));
3002 }
3003
3004 #[test]
3005 fn resolve_runner_registry_and_default_tiers_resolve_subprocess_variant() {
3006 let registry_runner = Runner::Subprocess {
3007 template: "echo-json".to_string(),
3008 overrides: SubprocessOverrides::default(),
3009 };
3010 // Tier 2: runner_ref → registry.
3011 let agent = agent_with_runner("headless", None, None, Some("proc-entry".to_string()));
3012 let mut bp = minimal_bp(None);
3013 bp.runners = vec![RunnerDef {
3014 name: "proc-entry".to_string(),
3015 runner: registry_runner.clone(),
3016 }];
3017 bp.agents = vec![agent.clone()];
3018 let resolved = resolve_runner(&bp, &agent).expect("resolves");
3019 assert_eq!(resolved, Some(registry_runner.clone()));
3020
3021 // Tier 4: default_runner alone.
3022 let bare = agent_with_runner("headless", None, None, None);
3023 bp.agents = vec![bare.clone()];
3024 bp.default_runner = Some("proc-entry".to_string());
3025 let resolved = resolve_runner(&bp, &bare).expect("resolves");
3026 assert_eq!(resolved, Some(registry_runner));
3027 }
3028
3029 #[test]
3030 fn bind_outcome_bound_roundtrips_through_json_and_tags_outcome() {
3031 let outcome = BindOutcome::Bound {
3032 receipt: BindReceipt {
3033 agent: "coder".to_string(),
3034 request_digest: BindingDigest::sha256("req"),
3035 provider_id: "mse-provider".to_string(),
3036 provider_revision: Some("1".to_string()),
3037 resolved_model: Some("claude-sonnet-4".to_string()),
3038 effective_tools: vec!["Read".to_string(), "Write".to_string()],
3039 launch_variant: Some("mse-coder".to_string()),
3040 capability_snapshot_digest: None,
3041 },
3042 };
3043 let json = serde_json::to_value(&outcome).expect("serializes");
3044 assert_eq!(json["outcome"], "bound");
3045 assert_eq!(json["receipt"]["agent"], "coder");
3046 let back: BindOutcome = serde_json::from_value(json).expect("deserializes");
3047 assert_eq!(back, outcome);
3048 }
3049
3050 #[test]
3051 fn bind_outcome_unbound_roundtrips_through_json_and_tags_outcome() {
3052 let outcome = BindOutcome::Unbound {
3053 agent: "coder".to_string(),
3054 reason: "no capability for launch variant".to_string(),
3055 };
3056 let json = serde_json::to_value(&outcome).expect("serializes");
3057 assert_eq!(json["outcome"], "unbound");
3058 assert_eq!(json["agent"], "coder");
3059 assert_eq!(json["reason"], "no capability for launch variant");
3060 let back: BindOutcome = serde_json::from_value(json).expect("deserializes");
3061 assert_eq!(back, outcome);
3062 }
3063
3064 #[test]
3065 fn bind_outcome_rejects_unknown_field() {
3066 let json = serde_json::json!({
3067 "outcome": "unbound",
3068 "agent": "coder",
3069 "reason": "gone",
3070 "not_a_real_field": true,
3071 });
3072 let err = serde_json::from_value::<BindOutcome>(json).unwrap_err();
3073 assert!(
3074 err.to_string().contains("not_a_real_field")
3075 || err.to_string().contains("unknown field"),
3076 "expected an unknown-field rejection, got: {err}"
3077 );
3078 }
3079
3080 #[test]
3081 fn compiler_strategy_strict_binding_defaults_false_and_omitted() {
3082 let strategy = CompilerStrategy::default();
3083 assert!(!strategy.strict_binding);
3084 // Absent in JSON deserializes back to false.
3085 let back: CompilerStrategy = serde_json::from_value(serde_json::json!({
3086 "strict_refs": true,
3087 "strict_kind": true,
3088 }))
3089 .expect("deserializes without strict_binding");
3090 assert!(!back.strict_binding);
3091 }
3092
3093 #[test]
3094 fn worker_model_roundtrips_through_json() {
3095 let model = WorkerModel {
3096 runner: agent_block_runner(vec!["Bash"]),
3097 agent: agent_with_runner("coder", None, None, None),
3098 };
3099 let json = serde_json::to_value(&model).expect("serializes");
3100 let back: WorkerModel = serde_json::from_value(json).expect("deserializes");
3101 assert_eq!(back, model);
3102 }
3103
3104 // ─── resolve_runner cascade precedence ─────────────────────────
3105
3106 #[test]
3107 fn resolve_runner_inline_wins_over_everything() {
3108 let inline = agent_block_runner(vec!["Bash"]);
3109 let profile = AgentProfile {
3110 worker_binding: Some("legacy-variant".to_string()),
3111 tools: vec!["Read".to_string()],
3112 ..Default::default()
3113 };
3114 let agent = agent_with_runner(
3115 "coder",
3116 Some(profile),
3117 Some(inline.clone()),
3118 Some("registry-entry".to_string()),
3119 );
3120 let mut bp = minimal_bp(None);
3121 bp.default_runner = Some("registry-entry".to_string());
3122 bp.runners = vec![RunnerDef {
3123 name: "registry-entry".to_string(),
3124 runner: ws_runner("other-variant", vec![]),
3125 }];
3126 bp.agents = vec![agent.clone()];
3127
3128 let resolved = resolve_runner(&bp, &agent).expect("resolves");
3129 assert_eq!(resolved, Some(inline));
3130 }
3131
3132 #[test]
3133 fn resolve_runner_runner_ref_wins_over_legacy_fallback() {
3134 let profile = AgentProfile {
3135 worker_binding: Some("legacy-variant".to_string()),
3136 tools: vec!["Read".to_string()],
3137 ..Default::default()
3138 };
3139 let registry_runner = ws_runner("registry-variant", vec!["Grep"]);
3140 let agent = agent_with_runner(
3141 "coder",
3142 Some(profile),
3143 None,
3144 Some("registry-entry".to_string()),
3145 );
3146 let mut bp = minimal_bp(None);
3147 bp.runners = vec![RunnerDef {
3148 name: "registry-entry".to_string(),
3149 runner: registry_runner.clone(),
3150 }];
3151 bp.agents = vec![agent.clone()];
3152
3153 let resolved = resolve_runner(&bp, &agent).expect("resolves");
3154 assert_eq!(resolved, Some(registry_runner));
3155 }
3156
3157 #[test]
3158 fn resolve_runner_legacy_fallback_wins_over_default_runner() {
3159 let profile = AgentProfile {
3160 worker_binding: Some("legacy-variant".to_string()),
3161 tools: vec!["Read".to_string(), "Grep".to_string()],
3162 ..Default::default()
3163 };
3164 let agent = agent_with_runner("coder", Some(profile), None, None);
3165 let mut bp = minimal_bp(None);
3166 bp.default_runner = Some("registry-entry".to_string());
3167 bp.runners = vec![RunnerDef {
3168 name: "registry-entry".to_string(),
3169 runner: agent_block_runner(vec!["Bash"]),
3170 }];
3171 bp.agents = vec![agent.clone()];
3172
3173 let resolved = resolve_runner(&bp, &agent).expect("resolves");
3174 assert_eq!(
3175 resolved,
3176 Some(ws_runner("legacy-variant", vec!["Read", "Grep"]))
3177 );
3178 }
3179
3180 #[test]
3181 fn resolve_runner_default_runner_alone_when_no_agent_level_declaration() {
3182 let agent = agent_with_runner("coder", None, None, None);
3183 let mut bp = minimal_bp(None);
3184 bp.default_runner = Some("registry-entry".to_string());
3185 bp.runners = vec![RunnerDef {
3186 name: "registry-entry".to_string(),
3187 runner: agent_block_runner(vec!["Bash"]),
3188 }];
3189 bp.agents = vec![agent.clone()];
3190
3191 let resolved = resolve_runner(&bp, &agent).expect("resolves");
3192 assert_eq!(resolved, Some(agent_block_runner(vec!["Bash"])));
3193 }
3194
3195 #[test]
3196 fn resolve_runner_none_when_nothing_declared_through_any_tier() {
3197 let agent = agent_with_runner("coder", None, None, None);
3198 let bp = minimal_bp(None);
3199
3200 let resolved = resolve_runner(&bp, &agent).expect("resolves");
3201 assert_eq!(resolved, None);
3202 }
3203
3204 #[test]
3205 fn resolve_runner_unknown_runner_ref_errs() {
3206 let agent = agent_with_runner("coder", None, None, Some("no-such-entry".to_string()));
3207 let mut bp = minimal_bp(None);
3208 bp.runners = vec![RunnerDef {
3209 name: "registry-entry".to_string(),
3210 runner: agent_block_runner(vec![]),
3211 }];
3212 bp.agents = vec![agent.clone()];
3213
3214 let err = resolve_runner(&bp, &agent).expect_err("unresolved runner_ref");
3215 assert_eq!(
3216 err,
3217 RunnerResolveError::UnknownRunnerRef {
3218 agent: "coder".to_string(),
3219 ref_name: "no-such-entry".to_string(),
3220 available: vec!["registry-entry".to_string()],
3221 }
3222 );
3223 }
3224
3225 #[test]
3226 fn resolve_runner_unknown_default_runner_errs() {
3227 let agent = agent_with_runner("coder", None, None, None);
3228 let mut bp = minimal_bp(None);
3229 bp.default_runner = Some("no-such-entry".to_string());
3230 bp.runners = vec![RunnerDef {
3231 name: "registry-entry".to_string(),
3232 runner: agent_block_runner(vec![]),
3233 }];
3234 bp.agents = vec![agent.clone()];
3235
3236 let err = resolve_runner(&bp, &agent).expect_err("unresolved default_runner");
3237 assert_eq!(
3238 err,
3239 RunnerResolveError::UnknownDefaultRunner {
3240 ref_name: "no-such-entry".to_string(),
3241 available: vec!["registry-entry".to_string()],
3242 }
3243 );
3244 }
3245
3246 #[test]
3247 fn bound_agent_digest_is_stable_and_tracks_runner_changes() {
3248 let agent = agent_with_runner(
3249 "coder",
3250 None,
3251 Some(ws_runner("worker-a", vec!["Read"])),
3252 None,
3253 );
3254 let mut bp = minimal_bp(None);
3255 bp.agents = vec![agent];
3256
3257 let first = resolve_bound_agents(&bp).expect("binds");
3258 let second = resolve_bound_agents(&bp).expect("binds again");
3259 assert_eq!(first[0].binding_digest, second[0].binding_digest);
3260 assert!(first[0].binding_digest.as_str().starts_with("sha256:"));
3261 assert_eq!(first[0].binding_digest.as_str().len(), 71);
3262 assert_eq!(first[0].runner_source, RunnerResolutionSource::AgentInline);
3263
3264 bp.agents[0].runner = Some(ws_runner("worker-b", vec!["Read"]));
3265 let changed = resolve_bound_agents(&bp).expect("binds changed runner");
3266 assert_ne!(first[0].binding_digest, changed[0].binding_digest);
3267 }
3268
3269 #[test]
3270 fn bound_agent_pins_effective_context_policy_and_full_agent() {
3271 let mut agent = agent_with_runner("scout", None, None, None);
3272 agent.profile = Some(AgentProfile {
3273 system_prompt: "inspect carefully".to_string(),
3274 ..Default::default()
3275 });
3276 let mut bp = minimal_bp(None);
3277 bp.default_context_policy = Some(ContextPolicy {
3278 include: Some(vec!["task".to_string()]),
3279 ..Default::default()
3280 });
3281 bp.agents = vec![agent];
3282
3283 let bound = resolve_bound_agents(&bp).expect("binds").remove(0);
3284 assert_eq!(
3285 bound.agent.profile.unwrap().system_prompt,
3286 "inspect carefully"
3287 );
3288 assert_eq!(
3289 bound.context_policy.unwrap().include,
3290 Some(vec!["task".to_string()])
3291 );
3292 assert_eq!(bound.runner_source, RunnerResolutionSource::None);
3293 }
3294
3295 #[test]
3296 fn strict_bound_agent_resolution_rejects_legacy_worker_binding() {
3297 let profile = AgentProfile {
3298 worker_binding: Some("legacy-worker".to_string()),
3299 ..Default::default()
3300 };
3301 let mut bp = minimal_bp(None);
3302 bp.agents = vec![agent_with_runner("coder", Some(profile), None, None)];
3303
3304 let err = resolve_bound_agents_strict(&bp).expect_err("legacy must fail closed");
3305 assert!(matches!(
3306 err,
3307 BoundAgentResolveError::LegacyWorkerBindingDisabled { agent } if agent == "coder"
3308 ));
3309 }
3310
3311 #[test]
3312 fn binding_digest_is_a_validated_transparent_string() {
3313 use std::str::FromStr as _;
3314
3315 let digest = BindingDigest::sha256(b"same snapshot");
3316 let json = serde_json::to_value(&digest).expect("serializes");
3317 assert_eq!(json, serde_json::Value::String(digest.to_string()));
3318 assert_eq!(
3319 serde_json::from_value::<BindingDigest>(json).expect("deserializes"),
3320 digest
3321 );
3322 for invalid in [
3323 "deadbeef",
3324 "sha256:abc",
3325 "sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
3326 "blake3:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
3327 ] {
3328 assert!(
3329 BindingDigest::from_str(invalid).is_err(),
3330 "accepted {invalid}"
3331 );
3332 }
3333 }
3334
3335 #[test]
3336 fn capability_snapshot_digest_accepts_the_legacy_wire_name() {
3337 let digest = BindingDigest::sha256("capabilities");
3338 let capability: AgentProviderCapability = serde_json::from_value(serde_json::json!({
3339 "launch_variant": "coder",
3340 "effective_tools": ["Read"],
3341 "evidence_digest": digest,
3342 }))
3343 .expect("legacy manifest remains readable");
3344 assert_eq!(capability.capability_snapshot_digest, Some(digest.clone()));
3345
3346 let serialized = serde_json::to_value(capability).expect("serialize new wire shape");
3347 assert_eq!(serialized["capability_snapshot_digest"], digest.to_string());
3348 assert!(serialized.get("evidence_digest").is_none());
3349 }
3350
3351 // ──────────────────────────────────────────────────────────────
3352 // GH #50: `AgentDef.verdict` / `VerdictContract` / `VerdictChannel`
3353 // ──────────────────────────────────────────────────────────────
3354
3355 #[test]
3356 fn verdict_contract_roundtrips_body_channel() {
3357 let json = serde_json::json!({"channel": "body", "values": ["PASS", "BLOCKED"]});
3358 let contract: VerdictContract = serde_json::from_value(json.clone()).expect("deserializes");
3359 assert_eq!(contract.channel, VerdictChannel::Body);
3360 assert_eq!(
3361 contract.values,
3362 vec!["PASS".to_string(), "BLOCKED".to_string()]
3363 );
3364 assert_eq!(serde_json::to_value(&contract).expect("serializes"), json);
3365 }
3366
3367 #[test]
3368 fn verdict_contract_roundtrips_part_channel() {
3369 let json = serde_json::json!({"channel": "part", "values": ["ALLOW"]});
3370 let contract: VerdictContract = serde_json::from_value(json.clone()).expect("deserializes");
3371 assert_eq!(contract.channel, VerdictChannel::Part);
3372 assert_eq!(serde_json::to_value(&contract).expect("serializes"), json);
3373 }
3374
3375 #[test]
3376 fn agent_def_verdict_omitted_when_none() {
3377 let agent = agent_with_runner("gate", None, None, None);
3378 let json = serde_json::to_value(&agent).expect("serializes");
3379 assert!(
3380 json.as_object().unwrap().get("verdict").is_none(),
3381 "verdict key must be absent when None: {json}"
3382 );
3383 let back: AgentDef = serde_json::from_value(json).expect("deserializes");
3384 assert_eq!(back.verdict, None);
3385 }
3386
3387 #[test]
3388 fn agent_def_verdict_roundtrips_when_some() {
3389 let mut agent = agent_with_runner("gate", None, None, None);
3390 agent.verdict = Some(VerdictContract {
3391 channel: VerdictChannel::Body,
3392 values: vec!["PASS".to_string(), "BLOCKED".to_string()],
3393 });
3394 let json = serde_json::to_value(&agent).expect("serializes");
3395 let back: AgentDef = serde_json::from_value(json).expect("deserializes");
3396 assert_eq!(back.verdict, agent.verdict);
3397 }
3398
3399 /// Acceptance criterion #2: the `02-verdict-loop.json` sample (no
3400 /// `verdict` field on any of its agents) must still deserialize
3401 /// unchanged under the new `#[serde(deny_unknown_fields)]`-constrained
3402 /// `AgentDef` — `verdict` is `#[serde(default)]`, so its absence is not
3403 /// an error.
3404 #[test]
3405 fn existing_verdict_loop_sample_deserializes_with_verdict_omitted() {
3406 const SAMPLE: &str =
3407 include_str!("../../mlua-swarm-cli/src/mcp/resources/samples/02-verdict-loop.json");
3408 let bp: Blueprint = serde_json::from_str(SAMPLE).expect("sample deserializes");
3409 assert_eq!(bp.agents.len(), 6);
3410 assert!(
3411 bp.agents.iter().all(|a| a.verdict.is_none()),
3412 "no agent in the sample declares a verdict contract"
3413 );
3414 }
3415
3416 // ──────────────────────────────────────────────────────────────
3417 // CheckPolicy enum relocation + Blueprint.check_policy
3418 // (T1: schema round-trip / omit→None / invalid→error)
3419 // ──────────────────────────────────────────────────────────────
3420
3421 /// The wire form is snake_case and byte-identical to the pre-relocation
3422 /// enum (`"silent"` / `"warn"` / `"strict"`), round-tripping in both
3423 /// directions — the relocation must not change the serde surface.
3424 #[test]
3425 fn check_policy_wire_form_round_trips() {
3426 for (variant, wire) in [
3427 (CheckPolicy::Silent, "silent"),
3428 (CheckPolicy::Warn, "warn"),
3429 (CheckPolicy::Strict, "strict"),
3430 ] {
3431 let json = serde_json::to_value(variant).expect("serializes");
3432 assert_eq!(json, serde_json::json!(wire), "wire form for {variant:?}");
3433 let back: CheckPolicy = serde_json::from_value(json).expect("deserializes");
3434 assert_eq!(back, variant, "round-trip for {variant:?}");
3435 }
3436 }
3437
3438 /// The default is `Warn` (preserves the pre-CheckPolicy fail-open
3439 /// behaviour of every submit-time projection sink).
3440 #[test]
3441 fn check_policy_default_is_warn() {
3442 assert_eq!(CheckPolicy::default(), CheckPolicy::Warn);
3443 }
3444
3445 /// A Blueprint that declares `check_policy: "strict"` parses to
3446 /// `Some(Strict)` and re-serializes with the same snake_case literal.
3447 #[test]
3448 fn blueprint_check_policy_strict_round_trips() {
3449 let json = serde_json::json!({
3450 "schema_version": current_schema_version(),
3451 "id": "check-policy-strict-ut",
3452 "flow": { "kind": "seq", "children": [] },
3453 "check_policy": "strict",
3454 });
3455 let bp: Blueprint = serde_json::from_value(json).expect("deserializes");
3456 assert_eq!(bp.check_policy, Some(CheckPolicy::Strict));
3457 let re = serde_json::to_string(&bp).expect("serializes");
3458 assert!(
3459 re.contains("\"check_policy\":\"strict\""),
3460 "re-serialized BP must preserve the snake_case wire literal: {re}"
3461 );
3462 }
3463
3464 /// An omitted `check_policy` parses to `None` and is skipped on
3465 /// serialize (backward-compat with every pre-cascade Blueprint).
3466 #[test]
3467 fn blueprint_check_policy_omitted_is_none() {
3468 let json = serde_json::json!({
3469 "schema_version": current_schema_version(),
3470 "id": "check-policy-omitted-ut",
3471 "flow": { "kind": "seq", "children": [] },
3472 });
3473 let bp: Blueprint = serde_json::from_value(json).expect("deserializes");
3474 assert_eq!(bp.check_policy, None);
3475
3476 let out = serde_json::to_value(&bp).expect("serializes");
3477 assert!(
3478 out.as_object().unwrap().get("check_policy").is_none(),
3479 "check_policy key must be absent when None: {out}"
3480 );
3481 }
3482
3483 /// An invalid `check_policy` value is a hard parse error (not silently
3484 /// dropped) — the enum is closed to the three snake_case variants. This
3485 /// also confirms `deny_unknown_fields` is not the gate here: the field
3486 /// IS known, only its value is invalid.
3487 #[test]
3488 fn blueprint_check_policy_invalid_value_errors() {
3489 let json = serde_json::json!({
3490 "schema_version": current_schema_version(),
3491 "id": "check-policy-invalid-ut",
3492 "flow": { "kind": "seq", "children": [] },
3493 "check_policy": "loud",
3494 });
3495 let err = serde_json::from_value::<Blueprint>(json)
3496 .expect_err("an unknown check_policy value must be rejected");
3497 let msg = err.to_string();
3498 assert!(
3499 msg.contains("check_policy") || msg.contains("loud") || msg.contains("variant"),
3500 "error should point at the bad check_policy value: {msg}"
3501 );
3502 }
3503
3504 #[test]
3505 fn agent_provider_manifest_round_trips_and_rejects_unknown_fields() {
3506 let json = serde_json::json!({
3507 "provider_id": "main-ai-self-report",
3508 "provider_revision": "1",
3509 "capabilities": [{
3510 "launch_variant": "mse-coder",
3511 "resolved_model": "claude-sonnet-4",
3512 "effective_tools": ["Read", "Edit"]
3513 }]
3514 });
3515 let manifest: AgentProviderManifest =
3516 serde_json::from_value(json.clone()).expect("manifest deserializes");
3517 assert_eq!(serde_json::to_value(manifest).unwrap(), json);
3518
3519 let invalid = serde_json::json!({
3520 "provider_id": "main-ai-self-report",
3521 "capabilities": [],
3522 "platform_secret": true
3523 });
3524 assert!(serde_json::from_value::<AgentProviderManifest>(invalid).is_err());
3525 }
3526}