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