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