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".into() },
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//! }],
56//! operators: vec![],
57//! metas: vec![],
58//! hints: Default::default(),
59//! strategy: Default::default(),
60//! metadata: Default::default(),
61//! spawner_hints: Default::default(),
62//! default_agent_kind: AgentKind::Operator,
63//! default_operator_kind: None,
64//! default_init_ctx: None,
65//! default_agent_ctx: None,
66//! default_context_policy: None,
67//! projection_placement: None,
68//! audits: vec![],
69//! degradation_policy: None,
70//! };
71//!
72//! assert_eq!(bp.id.as_str(), "hello");
73//! assert_eq!(bp.agents.len(), 1);
74//! assert_eq!(bp.strategy.strict_refs, true);
75//! ```
76//!
77//! Round-trip a [`Blueprint`] through JSON (= confirms `serde` derives and the
78//! `deny_unknown_fields` contract):
79//!
80//! ```
81//! use mlua_swarm_schema::{AgentKind, Blueprint, BlueprintMetadata};
82//! use mlua_flow_ir::{Expr, Node};
83//! use serde_json::json;
84//!
85//! let bp = Blueprint {
86//! schema_version: mlua_swarm_schema::current_schema_version(),
87//! id: "roundtrip".into(),
88//! flow: Node::Seq { children: vec![] },
89//! agents: vec![],
90//! operators: vec![],
91//! metas: vec![],
92//! hints: Default::default(),
93//! strategy: Default::default(),
94//! metadata: BlueprintMetadata {
95//! description: Some("roundtrip smoke".into()),
96//! default_run_ttl_secs: Some(1800),
97//! ..Default::default()
98//! },
99//! spawner_hints: Default::default(),
100//! default_agent_kind: AgentKind::Operator,
101//! default_operator_kind: None,
102//! default_init_ctx: None,
103//! default_agent_ctx: None,
104//! default_context_policy: None,
105//! projection_placement: None,
106//! audits: vec![],
107//! degradation_policy: None,
108//! };
109//!
110//! let json = serde_json::to_string(&bp).unwrap();
111//! let back: Blueprint = serde_json::from_str(&json).unwrap();
112//! assert_eq!(bp, back);
113//! assert_eq!(back.metadata.default_run_ttl_secs, Some(1800));
114//! ```
115
116#![warn(missing_docs)]
117
118use mlua_flow_ir::Node as FlowNode;
119use schemars::JsonSchema;
120use serde::{Deserialize, Serialize};
121use serde_json::Value;
122use std::collections::HashMap;
123
124// ──────────────────────────────────────────────────────────────────────────
125// Versioning
126// ──────────────────────────────────────────────────────────────────────────
127
128/// Current Blueprint schema version. Tied to this crate's semver.
129pub const CURRENT_SCHEMA_VERSION: &str = "0.1.0";
130
131fn default_schema_version() -> semver::Version {
132 current_schema_version()
133}
134
135/// Blueprint construction helper: returns the semver of the current schema version.
136/// Callers can write `schema_version: current_schema_version(),`.
137pub fn current_schema_version() -> semver::Version {
138 semver::Version::parse(CURRENT_SCHEMA_VERSION)
139 .expect("CURRENT_SCHEMA_VERSION must be valid semver")
140}
141
142// ──────────────────────────────────────────────────────────────────────────
143// BlueprintId (human-facing ID newtype)
144// ──────────────────────────────────────────────────────────────────────────
145
146/// Identifier for a Blueprint series — the domain name (`coding`,
147/// `design`, `testing`, etc.). Default: [`BlueprintId::main`].
148///
149/// One representation across the workspace (issue #14): this type is
150/// shared by the schema's [`Blueprint::id`] and the engine's store-layer
151/// keys (`mlua-swarm` re-exports it at the old
152/// `blueprint::store::types::BlueprintId` path). The value is
153/// user-supplied — there is no prefix convention to validate, unlike the
154/// engine's minted `T-` / `R-` / `ST-` ids — so construction is
155/// infallible; the inner string is private so call sites go through
156/// [`BlueprintId::new`] and the accessors. `#[serde(transparent)]` keeps
157/// both the JSON wire shape and the generated JSON Schema a plain string.
158#[derive(
159 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
160)]
161#[serde(transparent)]
162pub struct BlueprintId(String);
163
164impl BlueprintId {
165 /// The default series name used when a caller doesn't pick one.
166 pub const MAIN: &'static str = "main";
167
168 /// Shorthand for `BlueprintId::new(BlueprintId::MAIN)`.
169 pub fn main() -> Self {
170 Self(Self::MAIN.to_string())
171 }
172
173 /// Wrap any string-like value as a `BlueprintId` (user-supplied key;
174 /// nothing to validate).
175 pub fn new(s: impl Into<String>) -> Self {
176 Self(s.into())
177 }
178
179 /// Borrow the inner series name.
180 pub fn as_str(&self) -> &str {
181 &self.0
182 }
183
184 /// Consume the id and return the inner series name.
185 pub fn into_string(self) -> String {
186 self.0
187 }
188}
189
190impl std::fmt::Display for BlueprintId {
191 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192 f.write_str(&self.0)
193 }
194}
195
196impl From<String> for BlueprintId {
197 fn from(s: String) -> Self {
198 Self(s)
199 }
200}
201
202impl From<&str> for BlueprintId {
203 fn from(s: &str) -> Self {
204 Self(s.to_string())
205 }
206}
207
208#[cfg(test)]
209mod blueprint_id_tests {
210 use super::*;
211
212 /// issue #14 convergence guard: `Blueprint.id` becoming a newtype must
213 /// not change the generated JSON Schema — the property stays an inline
214 /// plain string (no `$ref`), byte-compatible with the `String` era.
215 #[test]
216 fn blueprint_id_field_schema_stays_a_plain_inline_string() {
217 let schema = schemars::schema_for!(Blueprint);
218 let v = serde_json::to_value(&schema).expect("schema serializes");
219 let id = &v["properties"]["id"];
220 assert_eq!(id["type"], "string", "id must stay a plain string: {id}");
221 assert!(id.get("$ref").is_none(), "id must not become a $ref: {id}");
222 }
223
224 /// The JSON wire shape of the newtype is the bare string.
225 #[test]
226 fn blueprint_id_serde_is_transparent() {
227 let id = BlueprintId::new("coding");
228 assert_eq!(
229 serde_json::to_value(&id).unwrap(),
230 serde_json::json!("coding")
231 );
232 let back: BlueprintId = serde_json::from_value(serde_json::json!("coding")).unwrap();
233 assert_eq!(back, id);
234 }
235}
236
237// ──────────────────────────────────────────────────────────────────────────
238// Blueprint (top-level package)
239// ──────────────────────────────────────────────────────────────────────────
240
241/// Unified package of flow.ir + Swarm extension layers. The entry-point type of Swarm.
242#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
243#[serde(deny_unknown_fields)]
244pub struct Blueprint {
245 /// Schema version (= tied to this crate's semver). Default = `CURRENT_SCHEMA_VERSION`.
246 /// Serialized as a semver string (e.g. `"0.1.0"`).
247 #[serde(default = "default_schema_version")]
248 #[schemars(with = "String")]
249 pub schema_version: semver::Version,
250 /// Blueprint identifier (= unique key within the caller's namespace).
251 #[schemars(with = "String")]
252 pub id: BlueprintId,
253 /// Embeds the flow.ir Node verbatim (= keeps flow.ir side unpolluted).
254 /// Opaque in the JSON Schema (the Node shape is owned by the `mlua-flow-ir`
255 /// crate, a separate repo; see its docs for the Node / Expr grammar).
256 #[schemars(with = "Value")]
257 pub flow: FlowNode,
258 /// Swarm extension layer: agent → backend mapping.
259 #[serde(default)]
260 pub agents: Vec<AgentDef>,
261 /// Swarm extension layer: **design-time definition** of Operator roles (first-class).
262 ///
263 /// `AgentDef.spec.operator_ref` references an `OperatorDef.name` (logical role name) in
264 /// this vec. Embedding runtime-generated IDs such as sid into the BP is forbidden
265 /// (= collapses the design-time vs runtime boundary). Runtime backend bindings are
266 /// established via the attach / register path; the BP side holds only logical names.
267 ///
268 /// Every `kind = Operator` agent must have its `spec.operator_ref` present in this
269 /// list — the compiler validates it at `compile()` time. May be `[]` only when the
270 /// Blueprint declares no Operator agents.
271 #[serde(default)]
272 pub operators: Vec<OperatorDef>,
273 /// GH #21 Phase 2 — named, BP-scoped pool of [`MetaDef`] entries. Two
274 /// independent consumers resolve names against this pool: a
275 /// `$step_meta.ref` envelope embedded in a Step's evaluated `in`
276 /// value (the Step tier — resolved by `EngineDispatcher` in the
277 /// `mlua-swarm` core crate at dispatch time), and
278 /// [`AgentMeta::meta_ref`] (the Agent tier — resolved at launch
279 /// time). The pool lets multiple Steps and/or Agents share one
280 /// declarative context object by name instead of repeating it
281 /// inline. `[]` = no named `MetaDef`s declared (pre-#21-Phase-2
282 /// Blueprints unaffected).
283 #[serde(default, skip_serializing_if = "Vec::is_empty")]
284 pub metas: Vec<MetaDef>,
285 /// Swarm extension layer: per-agent hints (interpreted by the Compiler).
286 #[serde(default)]
287 pub hints: CompilerHints,
288 /// Swarm extension layer: Compiler behavior strategy (strict / lenient).
289 #[serde(default)]
290 pub strategy: CompilerStrategy,
291 /// Blueprint metadata (description / origin / tags / ttl / version label / alias).
292 #[serde(default)]
293 pub metadata: BlueprintMetadata,
294 /// Swarm extension layer: hint keys of the layers to wrap around the SpawnerStack.
295 /// Resolved by the LayerRegistry at engine bind time (= unregistered keys are silently
296 /// skipped). Flow / Blueprint do not hold middleware implementations (e.g. MainAIMiddleware)
297 /// directly; they only declare required capabilities as string keys (= implementations
298 /// live in the engine-side LayerRegistry).
299 #[serde(default)]
300 pub spawner_hints: SpawnerHints,
301 /// BP-wide default `AgentKind` (= fallback when `AgentDef.kind` is omitted).
302 /// Four-layer cascade: (1) Schema impl Default = Operator, (2) CLI
303 /// `--default-agent-kind`, (3) this field (BP JSON literal), (4) `AgentDef.kind`
304 /// (per-agent literal). (5) `CompilerHints.kind_override` allows runtime override.
305 /// All default resolution flows through this path.
306 #[serde(default = "default_global_agent_kind")]
307 pub default_agent_kind: AgentKind,
308 /// BP-wide default `OperatorKind` (= the "BP Global" tier of the 4-tier
309 /// `OperatorKind` cascade). `None` when the Blueprint author does not
310 /// declare a default; the caller-side resolver then falls through to
311 /// the hardcoded `OperatorKind::default()` (Automate).
312 ///
313 /// # 4-tier cascade (highest to lowest priority)
314 ///
315 /// 1. Runtime Agent-level (per-agent override supplied at task-launch time)
316 /// 2. Runtime Global (the launch-time `operator_kind` request)
317 /// 3. BP Agent-level (`OperatorDef.kind`, resolved via `AgentDef.spec.operator_ref`)
318 /// 4. BP Global (this field)
319 /// 5. Default Fallback (`OperatorKind::default()` = Automate)
320 ///
321 /// The collapse itself is implemented once on the engine side and consumed
322 /// per-agent when resolving operator info.
323 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub default_operator_kind: Option<OperatorKind>,
325 /// Blueprint-level default initial `ctx` for flow-ir eval.
326 /// `TaskLaunchService::launch` shallow-merges this with the
327 /// Task-level `init_ctx` (Task wins on key collision when both
328 /// are `Object`; if Task's `init_ctx` is not an `Object`, it
329 /// full-replaces the default). `None` — no default is merged;
330 /// backward-compat with pre-#19 Blueprints.
331 #[serde(default, skip_serializing_if = "Option::is_none")]
332 #[schemars(with = "Option<Value>")]
333 pub default_init_ctx: Option<Value>,
334 /// GH #21 Phase 1 — "BP Global" tier of the agent-context supply axis:
335 /// a declarative object merged into `ctx.meta.runtime` (and, for
336 /// unnamed keys, `AgentContextView.extra`) targeting every agent's
337 /// runtime materialization. Contrast with [`Self::default_init_ctx`]:
338 /// that field seeds the flow-ir eval `ctx` once at flow start, while
339 /// this one is consumed per-spawn by
340 /// `AgentContextMiddleware`/`AgentContextView` (Contract C, GH #20) —
341 /// a pure flow-ir eval seed vs. an Agent/LLM-boundary runtime default.
342 /// `None` = no BP-global default (pre-#21 Blueprints unaffected).
343 #[serde(default, skip_serializing_if = "Option::is_none")]
344 #[schemars(with = "Option<Value>")]
345 pub default_agent_ctx: Option<Value>,
346 /// GH #21 Phase 1 — "BP Global" tier of the [`ContextPolicy`] cascade:
347 /// the default filter applied to the materialized `AgentContextView`
348 /// when the targeted agent declares no `AgentMeta.context_policy` of
349 /// its own. `None` = pass-all (the pre-#21 behavior).
350 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub default_context_policy: Option<ContextPolicy>,
352 /// GH #27 (follow-up to #23) — Blueprint-declared override of the
353 /// `mlua-swarm` core crate's projection placement resolver (root
354 /// preference + target directory template for materialized step
355 /// OUTPUT files). `None` = the resolver's byte-compat default (root =
356 /// `work_dir` falling back to `project_root`; dir_template =
357 /// `"workspace/tasks/{task_id}/ctx"`) — every pre-#27 Blueprint is
358 /// unaffected. See [`ProjectionPlacementSpec`]'s doc for field detail.
359 #[serde(default, skip_serializing_if = "Option::is_none")]
360 pub projection_placement: Option<ProjectionPlacementSpec>,
361 /// GH #34 — Blueprint-declared after-run audit hooks: the engine
362 /// auto-kicks each listed [`AuditDef`]'s agent once a matching Step
363 /// settles, and persists its findings as an `OutputEvent::Artifact`
364 /// named `"audit:<step_ref>"` on the AUDITED step's own output tail
365 /// (see `mlua-swarm` core's `AfterRunAuditMiddleware` for the
366 /// dispatch mechanics). `audits[].agent` is validated at
367 /// `Compiler::compile` time against `Blueprint.agents[].name`
368 /// (mirrors the `operator_ref` validation). `[]` (the default) = no
369 /// audit hooks declared — every pre-#34 Blueprint is unaffected,
370 /// byte-for-byte.
371 ///
372 /// **Binding invariant**: an audit's verdict, findings, or even its
373 /// own failure NEVER change the audited step's outcome or gate the
374 /// flow — audits are purely observational.
375 #[serde(default, skip_serializing_if = "Vec::is_empty")]
376 pub audits: Vec<AuditDef>,
377 /// GH #32 — Blueprint-declared policy for worker-reported degradations
378 /// (see `mlua-swarm` core's `RunRecord.degradations` /
379 /// `DegradationEntry`). `None` (the default) is schema-only for now:
380 /// [`DegradationPolicy::Warn`] and [`DegradationPolicy::Fail`] carry the
381 /// same observational behavior at this point — degradations are always
382 /// persisted, never gate the flow. Engine enforcement of `Fail`
383 /// (terminating a Run on any reported degradation) is a follow-up; this
384 /// field only declares author intent today. Every pre-#32 Blueprint is
385 /// unaffected.
386 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub degradation_policy: Option<DegradationPolicy>,
388}
389
390/// GH #32 — Blueprint-declared policy for worker-reported degradations. See
391/// [`Blueprint::degradation_policy`] for the (currently schema-only)
392/// enforcement contract.
393#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
394#[serde(rename_all = "snake_case")]
395pub enum DegradationPolicy {
396 /// Observational only (today's only enforced behavior, regardless of
397 /// which variant is declared): degradations are persisted to
398 /// `RunRecord.degradations` and surfaced via `mse_doctor` /
399 /// `GET /v1/runs/:id`, but never change the Run's outcome.
400 Warn,
401 /// Declares intent to terminate the Run on any reported degradation.
402 /// Not yet enforced by the engine — schema-only until the follow-up
403 /// lands.
404 Fail,
405}
406
407/// GH #34 — one Blueprint-declared after-run audit hook. See
408/// [`Blueprint::audits`] for the persistence / invariant contract.
409#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
410#[serde(deny_unknown_fields)]
411pub struct AuditDef {
412 /// Name of the audit agent (must match a [`Blueprint::agents`] entry's
413 /// `name`) the engine dispatches after a matched step settles.
414 /// Validated at `Compiler::compile` time (mirrors
415 /// `AgentDef.spec.operator_ref`'s `operator_ref` validation) — an
416 /// unresolved name rejects compilation.
417 pub agent: String,
418 /// Step names this audit applies to, matched against the step's agent
419 /// ref name. `None`, or a list containing the literal `"*"`, means
420 /// "every step". `Some(vec![])` (an explicit empty list) audits no
421 /// step. `None` is the default.
422 #[serde(default, skip_serializing_if = "Option::is_none")]
423 pub steps: Option<Vec<String>>,
424 /// Dispatch timing for this audit's agent (see [`AuditMode`]).
425 /// Defaults to [`AuditMode::Async`].
426 #[serde(default)]
427 pub mode: AuditMode,
428}
429
430/// GH #34 — dispatch timing for an [`AuditDef`]'s audit agent. Neither
431/// variant ever changes the audited step's outcome (see
432/// [`Blueprint::audits`]'s binding invariant).
433#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
434#[serde(rename_all = "snake_case")]
435pub enum AuditMode {
436 /// Fire-and-forget: the audit runs in the background after the
437 /// audited step settles; the audited step's own spawn signal returns
438 /// immediately, without waiting for the audit to finish.
439 #[default]
440 Async,
441 /// Awaited before the audited step's spawn signal is returned to the
442 /// engine — still never alters that signal or the step's recorded
443 /// outcome.
444 Sync,
445}
446
447/// Receptacle for a Blueprint-driven filter over the materialized
448/// `AgentContextView` (GH #20/#21). Declared BP-side via
449/// [`Blueprint::default_context_policy`] (BP-global) or
450/// `AgentMeta::context_policy` (per-agent, outranks the BP-global tier) —
451/// resolved and applied by `AgentContextMiddleware` in the `mlua-swarm`
452/// core crate (this crate stays execution-free; see the crate doc).
453/// Default (`include: None, exclude: vec![]`) is pass-all — [`Self::allows`]
454/// returns `true` for every field name.
455#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
456#[serde(deny_unknown_fields)]
457pub struct ContextPolicy {
458 /// Field names to keep. `None` means "keep everything" (pass-all).
459 /// Matched against the `AgentContextView` named-field strings
460 /// (`"project_root"` / `"work_dir"` / `"task_metadata"` / `"run_id"` /
461 /// `"project_name_alias"`) and `extra` keys by their own key string.
462 /// Identity fields (`task_id` / `agent` / `attempt`) are never
463 /// filtered regardless of `include`.
464 #[serde(default)]
465 pub include: Option<Vec<String>>,
466 /// Field names to drop, applied AFTER `include` (exclude wins when a
467 /// name appears in both). Same name-matching rule as `include`.
468 #[serde(default)]
469 pub exclude: Vec<String>,
470 /// Which preceding steps' OUTPUT pointers a worker's fetch payload may
471 /// see (`WorkerPayload.context.steps`, ST5 of the `projection-adapter`
472 /// design). `None` = pass-all (every submitted step, the pre-ST5
473 /// `ctx_step_dir` behavior); `Some(list)` = only the named steps;
474 /// `Some(vec![])` = none. Evaluated by [`Self::allows_step`], a sibling
475 /// of [`Self::allows`] with the same include/exclude precedence rule
476 /// but a separate namespace (step names vs. `AgentContextView` field /
477 /// `extra` key names never collide).
478 #[serde(default)]
479 pub steps: Option<Vec<String>>,
480 /// Step names to drop, applied AFTER `steps` (exclude wins when a name
481 /// appears in both). Same name-matching rule as `steps`.
482 #[serde(default)]
483 pub steps_exclude: Vec<String>,
484}
485
486impl ContextPolicy {
487 /// Whether `name` survives this policy: `false` if `exclude` lists it;
488 /// otherwise `true` when `include` is `None` (pass-all) or lists
489 /// `name`. Shared by both the schema crate (tests) and the `mlua-swarm`
490 /// core crate's `AgentContextView::apply_policy`, so the include/exclude
491 /// evaluation rule has exactly one implementation.
492 pub fn allows(&self, name: &str) -> bool {
493 if self.exclude.iter().any(|excluded| excluded == name) {
494 return false;
495 }
496 match &self.include {
497 Some(list) => list.iter().any(|included| included == name),
498 None => true,
499 }
500 }
501
502 /// Whether the preceding step named `name` survives this policy for the
503 /// worker fetch payload's `context.steps` pointer list: `false` if
504 /// `steps_exclude` lists it; otherwise `true` when `steps` is `None`
505 /// (pass-all) or lists `name`. Same precedence rule as [`Self::allows`],
506 /// evaluated against the separate `steps` / `steps_exclude` fields.
507 pub fn allows_step(&self, name: &str) -> bool {
508 if self.steps_exclude.iter().any(|excluded| excluded == name) {
509 return false;
510 }
511 match &self.steps {
512 Some(list) => list.iter().any(|included| included == name),
513 None => true,
514 }
515 }
516}
517
518/// Global default `AgentKind` at the Schema impl Default layer. Bottom of the 4-layer cascade.
519pub fn default_global_agent_kind() -> AgentKind {
520 AgentKind::Operator
521}
522
523/// Set of **capability hint keys** for the SpawnerLayer required by a Blueprint.
524///
525/// # Design rationale (= for the person who will reconstruct this later)
526///
527/// A Blueprint is a pure layer of flow.ir + agent name binding and holds no middleware
528/// **implementation**. Nevertheless there are cases where the caller must be told the BP
529/// needs certain **capabilities** — e.g. "MainAI hook required", "Operator delegate path
530/// required", operator role mode switching, presence/absence of senior escalation, and
531/// so on.
532///
533/// `spawner_hints.layers` is the place where those capabilities are declared as **string
534/// keys**. The engine-side `LayerRegistry` (= consumer crate) resolves key → factory and
535/// wraps the compiled routes with a `SpawnerStack`. The Blueprint does not import the
536/// concrete `MainAIMiddleware` type; it exposes intent through strings such as `"main_ai"`
537/// (= separates the pure Flow layer from implementation details).
538///
539/// # Canonical hint keys
540///
541/// - `"main_ai"` → `MainAIMiddleware` (= fires SpawnHook before/after when kind is MainAi/Composite)
542/// - `"senior_escalation"` → `SeniorEscalationMiddleware` (= fires SeniorBridge.ask on worker ok=false)
543/// - `"operator_delegate"` → `OperatorDelegateMiddleware` (= delegates the entire spawn to an external Operator.execute)
544///
545/// # Behavior of unregistered keys
546///
547/// If the engine-side LayerRegistry has no matching factory, the key is **silently skipped**
548/// (= lenient default). This preserves Blueprint portability (= an unsupported capability in
549/// another deployment falls back gracefully).
550#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)]
551#[serde(deny_unknown_fields)]
552pub struct SpawnerHints {
553 /// Ordered list of layer hint keys to wrap around the SpawnerStack.
554 #[serde(default)]
555 pub layers: Vec<String>,
556}
557
558// ──────────────────────────────────────────────────────────────────────────
559// AgentDef / AgentKind / AgentProfile / AgentMeta
560// ──────────────────────────────────────────────────────────────────────────
561
562/// Maps an agent name to a Worker IMPL kind and its configuration. Referenced from flow.ir
563/// `Step.ref` by name.
564///
565/// # Design
566///
567/// `AgentDef.kind` directly expresses the **Worker IMPL axis** (= not the old Spawner axis).
568/// Dispatching to a host Spawner adapter (`InProcSpawner` / `ProcessSpawner` /
569/// `OperatorSpawner`) is done by an internal Resolver on the compiler side. The design goal
570/// is "do not make the caller aware of which Spawner hosts the Worker IMPL"; the caller
571/// (Blueprint author) sees only the WorkerIMPL viewpoint.
572///
573/// A Spawner-axis hint (= "which adapter would you prefer running this Worker on", as a
574/// priority list) will be added via a future `spawner_hint: Vec<Spawner>` field as a carry.
575/// The current internal Resolver is a fixed 1:1 mapping, so the field is unnecessary today.
576#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
577#[serde(deny_unknown_fields)]
578pub struct AgentDef {
579 /// Agent name (= referenced from flow.ir `Step.ref`).
580 pub name: String,
581 /// Worker IMPL kind (= see [`AgentKind`]).
582 pub kind: AgentKind,
583 /// Free-form schema per kind. Interpreted by the SpawnerFactory.
584 #[serde(default)]
585 pub spec: Value,
586 /// Agent persona information (system_prompt / model / tools, etc.). Orthogonal to the
587 /// backend kind and is a first-class field. Expected to be populated by
588 /// `agent_md_loader` from the frontmatter + body of an `agent.md`. `None` = an agent
589 /// without a profile (= backend built solely from `spec`).
590 #[serde(default)]
591 pub profile: Option<AgentProfile>,
592 /// Agent-level metadata (description / version / tags).
593 #[serde(default)]
594 pub meta: Option<AgentMeta>,
595}
596
597/// Agent persona information. Orthogonal to the backend kind (Shell / InProc / Operator).
598///
599/// Populated by `agent_md_loader::load_dir` from the frontmatter and Markdown body of
600/// `agents/*.md` in agent-profiles. The backend (e.g. AgentBlockOperator) receives this
601/// struct at construction / dispatch time and consumes `system_prompt` as the LLM API
602/// system message and `model` / `tools` as configuration.
603///
604/// C-C-specific fields (`permissionMode` / `memory` / `abtest`, etc.) are dumped into
605/// `extras: Value`, and consumers that need them read them out. This is the escape hatch
606/// that keeps the schema future-proof rather than making it strict.
607#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
608#[serde(deny_unknown_fields)]
609pub struct AgentProfile {
610 /// Markdown body (= system prompt content).
611 #[serde(default)]
612 pub system_prompt: String,
613 /// LLM model identifier (e.g. `"sonnet"` / `"haiku"` / `"opus"`).
614 #[serde(default)]
615 pub model: Option<String>,
616 /// Reasoning effort (e.g. `"low"` / `"medium"` / `"high"`).
617 #[serde(default)]
618 pub effort: Option<String>,
619 /// List of available tool names (normalized from the CSV form in frontmatter).
620 #[serde(default)]
621 pub tools: Vec<String>,
622 /// Frontmatter `description`. A short one-line description.
623 #[serde(default)]
624 pub description: Option<String>,
625 /// C-C-specific / future-proof fields (permissionMode / memory / abtest / ...).
626 /// Shape is the leftover keys of the agent.md frontmatter dumped as a JSON object.
627 #[serde(default)]
628 pub extras: Value,
629 /// Content hash (blake3 32-byte hex) of the agent body (= `system_prompt`).
630 ///
631 /// # Purpose
632 ///
633 /// When the Enhance loop receives a Patch that replaces
634 /// `/agents/N/profile/system_prompt`, the post-hook in `patch_applier.lua`
635 /// recomputes this field (= new blake3 of the body) and updates it automatically.
636 /// This is the field that structurally prevents a Blueprint carrying a stale hash
637 /// from being committed.
638 ///
639 /// - `None` = hash not computed (= manually built agent, or a Blueprint predating this field)
640 /// - `Some(hex)` = latest hash at agent-profiles seed time or after PatchApplier
641 ///
642 /// Planned to be used as the cache-index key in `AgentStore`.
643 #[serde(default)]
644 pub version_hash: Option<String>,
645 /// Claude Code SubAgent definition name this agent binds to at spawn
646 /// time (e.g. "mse-worker-coder"). Why: the Blueprint is the single
647 /// source of truth for the declaration↔executor binding — an external
648 /// registry would duplicate what `tools` already declares and drift.
649 /// `None` is valid for agents whose operator backend never dispatches
650 /// a SubAgent (direct-LLM operators); WS thin-path operators require
651 /// it at compile time (see `Operator::requires_worker_binding`).
652 #[serde(default, skip_serializing_if = "Option::is_none")]
653 pub worker_binding: Option<String>,
654}
655
656/// SoT of the **Worker IMPL axis**. A closed enum managed inside Swarm and extended by
657/// variant addition through **explicit maintenance**. String lookup / escape hatches are
658/// deliberately not adopted.
659///
660/// This enum **expresses Worker IMPL directly**; dispatching to a host Spawner adapter is
661/// resolved by an internal Resolver on the compiler side (= callers see only the Worker
662/// IMPL viewpoint).
663///
664/// # Internal Resolver mapping (= currently a fixed 1:1, carry: priority list form)
665///
666/// | AgentKind | Host Spawner adapter |
667/// |---|---|
668/// | `Lua` | `InProcSpawner` (mlua VM eval) |
669/// | `RustFn` | `InProcSpawner` (Rust closure) |
670/// | `AgentBlock` | `InProcSpawner` (agent-block-core SDK in-process) |
671/// | `Subprocess` | `ProcessSpawner` (child process launch) |
672/// | `Operator` | `OperatorSpawner` (interactive role / Human-MainAI delegation) |
673#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, JsonSchema)]
674#[serde(rename_all = "snake_case")]
675pub enum AgentKind {
676 /// Lua script eval through the mlua VM (= factory-side registry looked up by `spec.fn_id`).
677 Lua,
678 /// Rust closure (= factory-side registry looked up by `spec.fn_id`).
679 RustFn,
680 /// Headless LLM agent via the agent-block-core SDK (in-process).
681 AgentBlock,
682 /// Child-process launch (= `spec.program` + `args`, via the ProcessSpawner path).
683 Subprocess,
684 /// Interactive Operator role (= MainAI / Human delegation, `spec.operator_ref`).
685 Operator,
686}
687
688// ──────────────────────────────────────────────────────────────────────────
689// OperatorDef / OperatorKind
690// ──────────────────────────────────────────────────────────────────────────
691
692/// Kind axis of an Operator role (= "in which mode does this Operator run").
693/// Corresponds 1:1 with the engine's runtime `OperatorKind`. Kept as a schema
694/// duplicate so that BPs can be authored while depending only on this crate.
695#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
696#[serde(rename_all = "snake_case")]
697pub enum OperatorKind {
698 /// MainAI (= interactive AI Operator via WS client or SDK).
699 MainAi,
700 /// Automate (= normal spawn path, without human interception).
701 #[default]
702 Automate,
703 /// Composite (= MainAi + Automate running side by side).
704 Composite,
705}
706
707/// Design-time definition of an Operator role (first-class).
708///
709/// `AgentDef.spec.operator_ref` references this struct's `name` as a logical role name.
710/// Binding to a runtime backend (WS session / SDK / pool, etc.) is established via the
711/// attach path; the BP side only declares "under this logical name we expect an Operator
712/// of this Kind".
713///
714/// `spec` is an escape hatch for kind-specific config (WS endpoint / SDK profile / pool
715/// binding, etc.). Even when empty, declaring `name` + `kind` alone is enough for
716/// compile-time validation to succeed (= it guarantees that agent `operator_ref` values
717/// reference an existing definition).
718#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
719#[serde(deny_unknown_fields)]
720pub struct OperatorDef {
721 /// Logical role name (= design-time symbol referenced from `AgentDef.spec.operator_ref`).
722 pub name: String,
723 /// Display name for UI / docs (optional).
724 #[serde(default)]
725 pub display_name: Option<String>,
726 /// Kind axis of the Operator (MainAi / Automate / Composite) — the "BP
727 /// Agent-level" tier of the 4-tier `OperatorKind` cascade (see
728 /// `Blueprint.default_operator_kind` for the full tier list). `None`
729 /// when this `OperatorDef` does not declare a kind; the resolver then
730 /// falls through to BP Global / Default Fallback for agents referencing
731 /// this role via `AgentDef.spec.operator_ref`.
732 #[serde(default)]
733 pub kind: Option<OperatorKind>,
734 /// Kind-specific config (WS endpoint / SDK profile / pool binding, etc.). Interpreted
735 /// by the factory.
736 #[serde(default)]
737 pub spec: Value,
738 /// Operator persona information (e.g. system_prompt template). Same shape as
739 /// `AgentDef.profile`. Used as a template when the Operator itself plays a "role".
740 /// If `None`, the agent-side profile is used instead.
741 #[serde(default)]
742 pub profile: Option<AgentProfile>,
743 /// Operator-level metadata (description / version / tags).
744 #[serde(default)]
745 pub meta: Option<AgentMeta>,
746}
747
748/// Named, multi-step-shared declarative context payload (GH #21 Phase 2).
749///
750/// Lives in the [`Blueprint::metas`] pool and is referenced by name from
751/// two independent consumers: a `$step_meta.ref` envelope embedded in a
752/// Step's evaluated `in` value (the Step tier, resolved by
753/// `EngineDispatcher::dispatch` in the `mlua-swarm` core crate at
754/// dispatch time — see `EngineDispatcher::with_step_metas`), and
755/// [`AgentMeta::meta_ref`] (the Agent tier, resolved at launch time and
756/// merged UNDER the agent's inline `AgentMeta::ctx`). The pool lets
757/// multiple Steps and/or Agents share one declarative context object by
758/// name instead of repeating it inline.
759#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
760#[serde(deny_unknown_fields)]
761pub struct MetaDef {
762 /// Logical name (= referenced by `$step_meta.ref` and
763 /// `AgentMeta.meta_ref`; unique within [`Blueprint::metas`]).
764 pub name: String,
765 /// Declarative context payload. Consumers expect a JSON `Object` so
766 /// it can be shallow-merged with an `inline` override / an agent's
767 /// own `ctx` (a non-`Object` value is rejected — loudly at dispatch
768 /// time for the Step tier, defensively (warn + skip) at launch time
769 /// for the Agent tier); the shape is otherwise free-form.
770 pub ctx: Value,
771}
772
773/// GH #27 (follow-up to #23) — Blueprint-declared override of the
774/// `mlua-swarm` core crate's placement resolver
775/// (`mlua_swarm::core::projection_placement::ProjectionPlacement`), which
776/// decides where a Step's materialized OUTPUT file (submit-time sink,
777/// server read-back, and spawn-time `ctx_projection` pointer — the "3
778/// path" convergence point) is written on disk. Both fields are
779/// independently optional and validated (`dir_template`) at
780/// `Compiler::compile` time — see that resolver's `from_spec` doc for the
781/// full rejection rules.
782#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
783#[serde(deny_unknown_fields)]
784pub struct ProjectionPlacementSpec {
785 /// Which of the spawn-time `work_dir` / `project_root` to prefer as
786 /// the materialize root, falling back to the other when the
787 /// preferred one is absent. `"work_dir"` (default, current
788 /// byte-compat behavior) | `"project_root"`. `None` = the default
789 /// (`"work_dir"`).
790 #[serde(default, skip_serializing_if = "Option::is_none")]
791 pub root: Option<String>,
792 /// Target directory template, relative to the resolved root, with a
793 /// `{task_id}` placeholder substituted at materialize time. `None` =
794 /// the default (`"workspace/tasks/{task_id}/ctx"`, current byte-compat
795 /// behavior). Must be non-empty, contain the `{task_id}` placeholder,
796 /// stay relative, and not contain any `..` path segment — rejected at
797 /// `Compiler::compile` time otherwise.
798 #[serde(default, skip_serializing_if = "Option::is_none")]
799 pub dir_template: Option<String>,
800}
801
802/// Agent / Operator level metadata (description / version / tags).
803#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
804#[serde(deny_unknown_fields)]
805pub struct AgentMeta {
806 /// Short human-readable description.
807 #[serde(default)]
808 pub description: Option<String>,
809 /// Free-form version label.
810 #[serde(default)]
811 pub version: Option<String>,
812 /// Tag list for classification / routing.
813 #[serde(default)]
814 pub tags: Vec<String>,
815 /// GH #21 Phase 1 — "BP Agent-level" tier of the agent-context supply
816 /// axis: a declarative object merged into `ctx.meta.runtime` for this
817 /// agent's spawns, on top of (and winning over)
818 /// [`Blueprint::default_agent_ctx`]. See that field's doc for the
819 /// contrast with `default_init_ctx`. `None` = this agent declares no
820 /// per-agent context (the BP-global tier alone applies, if any).
821 #[serde(default, skip_serializing_if = "Option::is_none")]
822 #[schemars(with = "Option<Value>")]
823 pub ctx: Option<Value>,
824 /// GH #21 Phase 1 — "BP Agent-level" tier of the [`ContextPolicy`]
825 /// cascade: outranks [`Blueprint::default_context_policy`] for this
826 /// agent. `None` = fall through to the BP-global policy (or pass-all
827 /// if that is also `None`).
828 #[serde(default, skip_serializing_if = "Option::is_none")]
829 pub context_policy: Option<ContextPolicy>,
830 /// GH #21 Phase 2 — "BP Agent-level" tier of the [`MetaDef`] pool:
831 /// resolves against [`Blueprint::metas`] by name. The resolved
832 /// `ctx` sits UNDER this agent's inline [`Self::ctx`] (inline wins
833 /// on key collision). `None` = this agent declares no shared
834 /// `MetaDef` reference.
835 #[serde(default, skip_serializing_if = "Option::is_none")]
836 pub meta_ref: Option<String>,
837 /// GH #23 — the step-projection canonical name this agent's dispatched
838 /// Steps should be addressed by (data-plane submit / `ContextPolicy`
839 /// filter / `StepPointer`/`StepSummary` `name` / REST `:step` path /
840 /// materialized file stem — see `mlua-swarm` core's
841 /// `core::step_naming::StepNaming` for the table this field feeds).
842 /// `None` = this agent declares no projection name; the canonical
843 /// name falls back to the Step's `ref` (the flow.ir data-plane
844 /// producer name), matching pre-GH-#23 behavior byte-for-byte.
845 #[serde(default, skip_serializing_if = "Option::is_none")]
846 pub projection_name: Option<String>,
847}
848
849// ──────────────────────────────────────────────────────────────────────────
850// Compiler hints / strategy
851// ──────────────────────────────────────────────────────────────────────────
852
853/// Per-agent overrides / hints. Interpreted by the Compiler / SpawnerFactory; not required.
854#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
855#[serde(deny_unknown_fields)]
856pub struct CompilerHints {
857 /// Agent name → per-agent hint (= passed to `SpawnerFactory.build`).
858 #[serde(default)]
859 pub per_agent: HashMap<String, Value>,
860 /// Global hints (= e.g. parallel limit, default timeout, ...).
861 #[serde(default)]
862 pub global: Value,
863}
864
865/// Compiler behavior rules. Controls strict / lenient handling and default fallback.
866#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
867#[serde(deny_unknown_fields)]
868pub struct CompilerStrategy {
869 /// If `true` (default), an unresolved `Step.ref` is an error; if `false`, it falls
870 /// through to the default Spawner.
871 #[serde(default = "default_true")]
872 pub strict_refs: bool,
873 /// If `true` (default), an `AgentKind` missing from the registry is an error; if
874 /// `false`, it is skipped.
875 #[serde(default = "default_true")]
876 pub strict_kind: bool,
877}
878
879fn default_true() -> bool {
880 true
881}
882
883impl Default for CompilerStrategy {
884 fn default() -> Self {
885 Self {
886 strict_refs: true,
887 strict_kind: true,
888 }
889 }
890}
891
892// ──────────────────────────────────────────────────────────────────────────
893// Blueprint metadata / origin
894// ──────────────────────────────────────────────────────────────────────────
895
896/// Blueprint-level metadata (description / origin / tags / ttl / version label / alias).
897#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
898#[serde(deny_unknown_fields)]
899pub struct BlueprintMetadata {
900 /// Short human-readable description of the Blueprint.
901 #[serde(default)]
902 pub description: Option<String>,
903 /// Provenance record (inline / file / algocline).
904 #[serde(default)]
905 pub origin: BlueprintOrigin,
906 /// Tag list for classification / routing.
907 #[serde(default)]
908 pub tags: Vec<String>,
909 /// Optional SemVer label (= match target for `TaskPipeline VersionSelector::SemVerReq`).
910 /// Example: `"1.2.3"`. Rewritten by `EnhanceAdapter` on PATCH/MINOR/MAJOR bumps.
911 #[serde(default, skip_serializing_if = "Option::is_none")]
912 pub version_label: Option<String>,
913 /// Optional LDS session alias label. The Swarm engine itself does not apply this
914 /// (= it is free-form content); the value is expanded into the Spawn directive and
915 /// reaches the MainAI. The MainAI is expected to establish a task session via
916 /// `mcp__lds__session_create(root=..., alias=<this>)`, and to inject
917 /// `LDS Session Alias: <this>` verbatim into the SubAgent dispatch prompt body.
918 /// The SubAgent body then calls `mcp__lds__session_start(alias=<this>)` with the
919 /// received alias. Worktree ownership is thereby unified under a single session, and
920 /// cross-SubAgent / cross-worktree ownership blocks (= `not owned by this session`)
921 /// cannot fire structurally.
922 #[serde(default, skip_serializing_if = "Option::is_none")]
923 pub project_name_alias: Option<String>,
924 /// Optional default TTL (seconds) for tasks dispatched via this BP. Estimated by the
925 /// Blueprint author from the flow shape (agent count × expected duration per agent).
926 /// If `POST /v1/tasks` supplies `ttl_secs` explicitly, the body value wins; otherwise
927 /// this metadata field is used as the default; if both are absent, the server global
928 /// default (`default_run_ttl()` = 1800s) applies. Not needed for short chains (~5 min);
929 /// recommended for long chains (14 agents × several minutes = 30-60 min).
930 #[serde(default, skip_serializing_if = "Option::is_none")]
931 pub default_run_ttl_secs: Option<u64>,
932}
933
934/// Provenance record of a Blueprint.
935#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
936#[serde(tag = "kind", rename_all = "snake_case")]
937pub enum BlueprintOrigin {
938 /// Inline construction, e.g. via a Rust struct literal or test code.
939 #[default]
940 Inline,
941 /// Loaded from a file.
942 File {
943 /// Source file path.
944 path: String,
945 },
946 /// Emitted by an algocline strategy (traced by `session_id`).
947 Algo {
948 /// Algocline session identifier.
949 session_id: String,
950 },
951}
952
953#[cfg(test)]
954mod tests {
955 use super::*;
956
957 #[test]
958 fn schema_version_default_parses() {
959 let v = default_schema_version();
960 assert_eq!(v.to_string(), "0.1.0");
961 }
962
963 #[test]
964 fn current_schema_version_const_matches() {
965 assert_eq!(CURRENT_SCHEMA_VERSION, "0.1.0");
966 }
967
968 #[test]
969 fn blueprint_json_schema_exports_key_properties() {
970 let schema = schemars::schema_for!(Blueprint);
971 let v = serde_json::to_value(&schema).expect("schema serializes");
972 let props = v["properties"].as_object().expect("object schema");
973 for key in [
974 "schema_version",
975 "id",
976 "flow",
977 "agents",
978 "operators",
979 "metas",
980 "hints",
981 "strategy",
982 "metadata",
983 "spawner_hints",
984 "default_agent_kind",
985 "default_operator_kind",
986 "default_init_ctx",
987 "default_agent_ctx",
988 "default_context_policy",
989 "projection_placement",
990 "audits",
991 ] {
992 assert!(props.contains_key(key), "missing property: {key}");
993 }
994 // semver override lands as a plain string
995 assert_eq!(v["properties"]["schema_version"]["type"], "string");
996 // enum variants (snake_case) survive into the schema (LLM author axis)
997 let dump = v.to_string();
998 assert!(dump.contains("agent_block"), "AgentKind variants in schema");
999 assert!(dump.contains("main_ai"), "OperatorKind variants in schema");
1000 // nested defs are referenced (AgentDef reachable from agents[])
1001 assert!(dump.contains("AgentDef"), "AgentDef definition in schema");
1002 }
1003
1004 #[test]
1005 fn agent_profile_worker_binding_roundtrips_when_some() {
1006 let profile = AgentProfile {
1007 worker_binding: Some("mse-worker-coder".to_string()),
1008 ..Default::default()
1009 };
1010 let json = serde_json::to_value(&profile).expect("serializes");
1011 assert_eq!(json["worker_binding"], "mse-worker-coder");
1012 let back: AgentProfile = serde_json::from_value(json).expect("deserializes");
1013 assert_eq!(back.worker_binding.as_deref(), Some("mse-worker-coder"));
1014 }
1015
1016 #[test]
1017 fn agent_profile_worker_binding_omitted_when_none() {
1018 let profile = AgentProfile::default();
1019 let json = serde_json::to_value(&profile).expect("serializes");
1020 // `skip_serializing_if = "Option::is_none"` — the key must not appear at all.
1021 assert!(
1022 json.as_object().unwrap().get("worker_binding").is_none(),
1023 "worker_binding key must be absent when None: {json}"
1024 );
1025 let back: AgentProfile = serde_json::from_value(json).expect("deserializes");
1026 assert_eq!(back.worker_binding, None);
1027 }
1028
1029 // ──────────────────────────────────────────────────────────────
1030 // issue #19 ST3: `Blueprint.default_init_ctx`
1031 // ──────────────────────────────────────────────────────────────
1032
1033 fn minimal_bp(default_init_ctx: Option<Value>) -> Blueprint {
1034 Blueprint {
1035 schema_version: current_schema_version(),
1036 id: "bp-init-ctx-ut".into(),
1037 flow: FlowNode::Seq { children: vec![] },
1038 agents: vec![],
1039 operators: vec![],
1040 metas: vec![],
1041 hints: Default::default(),
1042 strategy: Default::default(),
1043 metadata: Default::default(),
1044 spawner_hints: Default::default(),
1045 default_agent_kind: AgentKind::Operator,
1046 default_operator_kind: None,
1047 default_init_ctx,
1048 default_agent_ctx: None,
1049 default_context_policy: None,
1050 projection_placement: None,
1051 audits: vec![],
1052 degradation_policy: None,
1053 }
1054 }
1055
1056 #[test]
1057 fn blueprint_default_init_ctx_roundtrips_when_some() {
1058 let bp = minimal_bp(Some(serde_json::json!({ "seeded": true })));
1059 let json = serde_json::to_string(&bp).expect("serializes");
1060 let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
1061 assert_eq!(
1062 back.default_init_ctx,
1063 Some(serde_json::json!({ "seeded": true }))
1064 );
1065 assert_eq!(bp, back);
1066 }
1067
1068 #[test]
1069 fn blueprint_default_init_ctx_omitted_when_none() {
1070 let bp = minimal_bp(None);
1071 let json = serde_json::to_value(&bp).expect("serializes");
1072 // `skip_serializing_if = "Option::is_none"` — the key must not appear at all
1073 // (pre-#19 Blueprints round-trip byte-identical through this path).
1074 assert!(
1075 json.as_object().unwrap().get("default_init_ctx").is_none(),
1076 "default_init_ctx key must be absent when None: {json}"
1077 );
1078 let back: Blueprint = serde_json::from_value(json).expect("deserializes");
1079 assert_eq!(back.default_init_ctx, None);
1080 assert_eq!(bp, back);
1081 }
1082
1083 #[test]
1084 fn blueprint_json_schema_exports_default_init_ctx_as_nullable_value() {
1085 let schema = schemars::schema_for!(Blueprint);
1086 let v = serde_json::to_value(&schema).expect("schema serializes");
1087 assert!(
1088 v["properties"]["default_init_ctx"].is_object(),
1089 "default_init_ctx must appear in the exported schema: {v}"
1090 );
1091 }
1092
1093 // ──────────────────────────────────────────────────────────────
1094 // issue #21 Phase 1: `Blueprint.default_agent_ctx` /
1095 // `default_context_policy`, `AgentMeta.ctx` / `context_policy`,
1096 // `ContextPolicy`
1097 // ──────────────────────────────────────────────────────────────
1098
1099 #[test]
1100 fn blueprint_default_agent_ctx_and_context_policy_roundtrip_when_some() {
1101 let mut bp = minimal_bp(None);
1102 bp.default_agent_ctx = Some(serde_json::json!({ "org_conventions": "x" }));
1103 bp.default_context_policy = Some(ContextPolicy {
1104 include: Some(vec!["project_root".to_string()]),
1105 exclude: vec!["work_dir".to_string()],
1106 ..Default::default()
1107 });
1108 let json = serde_json::to_string(&bp).expect("serializes");
1109 let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
1110 assert_eq!(bp, back);
1111 assert_eq!(
1112 back.default_agent_ctx,
1113 Some(serde_json::json!({ "org_conventions": "x" }))
1114 );
1115 assert_eq!(
1116 back.default_context_policy,
1117 Some(ContextPolicy {
1118 include: Some(vec!["project_root".to_string()]),
1119 exclude: vec!["work_dir".to_string()],
1120 ..Default::default()
1121 })
1122 );
1123 }
1124
1125 #[test]
1126 fn blueprint_default_agent_ctx_and_context_policy_omitted_when_none() {
1127 let bp = minimal_bp(None);
1128 let json = serde_json::to_value(&bp).expect("serializes");
1129 let obj = json.as_object().unwrap();
1130 assert!(
1131 obj.get("default_agent_ctx").is_none(),
1132 "default_agent_ctx key must be absent when None: {json}"
1133 );
1134 assert!(
1135 obj.get("default_context_policy").is_none(),
1136 "default_context_policy key must be absent when None: {json}"
1137 );
1138 let back: Blueprint = serde_json::from_value(json).expect("deserializes");
1139 assert_eq!(back.default_agent_ctx, None);
1140 assert_eq!(back.default_context_policy, None);
1141 assert_eq!(bp, back);
1142 }
1143
1144 #[test]
1145 fn blueprint_json_schema_exports_agent_ctx_and_context_policy() {
1146 let schema = schemars::schema_for!(Blueprint);
1147 let v = serde_json::to_value(&schema).expect("schema serializes");
1148 assert!(
1149 v["properties"]["default_agent_ctx"].is_object(),
1150 "default_agent_ctx must appear in the exported schema: {v}"
1151 );
1152 assert!(
1153 v["properties"]["default_context_policy"].is_object(),
1154 "default_context_policy must appear in the exported schema: {v}"
1155 );
1156 }
1157
1158 // ──────────────────────────────────────────────────────────────
1159 // GH #27 (follow-up to #23): `Blueprint.projection_placement` /
1160 // `ProjectionPlacementSpec`
1161 // ──────────────────────────────────────────────────────────────
1162
1163 #[test]
1164 fn blueprint_projection_placement_roundtrips_when_some() {
1165 let mut bp = minimal_bp(None);
1166 bp.projection_placement = Some(ProjectionPlacementSpec {
1167 root: Some("project_root".to_string()),
1168 dir_template: Some("custom/{task_id}/out".to_string()),
1169 });
1170 let json = serde_json::to_string(&bp).expect("serializes");
1171 let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
1172 assert_eq!(bp, back);
1173 assert_eq!(
1174 back.projection_placement,
1175 Some(ProjectionPlacementSpec {
1176 root: Some("project_root".to_string()),
1177 dir_template: Some("custom/{task_id}/out".to_string()),
1178 })
1179 );
1180 }
1181
1182 #[test]
1183 fn blueprint_projection_placement_omitted_when_none() {
1184 let bp = minimal_bp(None);
1185 let json = serde_json::to_value(&bp).expect("serializes");
1186 assert!(
1187 json.as_object()
1188 .unwrap()
1189 .get("projection_placement")
1190 .is_none(),
1191 "projection_placement key must be absent when None: {json}"
1192 );
1193 let back: Blueprint = serde_json::from_value(json).expect("deserializes");
1194 assert_eq!(back.projection_placement, None);
1195 assert_eq!(bp, back);
1196 }
1197
1198 #[test]
1199 fn blueprint_json_schema_exports_projection_placement() {
1200 let schema = schemars::schema_for!(Blueprint);
1201 let v = serde_json::to_value(&schema).expect("schema serializes");
1202 assert!(
1203 v["properties"]["projection_placement"].is_object(),
1204 "projection_placement must appear in the exported schema: {v}"
1205 );
1206 }
1207
1208 #[test]
1209 fn agent_meta_ctx_and_context_policy_roundtrip_when_some() {
1210 let meta = AgentMeta {
1211 ctx: Some(serde_json::json!({ "k": "v" })),
1212 context_policy: Some(ContextPolicy {
1213 include: None,
1214 exclude: vec!["run_id".to_string()],
1215 ..Default::default()
1216 }),
1217 ..Default::default()
1218 };
1219 let json = serde_json::to_value(&meta).expect("serializes");
1220 let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
1221 assert_eq!(back, meta);
1222 }
1223
1224 #[test]
1225 fn agent_meta_ctx_and_context_policy_omitted_when_none() {
1226 let meta = AgentMeta::default();
1227 let json = serde_json::to_value(&meta).expect("serializes");
1228 let obj = json.as_object().unwrap();
1229 assert!(
1230 obj.get("ctx").is_none(),
1231 "ctx key must be absent when None: {json}"
1232 );
1233 assert!(
1234 obj.get("context_policy").is_none(),
1235 "context_policy key must be absent when None: {json}"
1236 );
1237 }
1238
1239 #[test]
1240 fn agent_meta_json_schema_exports_ctx_context_policy_and_meta_ref() {
1241 let schema = schemars::schema_for!(AgentMeta);
1242 let v = serde_json::to_value(&schema).expect("schema serializes");
1243 let props = v["properties"].as_object().expect("object schema");
1244 for key in [
1245 "description",
1246 "version",
1247 "tags",
1248 "ctx",
1249 "context_policy",
1250 "meta_ref",
1251 "projection_name",
1252 ] {
1253 assert!(props.contains_key(key), "missing property: {key}");
1254 }
1255 }
1256
1257 // ──────────────────────────────────────────────────────────────
1258 // issue #21 Phase 2: `MetaDef`, `Blueprint.metas`, `AgentMeta.meta_ref`
1259 // ──────────────────────────────────────────────────────────────
1260
1261 #[test]
1262 fn meta_def_roundtrips_through_json() {
1263 let def = MetaDef {
1264 name: "heavy-scan".to_string(),
1265 ctx: serde_json::json!({ "work_dir": "/x" }),
1266 };
1267 let json = serde_json::to_value(&def).expect("serializes");
1268 let back: MetaDef = serde_json::from_value(json).expect("deserializes");
1269 assert_eq!(back, def);
1270 }
1271
1272 #[test]
1273 fn blueprint_metas_omitted_when_empty() {
1274 let bp = minimal_bp(None);
1275 let json = serde_json::to_value(&bp).expect("serializes");
1276 assert!(
1277 json.as_object().unwrap().get("metas").is_none(),
1278 "metas key must be absent when empty: {json}"
1279 );
1280 let back: Blueprint = serde_json::from_value(json).expect("deserializes");
1281 assert!(back.metas.is_empty());
1282 assert_eq!(bp, back);
1283 }
1284
1285 #[test]
1286 fn blueprint_metas_roundtrips_when_non_empty() {
1287 let mut bp = minimal_bp(None);
1288 bp.metas = vec![MetaDef {
1289 name: "heavy-scan".to_string(),
1290 ctx: serde_json::json!({ "work_dir": "/x" }),
1291 }];
1292 let json = serde_json::to_string(&bp).expect("serializes");
1293 let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
1294 assert_eq!(bp, back);
1295 assert_eq!(back.metas.len(), 1);
1296 assert_eq!(back.metas[0].name, "heavy-scan");
1297 }
1298
1299 #[test]
1300 fn blueprint_json_schema_exports_metas() {
1301 let schema = schemars::schema_for!(Blueprint);
1302 let v = serde_json::to_value(&schema).expect("schema serializes");
1303 assert!(
1304 v["properties"]["metas"].is_object(),
1305 "metas must appear in the exported schema: {v}"
1306 );
1307 let dump = v.to_string();
1308 assert!(dump.contains("MetaDef"), "MetaDef definition in schema");
1309 }
1310
1311 #[test]
1312 fn agent_meta_meta_ref_roundtrips_when_some() {
1313 let meta = AgentMeta {
1314 meta_ref: Some("heavy-scan".to_string()),
1315 ..Default::default()
1316 };
1317 let json = serde_json::to_value(&meta).expect("serializes");
1318 assert_eq!(json["meta_ref"], "heavy-scan");
1319 let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
1320 assert_eq!(back, meta);
1321 }
1322
1323 #[test]
1324 fn agent_meta_meta_ref_omitted_when_none() {
1325 let meta = AgentMeta::default();
1326 let json = serde_json::to_value(&meta).expect("serializes");
1327 assert!(
1328 json.as_object().unwrap().get("meta_ref").is_none(),
1329 "meta_ref key must be absent when None: {json}"
1330 );
1331 let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
1332 assert_eq!(back.meta_ref, None);
1333 }
1334
1335 // ──────────────────────────────────────────────────────────────
1336 // GH #23: `AgentMeta.projection_name`
1337 // ──────────────────────────────────────────────────────────────
1338
1339 #[test]
1340 fn agent_meta_projection_name_roundtrips_when_some() {
1341 let meta = AgentMeta {
1342 projection_name: Some("plan".to_string()),
1343 ..Default::default()
1344 };
1345 let json = serde_json::to_value(&meta).expect("serializes");
1346 assert_eq!(json["projection_name"], "plan");
1347 let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
1348 assert_eq!(back, meta);
1349 }
1350
1351 #[test]
1352 fn agent_meta_projection_name_omitted_when_none() {
1353 let meta = AgentMeta::default();
1354 let json = serde_json::to_value(&meta).expect("serializes");
1355 assert!(
1356 json.as_object().unwrap().get("projection_name").is_none(),
1357 "projection_name key must be absent when None: {json}"
1358 );
1359 let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
1360 assert_eq!(back.projection_name, None);
1361 assert_eq!(back, meta);
1362 }
1363
1364 #[test]
1365 fn agent_meta_rejects_unknown_field_with_projection_name_present() {
1366 // `deny_unknown_fields` must still reject an unrelated stray key
1367 // even when `projection_name` is present alongside it (regression
1368 // guard: adding the field must not accidentally loosen the
1369 // contract for the rest of the struct).
1370 let json = serde_json::json!({
1371 "projection_name": "plan",
1372 "not_a_real_field": true
1373 });
1374 let err = serde_json::from_value::<AgentMeta>(json).unwrap_err();
1375 assert!(
1376 err.to_string().contains("not_a_real_field")
1377 || err.to_string().contains("unknown field"),
1378 "expected an unknown-field rejection, got: {err}"
1379 );
1380 }
1381
1382 #[test]
1383 fn context_policy_default_allows_everything() {
1384 let policy = ContextPolicy::default();
1385 assert!(policy.allows("project_root"));
1386 assert!(policy.allows("anything"));
1387 }
1388
1389 #[test]
1390 fn context_policy_include_only_allows_listed_names() {
1391 let policy = ContextPolicy {
1392 include: Some(vec!["project_root".to_string()]),
1393 exclude: vec![],
1394 ..Default::default()
1395 };
1396 assert!(policy.allows("project_root"));
1397 assert!(!policy.allows("work_dir"));
1398 }
1399
1400 #[test]
1401 fn context_policy_exclude_wins_over_include() {
1402 let policy = ContextPolicy {
1403 include: Some(vec!["project_root".to_string()]),
1404 exclude: vec!["project_root".to_string()],
1405 ..Default::default()
1406 };
1407 assert!(!policy.allows("project_root"));
1408 }
1409
1410 #[test]
1411 fn context_policy_roundtrips_through_json() {
1412 let policy = ContextPolicy {
1413 include: Some(vec!["a".to_string(), "b".to_string()]),
1414 exclude: vec!["c".to_string()],
1415 ..Default::default()
1416 };
1417 let json = serde_json::to_value(&policy).expect("serializes");
1418 let back: ContextPolicy = serde_json::from_value(json).expect("deserializes");
1419 assert_eq!(back, policy);
1420 }
1421
1422 #[test]
1423 fn context_policy_default_roundtrips_as_empty_object() {
1424 let policy = ContextPolicy::default();
1425 let json = serde_json::to_value(&policy).expect("serializes");
1426 assert_eq!(
1427 json,
1428 serde_json::json!({
1429 "include": null,
1430 "exclude": [],
1431 "steps": null,
1432 "steps_exclude": [],
1433 })
1434 );
1435 let back: ContextPolicy = serde_json::from_value(json).expect("deserializes");
1436 assert_eq!(back, policy);
1437 }
1438
1439 // ──────────────────────────────────────────────────────────────
1440 // ST5 (`projection-adapter`): `ContextPolicy.steps` / `steps_exclude`
1441 // ──────────────────────────────────────────────────────────────
1442
1443 #[test]
1444 fn context_policy_steps_default_allows_every_step() {
1445 let policy = ContextPolicy::default();
1446 assert!(policy.allows_step("planner"));
1447 assert!(policy.allows_step("anything"));
1448 }
1449
1450 #[test]
1451 fn context_policy_steps_include_only_allows_listed_names() {
1452 let policy = ContextPolicy {
1453 steps: Some(vec!["planner".to_string()]),
1454 ..Default::default()
1455 };
1456 assert!(policy.allows_step("planner"));
1457 assert!(!policy.allows_step("coder"));
1458 }
1459
1460 #[test]
1461 fn context_policy_steps_empty_list_allows_none() {
1462 let policy = ContextPolicy {
1463 steps: Some(vec![]),
1464 ..Default::default()
1465 };
1466 assert!(!policy.allows_step("planner"));
1467 }
1468
1469 #[test]
1470 fn context_policy_steps_exclude_wins_over_steps() {
1471 let policy = ContextPolicy {
1472 steps: Some(vec!["planner".to_string()]),
1473 steps_exclude: vec!["planner".to_string()],
1474 ..Default::default()
1475 };
1476 assert!(!policy.allows_step("planner"));
1477 }
1478
1479 #[test]
1480 fn context_policy_steps_roundtrips_through_json() {
1481 let policy = ContextPolicy {
1482 steps: Some(vec!["planner".to_string(), "coder".to_string()]),
1483 steps_exclude: vec!["reviewer".to_string()],
1484 ..Default::default()
1485 };
1486 let json = serde_json::to_value(&policy).expect("serializes");
1487 let back: ContextPolicy = serde_json::from_value(json).expect("deserializes");
1488 assert_eq!(back, policy);
1489 }
1490
1491 // ──────────────────────────────────────────────────────────────
1492 // GH #34: `AuditDef`, `AuditMode`, `Blueprint.audits`
1493 // ──────────────────────────────────────────────────────────────
1494
1495 #[test]
1496 fn blueprint_audits_omitted_when_empty() {
1497 let bp = minimal_bp(None);
1498 let json = serde_json::to_value(&bp).expect("serializes");
1499 assert!(
1500 json.as_object().unwrap().get("audits").is_none(),
1501 "audits key must be absent when empty: {json}"
1502 );
1503 let back: Blueprint = serde_json::from_value(json).expect("deserializes");
1504 assert!(back.audits.is_empty());
1505 assert_eq!(bp, back);
1506 }
1507
1508 #[test]
1509 fn blueprint_audits_roundtrips_when_non_empty() {
1510 let mut bp = minimal_bp(None);
1511 bp.audits = vec![AuditDef {
1512 agent: "auditor".to_string(),
1513 steps: Some(vec!["worker".to_string()]),
1514 mode: AuditMode::Sync,
1515 }];
1516 let json = serde_json::to_string(&bp).expect("serializes");
1517 let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
1518 assert_eq!(bp, back);
1519 assert_eq!(back.audits.len(), 1);
1520 assert_eq!(back.audits[0].agent, "auditor");
1521 assert_eq!(back.audits[0].mode, AuditMode::Sync);
1522 }
1523
1524 #[test]
1525 fn audit_def_steps_none_and_mode_default_when_omitted() {
1526 let json = serde_json::json!({ "agent": "auditor" });
1527 let def: AuditDef = serde_json::from_value(json).expect("deserializes");
1528 assert_eq!(def.steps, None);
1529 assert_eq!(def.mode, AuditMode::Async);
1530 }
1531
1532 #[test]
1533 fn audit_def_rejects_unknown_field() {
1534 let json = serde_json::json!({ "agent": "auditor", "not_a_real_field": true });
1535 let err = serde_json::from_value::<AuditDef>(json).unwrap_err();
1536 assert!(
1537 err.to_string().contains("not_a_real_field")
1538 || err.to_string().contains("unknown field"),
1539 "expected an unknown-field rejection, got: {err}"
1540 );
1541 }
1542
1543 #[test]
1544 fn audit_mode_serializes_snake_case() {
1545 assert_eq!(
1546 serde_json::to_value(AuditMode::Async).unwrap(),
1547 serde_json::json!("async")
1548 );
1549 assert_eq!(
1550 serde_json::to_value(AuditMode::Sync).unwrap(),
1551 serde_json::json!("sync")
1552 );
1553 }
1554
1555 #[test]
1556 fn blueprint_json_schema_exports_audits_and_audit_def() {
1557 let schema = schemars::schema_for!(Blueprint);
1558 let v = serde_json::to_value(&schema).expect("schema serializes");
1559 assert!(
1560 v["properties"]["audits"].is_object(),
1561 "audits must appear in the exported schema: {v}"
1562 );
1563 let dump = v.to_string();
1564 assert!(dump.contains("AuditDef"), "AuditDef definition in schema");
1565 }
1566
1567 // ──────────────────────────────────────────────────────────────
1568 // GH #32: `Blueprint.degradation_policy`, `DegradationPolicy`
1569 // ──────────────────────────────────────────────────────────────
1570
1571 #[test]
1572 fn blueprint_without_degradation_policy_deserializes_to_none() {
1573 let json = serde_json::json!({
1574 "schema_version": current_schema_version(),
1575 "id": "no-degradation-policy-ut",
1576 "flow": { "kind": "seq", "children": [] },
1577 });
1578 let bp: Blueprint = serde_json::from_value(json).expect("deserializes");
1579 assert_eq!(bp.degradation_policy, None);
1580 }
1581
1582 #[test]
1583 fn blueprint_degradation_policy_omitted_when_none() {
1584 let bp = minimal_bp(None);
1585 let json = serde_json::to_value(&bp).expect("serializes");
1586 assert!(
1587 json.as_object()
1588 .unwrap()
1589 .get("degradation_policy")
1590 .is_none(),
1591 "degradation_policy key must be absent when None: {json}"
1592 );
1593 }
1594
1595 #[test]
1596 fn blueprint_degradation_policy_warn_and_fail_roundtrip() {
1597 for (label, expected) in [
1598 ("warn", DegradationPolicy::Warn),
1599 ("fail", DegradationPolicy::Fail),
1600 ] {
1601 let mut bp = minimal_bp(None);
1602 bp.degradation_policy = Some(expected);
1603 let json = serde_json::to_string(&bp).expect("serializes");
1604 assert!(json.contains(&format!("\"degradation_policy\":\"{label}\"")));
1605 let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
1606 assert_eq!(back.degradation_policy, Some(expected));
1607 }
1608 }
1609
1610 #[test]
1611 fn degradation_policy_rejects_unknown_variant() {
1612 let json = serde_json::json!({
1613 "schema_version": current_schema_version(),
1614 "id": "degradation-policy-unknown-variant-ut",
1615 "flow": { "kind": "seq", "children": [] },
1616 "degradation_policy": "ignore",
1617 });
1618 let err = serde_json::from_value::<Blueprint>(json).unwrap_err();
1619 assert!(
1620 err.to_string().contains("unknown variant"),
1621 "expected an unknown-variant rejection, got: {err}"
1622 );
1623 }
1624}