Skip to main content

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