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