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//!     hints: Default::default(),
58//!     strategy: Default::default(),
59//!     metadata: Default::default(),
60//!     spawner_hints: Default::default(),
61//!     default_agent_kind: AgentKind::Operator,
62//!     default_operator_kind: None,
63//! };
64//!
65//! assert_eq!(bp.id.as_str(), "hello");
66//! assert_eq!(bp.agents.len(), 1);
67//! assert_eq!(bp.strategy.strict_refs, true);
68//! ```
69//!
70//! Round-trip a [`Blueprint`] through JSON (= confirms `serde` derives and the
71//! `deny_unknown_fields` contract):
72//!
73//! ```
74//! use mlua_swarm_schema::{AgentKind, Blueprint, BlueprintMetadata};
75//! use mlua_flow_ir::{Expr, Node};
76//! use serde_json::json;
77//!
78//! let bp = Blueprint {
79//!     schema_version: mlua_swarm_schema::current_schema_version(),
80//!     id: "roundtrip".into(),
81//!     flow: Node::Seq { children: vec![] },
82//!     agents: vec![],
83//!     operators: vec![],
84//!     hints: Default::default(),
85//!     strategy: Default::default(),
86//!     metadata: BlueprintMetadata {
87//!         description: Some("roundtrip smoke".into()),
88//!         default_run_ttl_secs: Some(1800),
89//!         ..Default::default()
90//!     },
91//!     spawner_hints: Default::default(),
92//!     default_agent_kind: AgentKind::Operator,
93//!     default_operator_kind: None,
94//! };
95//!
96//! let json = serde_json::to_string(&bp).unwrap();
97//! let back: Blueprint = serde_json::from_str(&json).unwrap();
98//! assert_eq!(bp, back);
99//! assert_eq!(back.metadata.default_run_ttl_secs, Some(1800));
100//! ```
101
102#![warn(missing_docs)]
103
104use mlua_flow_ir::Node as FlowNode;
105use schemars::JsonSchema;
106use serde::{Deserialize, Serialize};
107use serde_json::Value;
108use std::collections::HashMap;
109
110// ──────────────────────────────────────────────────────────────────────────
111// Versioning
112// ──────────────────────────────────────────────────────────────────────────
113
114/// Current Blueprint schema version. Tied to this crate's semver.
115pub const CURRENT_SCHEMA_VERSION: &str = "0.1.0";
116
117fn default_schema_version() -> semver::Version {
118    current_schema_version()
119}
120
121/// Blueprint construction helper: returns the semver of the current schema version.
122/// Callers can write `schema_version: current_schema_version(),`.
123pub fn current_schema_version() -> semver::Version {
124    semver::Version::parse(CURRENT_SCHEMA_VERSION)
125        .expect("CURRENT_SCHEMA_VERSION must be valid semver")
126}
127
128// ──────────────────────────────────────────────────────────────────────────
129// BlueprintId (human-facing ID newtype)
130// ──────────────────────────────────────────────────────────────────────────
131
132/// Identifier for a Blueprint series — the domain name (`coding`,
133/// `design`, `testing`, etc.). Default: [`BlueprintId::main`].
134///
135/// One representation across the workspace (issue #14): this type is
136/// shared by the schema's [`Blueprint::id`] and the engine's store-layer
137/// keys (`mlua-swarm` re-exports it at the old
138/// `blueprint::store::types::BlueprintId` path). The value is
139/// user-supplied — there is no prefix convention to validate, unlike the
140/// engine's minted `T-` / `R-` / `ST-` ids — so construction is
141/// infallible; the inner string is private so call sites go through
142/// [`BlueprintId::new`] and the accessors. `#[serde(transparent)]` keeps
143/// both the JSON wire shape and the generated JSON Schema a plain string.
144#[derive(
145    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
146)]
147#[serde(transparent)]
148pub struct BlueprintId(String);
149
150impl BlueprintId {
151    /// The default series name used when a caller doesn't pick one.
152    pub const MAIN: &'static str = "main";
153
154    /// Shorthand for `BlueprintId::new(BlueprintId::MAIN)`.
155    pub fn main() -> Self {
156        Self(Self::MAIN.to_string())
157    }
158
159    /// Wrap any string-like value as a `BlueprintId` (user-supplied key;
160    /// nothing to validate).
161    pub fn new(s: impl Into<String>) -> Self {
162        Self(s.into())
163    }
164
165    /// Borrow the inner series name.
166    pub fn as_str(&self) -> &str {
167        &self.0
168    }
169
170    /// Consume the id and return the inner series name.
171    pub fn into_string(self) -> String {
172        self.0
173    }
174}
175
176impl std::fmt::Display for BlueprintId {
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        f.write_str(&self.0)
179    }
180}
181
182impl From<String> for BlueprintId {
183    fn from(s: String) -> Self {
184        Self(s)
185    }
186}
187
188impl From<&str> for BlueprintId {
189    fn from(s: &str) -> Self {
190        Self(s.to_string())
191    }
192}
193
194#[cfg(test)]
195mod blueprint_id_tests {
196    use super::*;
197
198    /// issue #14 convergence guard: `Blueprint.id` becoming a newtype must
199    /// not change the generated JSON Schema — the property stays an inline
200    /// plain string (no `$ref`), byte-compatible with the `String` era.
201    #[test]
202    fn blueprint_id_field_schema_stays_a_plain_inline_string() {
203        let schema = schemars::schema_for!(Blueprint);
204        let v = serde_json::to_value(&schema).expect("schema serializes");
205        let id = &v["properties"]["id"];
206        assert_eq!(id["type"], "string", "id must stay a plain string: {id}");
207        assert!(id.get("$ref").is_none(), "id must not become a $ref: {id}");
208    }
209
210    /// The JSON wire shape of the newtype is the bare string.
211    #[test]
212    fn blueprint_id_serde_is_transparent() {
213        let id = BlueprintId::new("coding");
214        assert_eq!(
215            serde_json::to_value(&id).unwrap(),
216            serde_json::json!("coding")
217        );
218        let back: BlueprintId = serde_json::from_value(serde_json::json!("coding")).unwrap();
219        assert_eq!(back, id);
220    }
221}
222
223// ──────────────────────────────────────────────────────────────────────────
224// Blueprint (top-level package)
225// ──────────────────────────────────────────────────────────────────────────
226
227/// Unified package of flow.ir + Swarm extension layers. The entry-point type of Swarm.
228#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
229#[serde(deny_unknown_fields)]
230pub struct Blueprint {
231    /// Schema version (= tied to this crate's semver). Default = `CURRENT_SCHEMA_VERSION`.
232    /// Serialized as a semver string (e.g. `"0.1.0"`).
233    #[serde(default = "default_schema_version")]
234    #[schemars(with = "String")]
235    pub schema_version: semver::Version,
236    /// Blueprint identifier (= unique key within the caller's namespace).
237    #[schemars(with = "String")]
238    pub id: BlueprintId,
239    /// Embeds the flow.ir Node verbatim (= keeps flow.ir side unpolluted).
240    /// Opaque in the JSON Schema (the Node shape is owned by the `mlua-flow-ir`
241    /// crate, a separate repo; see its docs for the Node / Expr grammar).
242    #[schemars(with = "Value")]
243    pub flow: FlowNode,
244    /// Swarm extension layer: agent → backend mapping.
245    #[serde(default)]
246    pub agents: Vec<AgentDef>,
247    /// Swarm extension layer: **design-time definition** of Operator roles (first-class).
248    ///
249    /// `AgentDef.spec.operator_ref` references an `OperatorDef.name` (logical role name) in
250    /// this vec. Embedding runtime-generated IDs such as sid into the BP is forbidden
251    /// (= collapses the design-time vs runtime boundary). Runtime backend bindings are
252    /// established via the attach / register path; the BP side holds only logical names.
253    ///
254    /// Every `kind = Operator` agent must have its `spec.operator_ref` present in this
255    /// list — the compiler validates it at `compile()` time. May be `[]` only when the
256    /// Blueprint declares no Operator agents.
257    #[serde(default)]
258    pub operators: Vec<OperatorDef>,
259    /// Swarm extension layer: per-agent hints (interpreted by the Compiler).
260    #[serde(default)]
261    pub hints: CompilerHints,
262    /// Swarm extension layer: Compiler behavior strategy (strict / lenient).
263    #[serde(default)]
264    pub strategy: CompilerStrategy,
265    /// Blueprint metadata (description / origin / tags / ttl / version label / alias).
266    #[serde(default)]
267    pub metadata: BlueprintMetadata,
268    /// Swarm extension layer: hint keys of the layers to wrap around the SpawnerStack.
269    /// Resolved by the LayerRegistry at engine bind time (= unregistered keys are silently
270    /// skipped). Flow / Blueprint do not hold middleware implementations (e.g. MainAIMiddleware)
271    /// directly; they only declare required capabilities as string keys (= implementations
272    /// live in the engine-side LayerRegistry).
273    #[serde(default)]
274    pub spawner_hints: SpawnerHints,
275    /// BP-wide default `AgentKind` (= fallback when `AgentDef.kind` is omitted).
276    /// Four-layer cascade: (1) Schema impl Default = Operator, (2) CLI
277    /// `--default-agent-kind`, (3) this field (BP JSON literal), (4) `AgentDef.kind`
278    /// (per-agent literal). (5) `CompilerHints.kind_override` allows runtime override.
279    /// All default resolution flows through this path.
280    #[serde(default = "default_global_agent_kind")]
281    pub default_agent_kind: AgentKind,
282    /// BP-wide default `OperatorKind` (= the "BP Global" tier of the 4-tier
283    /// `OperatorKind` cascade). `None` when the Blueprint author does not
284    /// declare a default; the caller-side resolver then falls through to
285    /// the hardcoded `OperatorKind::default()` (Automate).
286    ///
287    /// # 4-tier cascade (highest to lowest priority)
288    ///
289    /// 1. Runtime Agent-level (per-agent override supplied at task-launch time)
290    /// 2. Runtime Global (the launch-time `operator_kind` request)
291    /// 3. BP Agent-level (`OperatorDef.kind`, resolved via `AgentDef.spec.operator_ref`)
292    /// 4. BP Global (this field)
293    /// 5. Default Fallback (`OperatorKind::default()` = Automate)
294    ///
295    /// The collapse itself is implemented once on the engine side and consumed
296    /// per-agent when resolving operator info.
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub default_operator_kind: Option<OperatorKind>,
299}
300
301/// Global default `AgentKind` at the Schema impl Default layer. Bottom of the 4-layer cascade.
302pub fn default_global_agent_kind() -> AgentKind {
303    AgentKind::Operator
304}
305
306/// Set of **capability hint keys** for the SpawnerLayer required by a Blueprint.
307///
308/// # Design rationale (= for the person who will reconstruct this later)
309///
310/// A Blueprint is a pure layer of flow.ir + agent name binding and holds no middleware
311/// **implementation**. Nevertheless there are cases where the caller must be told the BP
312/// needs certain **capabilities** — e.g. "MainAI hook required", "Operator delegate path
313/// required", operator role mode switching, presence/absence of senior escalation, and
314/// so on.
315///
316/// `spawner_hints.layers` is the place where those capabilities are declared as **string
317/// keys**. The engine-side `LayerRegistry` (= consumer crate) resolves key → factory and
318/// wraps the compiled routes with a `SpawnerStack`. The Blueprint does not import the
319/// concrete `MainAIMiddleware` type; it exposes intent through strings such as `"main_ai"`
320/// (= separates the pure Flow layer from implementation details).
321///
322/// # Canonical hint keys
323///
324/// - `"main_ai"` → `MainAIMiddleware` (= fires SpawnHook before/after when kind is MainAi/Composite)
325/// - `"senior_escalation"` → `SeniorEscalationMiddleware` (= fires SeniorBridge.ask on worker ok=false)
326/// - `"operator_delegate"` → `OperatorDelegateMiddleware` (= delegates the entire spawn to an external Operator.execute)
327///
328/// # Behavior of unregistered keys
329///
330/// If the engine-side LayerRegistry has no matching factory, the key is **silently skipped**
331/// (= lenient default). This preserves Blueprint portability (= an unsupported capability in
332/// another deployment falls back gracefully).
333#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)]
334#[serde(deny_unknown_fields)]
335pub struct SpawnerHints {
336    /// Ordered list of layer hint keys to wrap around the SpawnerStack.
337    #[serde(default)]
338    pub layers: Vec<String>,
339}
340
341// ──────────────────────────────────────────────────────────────────────────
342// AgentDef / AgentKind / AgentProfile / AgentMeta
343// ──────────────────────────────────────────────────────────────────────────
344
345/// Maps an agent name to a Worker IMPL kind and its configuration. Referenced from flow.ir
346/// `Step.ref` by name.
347///
348/// # Design
349///
350/// `AgentDef.kind` directly expresses the **Worker IMPL axis** (= not the old Spawner axis).
351/// Dispatching to a host Spawner adapter (`InProcSpawner` / `ProcessSpawner` /
352/// `OperatorSpawner`) is done by an internal Resolver on the compiler side. The design goal
353/// is "do not make the caller aware of which Spawner hosts the Worker IMPL"; the caller
354/// (Blueprint author) sees only the WorkerIMPL viewpoint.
355///
356/// A Spawner-axis hint (= "which adapter would you prefer running this Worker on", as a
357/// priority list) will be added via a future `spawner_hint: Vec<Spawner>` field as a carry.
358/// The current internal Resolver is a fixed 1:1 mapping, so the field is unnecessary today.
359#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
360#[serde(deny_unknown_fields)]
361pub struct AgentDef {
362    /// Agent name (= referenced from flow.ir `Step.ref`).
363    pub name: String,
364    /// Worker IMPL kind (= see [`AgentKind`]).
365    pub kind: AgentKind,
366    /// Free-form schema per kind. Interpreted by the SpawnerFactory.
367    #[serde(default)]
368    pub spec: Value,
369    /// Agent persona information (system_prompt / model / tools, etc.). Orthogonal to the
370    /// backend kind and is a first-class field. Expected to be populated by
371    /// `agent_md_loader` from the frontmatter + body of an `agent.md`. `None` = an agent
372    /// without a profile (= backend built solely from `spec`).
373    #[serde(default)]
374    pub profile: Option<AgentProfile>,
375    /// Agent-level metadata (description / version / tags).
376    #[serde(default)]
377    pub meta: Option<AgentMeta>,
378}
379
380/// Agent persona information. Orthogonal to the backend kind (Shell / InProc / Operator).
381///
382/// Populated by `agent_md_loader::load_dir` from the frontmatter and Markdown body of
383/// `agents/*.md` in agent-profiles. The backend (e.g. AgentBlockOperator) receives this
384/// struct at construction / dispatch time and consumes `system_prompt` as the LLM API
385/// system message and `model` / `tools` as configuration.
386///
387/// C-C-specific fields (`permissionMode` / `memory` / `abtest`, etc.) are dumped into
388/// `extras: Value`, and consumers that need them read them out. This is the escape hatch
389/// that keeps the schema future-proof rather than making it strict.
390#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
391#[serde(deny_unknown_fields)]
392pub struct AgentProfile {
393    /// Markdown body (= system prompt content).
394    #[serde(default)]
395    pub system_prompt: String,
396    /// LLM model identifier (e.g. `"sonnet"` / `"haiku"` / `"opus"`).
397    #[serde(default)]
398    pub model: Option<String>,
399    /// Reasoning effort (e.g. `"low"` / `"medium"` / `"high"`).
400    #[serde(default)]
401    pub effort: Option<String>,
402    /// List of available tool names (normalized from the CSV form in frontmatter).
403    #[serde(default)]
404    pub tools: Vec<String>,
405    /// Frontmatter `description`. A short one-line description.
406    #[serde(default)]
407    pub description: Option<String>,
408    /// C-C-specific / future-proof fields (permissionMode / memory / abtest / ...).
409    /// Shape is the leftover keys of the agent.md frontmatter dumped as a JSON object.
410    #[serde(default)]
411    pub extras: Value,
412    /// Content hash (blake3 32-byte hex) of the agent body (= `system_prompt`).
413    ///
414    /// # Purpose
415    ///
416    /// When the Enhance loop receives a Patch that replaces
417    /// `/agents/N/profile/system_prompt`, the post-hook in `patch_applier.lua`
418    /// recomputes this field (= new blake3 of the body) and updates it automatically.
419    /// This is the field that structurally prevents a Blueprint carrying a stale hash
420    /// from being committed.
421    ///
422    /// - `None` = hash not computed (= manually built agent, or a Blueprint predating this field)
423    /// - `Some(hex)` = latest hash at agent-profiles seed time or after PatchApplier
424    ///
425    /// Planned to be used as the cache-index key in `AgentStore`.
426    #[serde(default)]
427    pub version_hash: Option<String>,
428    /// Claude Code SubAgent definition name this agent binds to at spawn
429    /// time (e.g. "mse-worker-coder"). Why: the Blueprint is the single
430    /// source of truth for the declaration↔executor binding — an external
431    /// registry would duplicate what `tools` already declares and drift.
432    /// `None` is valid for agents whose operator backend never dispatches
433    /// a SubAgent (direct-LLM operators); WS thin-path operators require
434    /// it at compile time (see `Operator::requires_worker_binding`).
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    pub worker_binding: Option<String>,
437}
438
439/// SoT of the **Worker IMPL axis**. A closed enum managed inside Swarm and extended by
440/// variant addition through **explicit maintenance**. String lookup / escape hatches are
441/// deliberately not adopted.
442///
443/// This enum **expresses Worker IMPL directly**; dispatching to a host Spawner adapter is
444/// resolved by an internal Resolver on the compiler side (= callers see only the Worker
445/// IMPL viewpoint).
446///
447/// # Internal Resolver mapping (= currently a fixed 1:1, carry: priority list form)
448///
449/// | AgentKind | Host Spawner adapter |
450/// |---|---|
451/// | `Lua` | `InProcSpawner` (mlua VM eval) |
452/// | `RustFn` | `InProcSpawner` (Rust closure) |
453/// | `AgentBlock` | `InProcSpawner` (agent-block-core SDK in-process) |
454/// | `Subprocess` | `ProcessSpawner` (child process launch) |
455/// | `Operator` | `OperatorSpawner` (interactive role / Human-MainAI delegation) |
456#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, JsonSchema)]
457#[serde(rename_all = "snake_case")]
458pub enum AgentKind {
459    /// Lua script eval through the mlua VM (= factory-side registry looked up by `spec.fn_id`).
460    Lua,
461    /// Rust closure (= factory-side registry looked up by `spec.fn_id`).
462    RustFn,
463    /// Headless LLM agent via the agent-block-core SDK (in-process).
464    AgentBlock,
465    /// Child-process launch (= `spec.program` + `args`, via the ProcessSpawner path).
466    Subprocess,
467    /// Interactive Operator role (= MainAI / Human delegation, `spec.operator_ref`).
468    Operator,
469}
470
471// ──────────────────────────────────────────────────────────────────────────
472// OperatorDef / OperatorKind
473// ──────────────────────────────────────────────────────────────────────────
474
475/// Kind axis of an Operator role (= "in which mode does this Operator run").
476/// Corresponds 1:1 with the engine's runtime `OperatorKind`. Kept as a schema
477/// duplicate so that BPs can be authored while depending only on this crate.
478#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
479#[serde(rename_all = "snake_case")]
480pub enum OperatorKind {
481    /// MainAI (= interactive AI Operator via WS client or SDK).
482    MainAi,
483    /// Automate (= normal spawn path, without human interception).
484    #[default]
485    Automate,
486    /// Composite (= MainAi + Automate running side by side).
487    Composite,
488}
489
490/// Design-time definition of an Operator role (first-class).
491///
492/// `AgentDef.spec.operator_ref` references this struct's `name` as a logical role name.
493/// Binding to a runtime backend (WS session / SDK / pool, etc.) is established via the
494/// attach path; the BP side only declares "under this logical name we expect an Operator
495/// of this Kind".
496///
497/// `spec` is an escape hatch for kind-specific config (WS endpoint / SDK profile / pool
498/// binding, etc.). Even when empty, declaring `name` + `kind` alone is enough for
499/// compile-time validation to succeed (= it guarantees that agent `operator_ref` values
500/// reference an existing definition).
501#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
502#[serde(deny_unknown_fields)]
503pub struct OperatorDef {
504    /// Logical role name (= design-time symbol referenced from `AgentDef.spec.operator_ref`).
505    pub name: String,
506    /// Display name for UI / docs (optional).
507    #[serde(default)]
508    pub display_name: Option<String>,
509    /// Kind axis of the Operator (MainAi / Automate / Composite) — the "BP
510    /// Agent-level" tier of the 4-tier `OperatorKind` cascade (see
511    /// `Blueprint.default_operator_kind` for the full tier list). `None`
512    /// when this `OperatorDef` does not declare a kind; the resolver then
513    /// falls through to BP Global / Default Fallback for agents referencing
514    /// this role via `AgentDef.spec.operator_ref`.
515    #[serde(default)]
516    pub kind: Option<OperatorKind>,
517    /// Kind-specific config (WS endpoint / SDK profile / pool binding, etc.). Interpreted
518    /// by the factory.
519    #[serde(default)]
520    pub spec: Value,
521    /// Operator persona information (e.g. system_prompt template). Same shape as
522    /// `AgentDef.profile`. Used as a template when the Operator itself plays a "role".
523    /// If `None`, the agent-side profile is used instead.
524    #[serde(default)]
525    pub profile: Option<AgentProfile>,
526    /// Operator-level metadata (description / version / tags).
527    #[serde(default)]
528    pub meta: Option<AgentMeta>,
529}
530
531/// Agent / Operator level metadata (description / version / tags).
532#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
533#[serde(deny_unknown_fields)]
534pub struct AgentMeta {
535    /// Short human-readable description.
536    #[serde(default)]
537    pub description: Option<String>,
538    /// Free-form version label.
539    #[serde(default)]
540    pub version: Option<String>,
541    /// Tag list for classification / routing.
542    #[serde(default)]
543    pub tags: Vec<String>,
544}
545
546// ──────────────────────────────────────────────────────────────────────────
547// Compiler hints / strategy
548// ──────────────────────────────────────────────────────────────────────────
549
550/// Per-agent overrides / hints. Interpreted by the Compiler / SpawnerFactory; not required.
551#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
552#[serde(deny_unknown_fields)]
553pub struct CompilerHints {
554    /// Agent name → per-agent hint (= passed to `SpawnerFactory.build`).
555    #[serde(default)]
556    pub per_agent: HashMap<String, Value>,
557    /// Global hints (= e.g. parallel limit, default timeout, ...).
558    #[serde(default)]
559    pub global: Value,
560}
561
562/// Compiler behavior rules. Controls strict / lenient handling and default fallback.
563#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
564#[serde(deny_unknown_fields)]
565pub struct CompilerStrategy {
566    /// If `true` (default), an unresolved `Step.ref` is an error; if `false`, it falls
567    /// through to the default Spawner.
568    #[serde(default = "default_true")]
569    pub strict_refs: bool,
570    /// If `true` (default), an `AgentKind` missing from the registry is an error; if
571    /// `false`, it is skipped.
572    #[serde(default = "default_true")]
573    pub strict_kind: bool,
574}
575
576fn default_true() -> bool {
577    true
578}
579
580impl Default for CompilerStrategy {
581    fn default() -> Self {
582        Self {
583            strict_refs: true,
584            strict_kind: true,
585        }
586    }
587}
588
589// ──────────────────────────────────────────────────────────────────────────
590// Blueprint metadata / origin
591// ──────────────────────────────────────────────────────────────────────────
592
593/// Blueprint-level metadata (description / origin / tags / ttl / version label / alias).
594#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
595#[serde(deny_unknown_fields)]
596pub struct BlueprintMetadata {
597    /// Short human-readable description of the Blueprint.
598    #[serde(default)]
599    pub description: Option<String>,
600    /// Provenance record (inline / file / algocline).
601    #[serde(default)]
602    pub origin: BlueprintOrigin,
603    /// Tag list for classification / routing.
604    #[serde(default)]
605    pub tags: Vec<String>,
606    /// Optional SemVer label (= match target for `TaskPipeline VersionSelector::SemVerReq`).
607    /// Example: `"1.2.3"`. Rewritten by `EnhanceAdapter` on PATCH/MINOR/MAJOR bumps.
608    #[serde(default, skip_serializing_if = "Option::is_none")]
609    pub version_label: Option<String>,
610    /// Optional LDS session alias label. The Swarm engine itself does not apply this
611    /// (= it is free-form content); the value is expanded into the Spawn directive and
612    /// reaches the MainAI. The MainAI is expected to establish a task session via
613    /// `mcp__lds__session_create(root=..., alias=<this>)`, and to inject
614    /// `LDS Session Alias: <this>` verbatim into the SubAgent dispatch prompt body.
615    /// The SubAgent body then calls `mcp__lds__session_start(alias=<this>)` with the
616    /// received alias. Worktree ownership is thereby unified under a single session, and
617    /// cross-SubAgent / cross-worktree ownership blocks (= `not owned by this session`)
618    /// cannot fire structurally.
619    #[serde(default, skip_serializing_if = "Option::is_none")]
620    pub project_name_alias: Option<String>,
621    /// Optional default TTL (seconds) for tasks dispatched via this BP. Estimated by the
622    /// Blueprint author from the flow shape (agent count × expected duration per agent).
623    /// If `POST /v1/tasks` supplies `ttl_secs` explicitly, the body value wins; otherwise
624    /// this metadata field is used as the default; if both are absent, the server global
625    /// default (`default_run_ttl()` = 1800s) applies. Not needed for short chains (~5 min);
626    /// recommended for long chains (14 agents × several minutes = 30-60 min).
627    #[serde(default, skip_serializing_if = "Option::is_none")]
628    pub default_run_ttl_secs: Option<u64>,
629}
630
631/// Provenance record of a Blueprint.
632#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
633#[serde(tag = "kind", rename_all = "snake_case")]
634pub enum BlueprintOrigin {
635    /// Inline construction, e.g. via a Rust struct literal or test code.
636    #[default]
637    Inline,
638    /// Loaded from a file.
639    File {
640        /// Source file path.
641        path: String,
642    },
643    /// Emitted by an algocline strategy (traced by `session_id`).
644    Algo {
645        /// Algocline session identifier.
646        session_id: String,
647    },
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653
654    #[test]
655    fn schema_version_default_parses() {
656        let v = default_schema_version();
657        assert_eq!(v.to_string(), "0.1.0");
658    }
659
660    #[test]
661    fn current_schema_version_const_matches() {
662        assert_eq!(CURRENT_SCHEMA_VERSION, "0.1.0");
663    }
664
665    #[test]
666    fn blueprint_json_schema_exports_key_properties() {
667        let schema = schemars::schema_for!(Blueprint);
668        let v = serde_json::to_value(&schema).expect("schema serializes");
669        let props = v["properties"].as_object().expect("object schema");
670        for key in [
671            "schema_version",
672            "id",
673            "flow",
674            "agents",
675            "operators",
676            "hints",
677            "strategy",
678            "metadata",
679            "spawner_hints",
680            "default_agent_kind",
681            "default_operator_kind",
682        ] {
683            assert!(props.contains_key(key), "missing property: {key}");
684        }
685        // semver override lands as a plain string
686        assert_eq!(v["properties"]["schema_version"]["type"], "string");
687        // enum variants (snake_case) survive into the schema (LLM author axis)
688        let dump = v.to_string();
689        assert!(dump.contains("agent_block"), "AgentKind variants in schema");
690        assert!(dump.contains("main_ai"), "OperatorKind variants in schema");
691        // nested defs are referenced (AgentDef reachable from agents[])
692        assert!(dump.contains("AgentDef"), "AgentDef definition in schema");
693    }
694
695    #[test]
696    fn agent_profile_worker_binding_roundtrips_when_some() {
697        let profile = AgentProfile {
698            worker_binding: Some("mse-worker-coder".to_string()),
699            ..Default::default()
700        };
701        let json = serde_json::to_value(&profile).expect("serializes");
702        assert_eq!(json["worker_binding"], "mse-worker-coder");
703        let back: AgentProfile = serde_json::from_value(json).expect("deserializes");
704        assert_eq!(back.worker_binding.as_deref(), Some("mse-worker-coder"));
705    }
706
707    #[test]
708    fn agent_profile_worker_binding_omitted_when_none() {
709        let profile = AgentProfile::default();
710        let json = serde_json::to_value(&profile).expect("serializes");
711        // `skip_serializing_if = "Option::is_none"` — the key must not appear at all.
712        assert!(
713            json.as_object().unwrap().get("worker_binding").is_none(),
714            "worker_binding key must be absent when None: {json}"
715        );
716        let back: AgentProfile = serde_json::from_value(json).expect("deserializes");
717        assert_eq!(back.worker_binding, None);
718    }
719}