Skip to main content

mlua_swarm/blueprint/
compiler.rs

1//! Blueprint `Compiler`, `CompiledAgentTable`, and the three default
2//! `SpawnerFactory` implementations.
3//!
4//! ## Pipeline
5//!
6//! ```text
7//! Blueprint (= flow + agents + hints + strategy + spawner_hints)
8//!     │
9//!     │ Compiler.compile(&bp)          ← this module (AgentDef → SpawnerAdapter table)
10//!     ▼
11//! CompiledBlueprint {
12//!     router: Arc<CompiledAgentTable>, // ctx.agent → SpawnerAdapter lookup
13//!     flow:   FlowNode,                // the flow.ir source (evaluated via EngineDispatcher)
14//!     metadata: BlueprintMetadata,
15//! }
16//!     │
17//!     │ service::linker::link(router, blueprint.spawner_hints.layers, &engine)
18//!     ▼                                   ↑ Layer wrapping is done separately (src/service/linker.rs)
19//! `Arc<dyn SpawnerAdapter>`            (already wrapped with base + hint SpawnerLayers)
20//!     │
21//!     ▼ EngineDispatcher::with_spawner → engine.dispatch_attempt_with
22//! ```
23//!
24//! `CompiledAgentTable` is a thin table: it looks up `routes[name]` by
25//! `ctx.agent` and hands the spawn off to the matching `SpawnerAdapter`.
26//! The `routes` map is built at compile time through `SpawnerFactory`
27//! implementations. Layer wrapping is not part of this module — it lives
28//! in `service::linker::link`.
29
30use crate::blueprint::{AgentDef, AgentKind, Blueprint, BlueprintMetadata};
31use crate::core::ctx::Ctx;
32use crate::core::engine::Engine;
33use crate::core::projection_placement::{ProjectionPlacement, ProjectionPlacementError};
34use crate::core::step_naming::{StepNaming, StepNamingError};
35use crate::operator::{Operator, OperatorSpawner, WorkerBinding};
36use crate::types::{CapToken, StepId};
37use crate::worker::adapter::{InProcSpawner, SpawnError, SpawnerAdapter, WorkerFn};
38use crate::worker::process_spawner::{ProcessSpawner, StreamMode};
39use crate::worker::Worker;
40use async_trait::async_trait;
41use mlua_flow_ir::{Expr, Node as FlowNode, Path};
42use mlua_swarm_schema::{VerdictChannel, VerdictContract};
43use serde_json::Value;
44use std::collections::HashMap;
45use std::sync::Arc;
46use thiserror::Error;
47
48// ─── error ───────────────────────────────────────────────────────────────
49
50/// Everything that can go wrong while `Compiler::compile` turns a
51/// `Blueprint` into a `CompiledBlueprint`.
52#[derive(Debug, Error)]
53pub enum CompileError {
54    /// An `AgentDef.kind` has no matching entry in the `SpawnerRegistry`
55    /// and `Blueprint.strategy.strict_kind` is set.
56    #[error("unknown agent kind in SpawnerRegistry: {0:?}")]
57    UnknownKind(AgentKind),
58    /// The `AgentDef.spec` shape did not match what the factory for its
59    /// kind requires (missing/mistyped field, etc.).
60    #[error("agent '{name}' spec invalid: {msg}")]
61    InvalidSpec {
62        /// The offending agent's name.
63        name: String,
64        /// Human-readable description of what was wrong with the spec.
65        msg: String,
66    },
67    /// The flow references an agent name that has no corresponding
68    /// `AgentDef` (and no default spawner is configured).
69    #[error("flow references agent '{0}' but no AgentDef matches")]
70    UnresolvedRef(String),
71    /// Two `AgentDef`s in the same `Blueprint` share a name.
72    #[error("duplicate AgentDef name: {0}")]
73    DuplicateAgent(String),
74    /// A `kind = Operator` agent's `spec.operator_ref` does not match
75    /// any `OperatorDef.name` declared in `Blueprint.operators`.
76    #[error("agent '{agent}' operator_ref '{op_ref}' does not match any OperatorDef.name in Blueprint.operators (defined: {defined:?})")]
77    UnresolvedOperatorRef {
78        /// The agent whose `operator_ref` didn't resolve.
79        agent: String,
80        /// The `operator_ref` value that was looked up.
81        op_ref: String,
82        /// The `OperatorDef.name`s that *are* declared, for the error
83        /// message.
84        defined: Vec<String>,
85    },
86    /// GH #21 Phase 2: an `AgentMeta.meta_ref` or a statically-visible
87    /// `$step_meta.ref` (inside a `Step.in` **Lit** expr) does not match
88    /// any `MetaDef.name` declared in `Blueprint.metas`.
89    #[error("{where_} names an undefined MetaDef: '{meta_ref}' (defined: {defined:?})")]
90    UnresolvedMetaRef {
91        /// Human-readable description of where the reference was found
92        /// (e.g. `"AgentMeta.meta_ref of agent 'planner'"` or `"Step
93        /// 'scout' $step_meta.ref"`).
94        where_: String,
95        /// The `meta_ref` value that was looked up.
96        meta_ref: String,
97        /// The `MetaDef.name`s that *are* declared, for the error
98        /// message.
99        defined: Vec<String>,
100    },
101    /// GH #23: two Steps' canonical/alias projection names collide and at
102    /// least one side declared `AgentMeta.projection_name` — see
103    /// [`crate::core::step_naming::StepNaming::from_blueprint`]'s doc for
104    /// the full resolution rule (an undeclared/undeclared clash is a soft
105    /// warning instead, logged but not rejected).
106    #[error("StepNaming collision: {0}")]
107    StepNamingCollision(#[from] StepNamingError),
108    /// GH #27 (follow-up to #23): `Blueprint.projection_placement` failed
109    /// validation — see
110    /// [`crate::core::projection_placement::ProjectionPlacement::from_spec`]'s
111    /// doc for the rejection rules (`dir_template` empty / missing the
112    /// `{task_id}` placeholder / absolute / containing a `..` segment, or
113    /// `root` not `"work_dir"`/`"project_root"`).
114    #[error("invalid projection_placement: {0}")]
115    InvalidProjectionPlacement(#[from] ProjectionPlacementError),
116    /// GH #34: an `audits[].agent` name does not match any `AgentDef.name`
117    /// declared in `Blueprint.agents` — mirrors the `operator_ref`
118    /// validation above (same "design-time reference must resolve"
119    /// discipline).
120    #[error("audits[].agent '{agent}' does not match any AgentDef.name in Blueprint.agents (defined: {defined:?})")]
121    UnresolvedAuditAgent {
122        /// The `audits[].agent` value that was looked up.
123        agent: String,
124        /// The `AgentDef.name`s that *are* declared, for the error
125        /// message.
126        defined: Vec<String>,
127    },
128    /// GH #50: a `Branch`/`Loop` `cond` compares a contract-bearing
129    /// agent's output using the wrong OUTPUT channel — e.g. the agent
130    /// declares `channel: "part"` (verdict staged as the named part
131    /// `"verdict"`, addressed `$.<step>.parts.verdict`) but the cond
132    /// addresses the bare step output (`$.<step>`) instead, or vice
133    /// versa. See the `blueprint-authoring.md` guide's "Returning
134    /// verdicts to drive BP flow" section for Pattern A (`channel:
135    /// "body"`) vs Pattern B (`channel: "part"`).
136    #[error(
137        "agent '{agent}' declares verdict channel '{expected_channel}' but {where_} \
138         addresses it as '{actual_shape}' output — see the \"Returning verdicts to drive \
139         BP flow\" guide's Pattern A (channel: \"body\") / Pattern B (channel: \"part\")"
140    )]
141    VerdictChannelMismatch {
142        /// Human-readable description of where the offending cond was
143        /// found (e.g. `"Branch cond"` / `"Loop cond"`).
144        where_: String,
145        /// The agent whose declared `verdict.channel` didn't match.
146        agent: String,
147        /// The agent's declared channel (`"body"` or `"part"`).
148        expected_channel: String,
149        /// The channel shape the cond's `Path` actually addressed
150        /// (`"body"` or `"part"`).
151        actual_shape: String,
152    },
153    /// GH #50: a `Branch`/`Loop` `cond`'s `Lit` operand (or, for `In`, one
154    /// of the `Lit` haystack's array elements) is not a member of a
155    /// contract-bearing agent's declared `verdict.values` closed token
156    /// set.
157    #[error(
158        "agent '{agent}' verdict Lit '{value}' at {where_} is not a member of the declared \
159         values {values:?}"
160    )]
161    VerdictValueNotInContract {
162        /// Human-readable description of where the offending cond was
163        /// found (e.g. `"Branch cond"` / `"Loop cond"`).
164        where_: String,
165        /// The agent whose declared `verdict.values` didn't contain
166        /// `value`.
167        agent: String,
168        /// The offending `Lit` value, rendered as a string (the raw JSON
169        /// representation when it is not itself a JSON string — a
170        /// non-string `Lit` can never be a member of `values: Vec<String>`
171        /// either way).
172        value: String,
173        /// The agent's declared `verdict.values` closed token set, for the
174        /// error message.
175        values: Vec<String>,
176    },
177}
178
179// ─── SpawnerFactory + Registry ───────────────────────────────────────────
180
181/// Factory trait that interprets an `AgentDef` and builds the concrete
182/// `SpawnerAdapter`. Register one per kind. Parsing the spec,
183/// validating it, and baking the profile are the implementation's job.
184///
185/// The signature was widened in v9 from `(name, spec, hint)` to
186/// `(&AgentDef, hint)` so the profile can be passed through. Most
187/// implementations still just pull `&agent_def.name` and
188/// `&agent_def.spec`, but Operator-backend factories consume
189/// `agent_def.profile` to bake the persona in.
190pub trait SpawnerFactory: Send + Sync {
191    /// Build the concrete `SpawnerAdapter` for one `AgentDef`. `hint` is
192    /// the matching entry (if any) from `Blueprint.hints.per_agent`.
193    fn build(
194        &self,
195        agent_def: &AgentDef,
196        hint: Option<&Value>,
197    ) -> Result<Arc<dyn SpawnerAdapter>, CompileError>;
198}
199
200/// Companion trait that carries the **type-side source of truth** for
201/// the Adapter ↔ `AgentKind` correspondence.
202///
203/// The base [`SpawnerFactory`] trait deliberately does not carry an
204/// associated const so it stays dyn-compatible — that is, so it can be
205/// stored and dispatched as `Arc<dyn SpawnerFactory>`. This companion
206/// trait splits `const KIND: AgentKind` out, and
207/// [`SpawnerRegistry::register`] uses `F::KIND` as the `HashMap` key.
208/// That physically removes the string-lookup failure mode at the type
209/// layer.
210///
211/// The three built-in factories (`Shell` / `InProc` / `Operator`)
212/// implement this. Extension backends (say, `AgentBlockSpawnerFactory`)
213/// follow the same explicit two-step recipe: add a new `AgentKind`
214/// variant and implement this trait.
215pub trait SpawnerFactoryKind: SpawnerFactory {
216    /// The `AgentKind` this factory handles — used as the `HashMap` key
217    /// by `SpawnerRegistry::register`.
218    const KIND: AgentKind;
219    /// The concrete Worker type produced by this `AgentKind` — this
220    /// binds the type chain all the way from `AgentKind` down to `Worker`.
221    /// Every factory declares it so the `AgentKind → Worker` mapping is
222    /// explicit across all four layers. It is the source of truth for
223    /// preserving the concrete type right up until `SpawnerAdapter::spawn`
224    /// erases it into `Box<dyn Worker>`.
225    type Worker: crate::worker::Worker;
226}
227
228/// `AgentKind → SpawnerFactory` mapping. The compiler looks entries up
229/// during `compile()`.
230#[derive(Clone)]
231pub struct SpawnerRegistry {
232    factories: HashMap<AgentKind, Arc<dyn SpawnerFactory>>,
233}
234
235impl SpawnerRegistry {
236    /// Start with an empty `AgentKind → SpawnerFactory` mapping.
237    pub fn new() -> Self {
238        Self {
239            factories: HashMap::new(),
240        }
241    }
242    /// **Type-driven registration** — takes `F::KIND` and uses it as the
243    /// `HashMap` key.
244    ///
245    /// Callers use the form
246    /// `reg.register::<SubprocessProcessSpawnerFactory>(Arc::new(...))`
247    /// and never have to pass an `AgentKind` literal. The Adapter ↔ Kind
248    /// correspondence is enforced at the type layer, physically removing
249    /// the string / enum-literal lookup failure mode.
250    pub fn register<F: SpawnerFactoryKind + 'static>(&mut self, factory: Arc<F>) -> &mut Self {
251        let f: Arc<dyn SpawnerFactory> = factory;
252        self.factories.insert(F::KIND, f);
253        self
254    }
255}
256
257impl Default for SpawnerRegistry {
258    fn default() -> Self {
259        Self::new()
260    }
261}
262
263// ─── Compiler ────────────────────────────────────────────────────────────
264
265/// Turns a `Blueprint` into a `CompiledBlueprint` by resolving every
266/// `AgentDef` against a `SpawnerRegistry`. One-shot: build a fresh
267/// `Compiler` per `compile()` call (or reuse it — it holds no
268/// per-compile state).
269pub struct Compiler {
270    registry: SpawnerRegistry,
271    default_spawner: Option<Arc<dyn SpawnerAdapter>>,
272}
273
274/// The result of `Compiler::compile` — a routing table plus the
275/// unmodified flow and metadata, ready to hand to
276/// `EngineDispatcher::with_spawner` / `mlua_flow_ir::eval_async`.
277pub struct CompiledBlueprint {
278    /// `ctx.agent → SpawnerAdapter` lookup table.
279    pub router: Arc<CompiledAgentTable>,
280    /// The flow.ir source, copied verbatim from `Blueprint.flow`.
281    pub flow: FlowNode,
282    /// Copied verbatim from `Blueprint.metadata`.
283    pub metadata: BlueprintMetadata,
284    /// GH #23: the Blueprint's [`StepNaming`] addressing-space table,
285    /// built once here (the sole construction site — see
286    /// [`StepNaming::from_blueprint`]'s doc) and threaded through
287    /// `EngineDispatcher::with_step_naming` for `EngineState` storage.
288    pub step_naming: Arc<StepNaming>,
289    /// GH #27 (follow-up to #23): the Blueprint's [`ProjectionPlacement`]
290    /// resolver, built once here (the sole construction site — see
291    /// [`ProjectionPlacement::from_spec`]'s doc) and threaded through
292    /// `EngineDispatcher::with_projection_placement` for `EngineState`
293    /// storage.
294    pub projection_placement: Arc<ProjectionPlacement>,
295}
296
297impl Compiler {
298    /// Build a `Compiler` around the given `SpawnerRegistry`, with no
299    /// default spawner (unresolved flow refs are an error unless
300    /// `with_default` is chained on).
301    pub fn new(registry: SpawnerRegistry) -> Self {
302        Self {
303            registry,
304            default_spawner: None,
305        }
306    }
307
308    /// Set a default spawner — used for flow refs (and unregistered
309    /// `AgentKind`s under non-strict strategy) that don't resolve
310    /// against any `AgentDef`/`SpawnerRegistry` entry.
311    pub fn with_default(mut self, sp: Arc<dyn SpawnerAdapter>) -> Self {
312        self.default_spawner = Some(sp);
313        self
314    }
315
316    /// Resolve every `Blueprint.agents` entry through the registry,
317    /// validate `operator_ref`s and flow refs per `Blueprint.strategy`,
318    /// and return the routing table alongside the untouched flow and
319    /// metadata.
320    pub fn compile(&self, bp: &Blueprint) -> Result<CompiledBlueprint, CompileError> {
321        let mut routes: HashMap<String, Arc<dyn SpawnerAdapter>> = HashMap::new();
322        let mut seen: HashMap<String, ()> = HashMap::new();
323        // GH #50: `AgentDef.name` → declared `VerdictContract`, collected
324        // alongside `routes` below (every `verdict: Some(...)` agent, kind
325        // resolution notwithstanding). Consumed by the cond↔output-shape
326        // lint right after the loop, and carried into
327        // `CompiledAgentTable.verdict_contracts`.
328        let mut verdict_contracts: HashMap<String, VerdictContract> = HashMap::new();
329
330        // Design-time validation (OperatorDef as a first-class value):
331        // every `kind = Operator` agent's `spec.operator_ref` must point at
332        // one of `bp.operators[].name`. A Blueprint with any Operator agent
333        // must therefore declare its operators up front; the empty-operators
334        // backward-compat bypass is retired.
335        let defined: Vec<String> = bp.operators.iter().map(|o| o.name.clone()).collect();
336        for ad in &bp.agents {
337            if !matches!(ad.kind, AgentKind::Operator) {
338                continue;
339            }
340            let op_ref = ad.spec.get("operator_ref").and_then(|v| v.as_str());
341            if let Some(op_ref) = op_ref {
342                if !defined.iter().any(|n| n == op_ref) {
343                    return Err(CompileError::UnresolvedOperatorRef {
344                        agent: ad.name.clone(),
345                        op_ref: op_ref.to_string(),
346                        defined: defined.clone(),
347                    });
348                }
349            }
350            // A missing `op_ref` is reported through OperatorSpawnerFactory.build under a different error.
351        }
352
353        // GH #21 Phase 2: named `MetaDef` pool (`Blueprint.metas`) —
354        // validate every reference against it, mirroring the
355        // `operator_ref` validation above.
356        let metas_defined: Vec<String> = bp.metas.iter().map(|m| m.name.clone()).collect();
357        for ad in &bp.agents {
358            let meta_ref = ad.meta.as_ref().and_then(|m| m.meta_ref.as_ref());
359            if let Some(meta_ref) = meta_ref {
360                if !metas_defined.iter().any(|n| n == meta_ref) {
361                    return Err(CompileError::UnresolvedMetaRef {
362                        where_: format!("AgentMeta.meta_ref of agent '{}'", ad.name),
363                        meta_ref: meta_ref.clone(),
364                        defined: metas_defined.clone(),
365                    });
366                }
367            }
368        }
369        // Best-effort static walk of the flow for `$step_meta.ref`
370        // envelopes embedded in a Step's **Lit** `in` expr — this is a
371        // design-time hint only: a non-`Lit` `Step.in` (e.g. `Path`) is
372        // invisible here and skipped silently; `EngineDispatcher::dispatch`
373        // is the authoritative, loud validation line for those.
374        let mut static_step_meta_refs: Vec<(String, String)> = Vec::new();
375        collect_step_meta_refs(&bp.flow, &mut static_step_meta_refs);
376        for (where_, meta_ref) in static_step_meta_refs {
377            if !metas_defined.iter().any(|n| n == &meta_ref) {
378                return Err(CompileError::UnresolvedMetaRef {
379                    where_,
380                    meta_ref,
381                    defined: metas_defined.clone(),
382                });
383            }
384        }
385
386        // GH #34: `audits[].agent` must name an entry in `Blueprint.agents`
387        // — mirrors the `operator_ref` validation above (design-time
388        // reference must resolve at compile time, before any spawner is
389        // built).
390        let agents_defined: Vec<String> = bp.agents.iter().map(|a| a.name.clone()).collect();
391        for audit in &bp.audits {
392            if !agents_defined.iter().any(|n| n == &audit.agent) {
393                return Err(CompileError::UnresolvedAuditAgent {
394                    agent: audit.agent.clone(),
395                    defined: agents_defined.clone(),
396                });
397            }
398        }
399
400        for ad in &bp.agents {
401            if seen.contains_key(&ad.name) {
402                return Err(CompileError::DuplicateAgent(ad.name.clone()));
403            }
404            seen.insert(ad.name.clone(), ());
405
406            // GH #50: contract registration is orthogonal to spawner
407            // resolution (an agent may declare `verdict` regardless of
408            // whether its `kind` resolves), so it happens unconditionally
409            // here, before the kind-resolution branch below that may
410            // `continue`.
411            if let Some(contract) = &ad.verdict {
412                verdict_contracts.insert(ad.name.clone(), contract.clone());
413            }
414
415            let factory = match self.registry.factories.get(&ad.kind) {
416                Some(f) => f.clone(),
417                None => {
418                    if bp.strategy.strict_kind {
419                        return Err(CompileError::UnknownKind(ad.kind.clone()));
420                    } else {
421                        tracing::warn!(
422                            agent = %ad.name,
423                            kind = ?ad.kind,
424                            "no spawner factory registered for agent kind; \
425                             dropping agent from routing table (strict_kind=false)"
426                        );
427                        continue;
428                    }
429                }
430            };
431            let hint = bp.hints.per_agent.get(&ad.name);
432            let spawner = factory.build(ad, hint)?;
433            routes.insert(ad.name.clone(), spawner);
434        }
435
436        // GH #50: `Branch`/`Loop` cond↔output-shape lint. A contract-
437        // bearing agent's output must be compared the way its declared
438        // `verdict.channel` requires and its `Lit` value(s) must be
439        // members of its declared `verdict.values`; an agent referenced by
440        // a cond but declaring no contract only gets a `tracing::warn!`
441        // (opt-in, back-compat — see `AgentDef::verdict`'s doc). Read-only
442        // inspection of `bp.flow` — no rewriting, no new `Expr` forms.
443        verify_verdict_conds(&bp.flow, &verdict_contracts)?;
444
445        if bp.strategy.strict_refs {
446            verify_refs(&bp.flow, &routes, self.default_spawner.is_some())?;
447        }
448
449        // GH #23: build the StepNaming addressing-space table once, here
450        // (the sole construction site). A hard collision (either side
451        // declares `AgentMeta.projection_name`) rejects the compile via
452        // `?` (`StepNamingError` → `CompileError::StepNamingCollision`,
453        // same family as the other Blueprint validation checks above); a
454        // soft undeclared/undeclared collision is logged and compilation
455        // proceeds (pre-GH-#23 union-rule behavior preserved).
456        let (step_naming, step_naming_warnings) = StepNaming::from_blueprint(bp)?;
457        for warning in &step_naming_warnings {
458            tracing::warn!(
459                name = %warning.name,
460                first_step_ref = %warning.first_step_ref,
461                second_step_ref = %warning.second_step_ref,
462                "StepNaming: undeclared steps' canonical/alias names collide; \
463                 the step whose own ref matches the name keeps it (data-plane priority)"
464            );
465        }
466
467        // GH #27 (follow-up to #23): build the ProjectionPlacement resolver
468        // once, here (the sole construction site) — an invalid
469        // `dir_template` / `root` literal rejects the compile via `?`
470        // (`ProjectionPlacementError` → `CompileError::InvalidProjectionPlacement`,
471        // same family as the other Blueprint validation checks above). No
472        // declared `projection_placement` (the pre-#27 default) resolves
473        // to `ProjectionPlacement::default()` unchanged.
474        let projection_placement =
475            ProjectionPlacement::from_spec(bp.projection_placement.as_ref())?;
476
477        let router = Arc::new(CompiledAgentTable {
478            routes,
479            default: self.default_spawner.clone(),
480            verdict_contracts,
481        });
482        Ok(CompiledBlueprint {
483            router,
484            flow: bp.flow.clone(),
485            metadata: bp.metadata.clone(),
486            step_naming: Arc::new(step_naming),
487            projection_placement: Arc::new(projection_placement),
488        })
489    }
490}
491
492/// Walk the flow `Node`, collect every `Step.ref`, and check that no ref
493/// is unresolved against `routes` (or the default, when one exists).
494fn verify_refs(
495    node: &FlowNode,
496    routes: &HashMap<String, Arc<dyn SpawnerAdapter>>,
497    has_default: bool,
498) -> Result<(), CompileError> {
499    let mut refs: Vec<String> = Vec::new();
500    collect_refs(node, &mut refs);
501    for r in refs {
502        if !routes.contains_key(&r) && !has_default {
503            return Err(CompileError::UnresolvedRef(r));
504        }
505    }
506    Ok(())
507}
508
509fn collect_refs(node: &FlowNode, out: &mut Vec<String>) {
510    match node {
511        FlowNode::Step { ref_, .. } => out.push(ref_.clone()),
512        FlowNode::Seq { children } => {
513            for c in children {
514                collect_refs(c, out);
515            }
516        }
517        FlowNode::Branch { then_, else_, .. } => {
518            collect_refs(then_, out);
519            collect_refs(else_, out);
520        }
521        FlowNode::Fanout { body, .. } => collect_refs(body, out),
522        FlowNode::Loop { body, .. } => collect_refs(body, out),
523        FlowNode::Try { body, catch, .. } => {
524            collect_refs(body, out);
525            collect_refs(catch, out);
526        }
527        FlowNode::Assign { .. } => {} // The Assign node carries no ref.
528    }
529}
530
531/// GH #21 Phase 2: walk the flow `Node` (same recursion shape as
532/// [`collect_refs`]) and collect every statically-visible `$step_meta.ref`
533/// found inside a Step's `in` **Lit** expr, as `(where_, meta_ref)` pairs
534/// for [`CompileError::UnresolvedMetaRef`] reporting. Non-`Lit` `in`
535/// exprs (e.g. `Expr::Path`) cannot be inspected statically and are
536/// silently skipped — `EngineDispatcher::dispatch` (the `mlua-swarm` core
537/// crate) is the authoritative, loud validation line for those.
538fn collect_step_meta_refs(node: &FlowNode, out: &mut Vec<(String, String)>) {
539    match node {
540        FlowNode::Step { ref_, in_, .. } => {
541            if let Expr::Lit { value } = in_ {
542                if let Some(meta_ref) = static_step_meta_ref(value) {
543                    out.push((format!("Step '{ref_}' $step_meta.ref"), meta_ref));
544                }
545            }
546        }
547        FlowNode::Seq { children } => {
548            for c in children {
549                collect_step_meta_refs(c, out);
550            }
551        }
552        FlowNode::Branch { then_, else_, .. } => {
553            collect_step_meta_refs(then_, out);
554            collect_step_meta_refs(else_, out);
555        }
556        FlowNode::Fanout { body, .. } => collect_step_meta_refs(body, out),
557        FlowNode::Loop { body, .. } => collect_step_meta_refs(body, out),
558        FlowNode::Try { body, catch, .. } => {
559            collect_step_meta_refs(body, out);
560            collect_step_meta_refs(catch, out);
561        }
562        FlowNode::Assign { .. } => {} // The Assign node carries no `in`.
563    }
564}
565
566/// Extract the `$step_meta.ref` string out of a literal `Step.in` value,
567/// if present and well-formed: `{"$step_meta": {"ref": "<name>", ...},
568/// ...}`. Any other shape (no `$step_meta` key, `ref` absent/null, `ref`
569/// not a string) yields `None` — this is a best-effort static hint only;
570/// a malformed envelope is caught loudly at dispatch time instead (see
571/// `EngineDispatcher::dispatch`'s doc in the `mlua-swarm` core crate).
572fn static_step_meta_ref(value: &Value) -> Option<String> {
573    value
574        .as_object()?
575        .get("$step_meta")?
576        .as_object()?
577        .get("ref")?
578        .as_str()
579        .map(str::to_string)
580}
581
582// ─── GH #50: verdict contract cond↔output-shape lint ───────────────────────
583
584/// GH #50: `Blueprint.agents[].verdict` cond↔output-shape lint, run from
585/// `Compiler::compile` after the routing table is built. Two-pass, same
586/// shape as [`collect_step_meta_refs`]'s best-effort static walk: Pass 1
587/// ([`collect_step_outputs`]) builds `Step.out` `Path` string → producing
588/// `Step.ref_`; Pass 2 ([`collect_verdict_conds`]) walks every
589/// `Branch`/`Loop` `cond` and resolves each `Eq`/`Ne`/`In` `Path`+`Lit`
590/// comparison back through the Pass 1 map. Collects every violation before
591/// returning, then surfaces the first one (mirrors the other
592/// `Compiler::compile` validation blocks' `Result::Err`-via-`?` pattern).
593fn verify_verdict_conds(
594    flow: &FlowNode,
595    verdict_contracts: &HashMap<String, VerdictContract>,
596) -> Result<(), CompileError> {
597    let mut step_outputs: HashMap<String, String> = HashMap::new();
598    collect_step_outputs(flow, &mut step_outputs);
599
600    let mut errors: Vec<CompileError> = Vec::new();
601    collect_verdict_conds(flow, &step_outputs, verdict_contracts, &mut errors);
602    match errors.into_iter().next() {
603        Some(e) => Err(e),
604        None => Ok(()),
605    }
606}
607
608/// Pass 1 of [`verify_verdict_conds`]: `Step.out` `Path` (rendered via its
609/// canonical `Display` string) → the producing `Step.ref_` — mirrors
610/// [`collect_refs`]'s `Step.ref_` ↔ `AgentDef.name` correspondence (a
611/// `Step.ref_` directly indexes `Blueprint.agents[].name`, per
612/// `verify_refs`). Only `Step` nodes produce agent output; `Fanout`'s
613/// joined-array `out` and `Assign`'s computed `at` are not attributed to
614/// any single agent and are not inserted here.
615fn collect_step_outputs(node: &FlowNode, out: &mut HashMap<String, String>) {
616    match node {
617        FlowNode::Step {
618            ref_,
619            out: out_expr,
620            ..
621        } => {
622            if let Expr::Path { at } = out_expr {
623                out.insert(at.to_string(), ref_.clone());
624            }
625        }
626        FlowNode::Seq { children } => {
627            for c in children {
628                collect_step_outputs(c, out);
629            }
630        }
631        FlowNode::Branch { then_, else_, .. } => {
632            collect_step_outputs(then_, out);
633            collect_step_outputs(else_, out);
634        }
635        FlowNode::Fanout { body, .. } => collect_step_outputs(body, out),
636        FlowNode::Loop { body, .. } => collect_step_outputs(body, out),
637        FlowNode::Try { body, catch, .. } => {
638            collect_step_outputs(body, out);
639            collect_step_outputs(catch, out);
640        }
641        FlowNode::Assign { .. } => {} // The Assign node produces no agent output.
642    }
643}
644
645/// Pass 2 of [`verify_verdict_conds`]: recurse through the flow the same
646/// way [`collect_refs`] does, and for every `Branch`/`Loop` node lint its
647/// own `cond` field via [`lint_cond_expr`] (in addition to recursing into
648/// `then_`/`else_`/`body`).
649fn collect_verdict_conds(
650    node: &FlowNode,
651    step_outputs: &HashMap<String, String>,
652    verdict_contracts: &HashMap<String, VerdictContract>,
653    errors: &mut Vec<CompileError>,
654) {
655    match node {
656        FlowNode::Branch { cond, then_, else_ } => {
657            lint_cond_expr(cond, "Branch cond", step_outputs, verdict_contracts, errors);
658            collect_verdict_conds(then_, step_outputs, verdict_contracts, errors);
659            collect_verdict_conds(else_, step_outputs, verdict_contracts, errors);
660        }
661        FlowNode::Loop { cond, body, .. } => {
662            lint_cond_expr(cond, "Loop cond", step_outputs, verdict_contracts, errors);
663            collect_verdict_conds(body, step_outputs, verdict_contracts, errors);
664        }
665        FlowNode::Seq { children } => {
666            for c in children {
667                collect_verdict_conds(c, step_outputs, verdict_contracts, errors);
668            }
669        }
670        FlowNode::Fanout { body, .. } => {
671            collect_verdict_conds(body, step_outputs, verdict_contracts, errors)
672        }
673        FlowNode::Try { body, catch, .. } => {
674            collect_verdict_conds(body, step_outputs, verdict_contracts, errors);
675            collect_verdict_conds(catch, step_outputs, verdict_contracts, errors);
676        }
677        FlowNode::Step { .. } | FlowNode::Assign { .. } => {}
678    }
679}
680
681/// Lint one `cond` `Expr` tree for [`collect_verdict_conds`]: recurses into
682/// `And`/`Or`/`Not` (the only boolean combinators a verdict comparison can
683/// be nested under) and, for every `Eq`/`Ne` leaf whose operands are a
684/// `Path` + `Lit` pair (either order — see [`path_lit_operands`]), or every
685/// `In` leaf whose `needle` is a `Path` and `haystack` is a `Lit` JSON
686/// array, resolves + validates via [`resolve_and_check`]. Any other `Expr`
687/// shape (arithmetic, `Exists`, `CallExtern`, a non-`Path`/`Lit` `Eq`/`Ne`
688/// pair, ...) is not a verdict comparison and is skipped.
689fn lint_cond_expr(
690    expr: &Expr,
691    where_: &str,
692    step_outputs: &HashMap<String, String>,
693    verdict_contracts: &HashMap<String, VerdictContract>,
694    errors: &mut Vec<CompileError>,
695) {
696    match expr {
697        Expr::Eq { lhs, rhs } | Expr::Ne { lhs, rhs } => {
698            if let Some((path, lit)) = path_lit_operands(lhs, rhs) {
699                resolve_and_check(
700                    path,
701                    &[lit],
702                    where_,
703                    step_outputs,
704                    verdict_contracts,
705                    errors,
706                );
707            }
708        }
709        Expr::In { needle, haystack } => {
710            if let (
711                Expr::Path { at },
712                Expr::Lit {
713                    value: Value::Array(items),
714                },
715            ) = (needle.as_ref(), haystack.as_ref())
716            {
717                let lits: Vec<&Value> = items.iter().collect();
718                resolve_and_check(at, &lits, where_, step_outputs, verdict_contracts, errors);
719            }
720        }
721        Expr::And { args } | Expr::Or { args } => {
722            for a in args {
723                lint_cond_expr(a, where_, step_outputs, verdict_contracts, errors);
724            }
725        }
726        Expr::Not { arg } => lint_cond_expr(arg, where_, step_outputs, verdict_contracts, errors),
727        _ => {}
728    }
729}
730
731/// Extract a `(Path, Lit value)` pair out of an `Eq`/`Ne`'s two operands,
732/// regardless of which side the `Path` is on. `None` when the pairing is
733/// not exactly one `Path` + one `Lit` (e.g. both are `Path`, or either is a
734/// compound expr) — those are not statically resolvable to a single
735/// literal token and are left for `EngineDispatcher`'s runtime eval.
736fn path_lit_operands<'a>(lhs: &'a Expr, rhs: &'a Expr) -> Option<(&'a Path, &'a Value)> {
737    match (lhs, rhs) {
738        (Expr::Path { at }, Expr::Lit { value }) => Some((at, value)),
739        (Expr::Lit { value }, Expr::Path { at }) => Some((at, value)),
740        _ => None,
741    }
742}
743
744/// Resolve `path` back to a producing step — either as the bare step
745/// output (`channel: Body`) or, via the literal `.parts.verdict` suffix
746/// (`channel: Part` — the "verdict" part name is a literal, per the
747/// "Returning verdicts to drive BP flow" guide's Pattern B), as that
748/// step's staged verdict part. A `path` that resolves to neither shape
749/// against any known step output is skipped silently (best-effort static
750/// lint only, same posture as [`collect_step_meta_refs`]).
751///
752/// When the resolved agent declares a [`VerdictContract`], validates the
753/// resolved channel against it first (a mismatch short-circuits — the
754/// value comparison is moot once the channel itself is wrong) and then
755/// every entry of `lits` against `contract.values`, pushing at most one
756/// `CompileError` per violation. When the resolved agent declares no
757/// contract, emits a `tracing::warn!` only (GH #50's opt-in requirement).
758fn resolve_and_check(
759    path: &Path,
760    lits: &[&Value],
761    where_: &str,
762    step_outputs: &HashMap<String, String>,
763    verdict_contracts: &HashMap<String, VerdictContract>,
764    errors: &mut Vec<CompileError>,
765) {
766    let path_str = path.to_string();
767    let (agent, actual_shape) = if let Some(agent) = step_outputs.get(&path_str) {
768        (agent, "body")
769    } else if let Some(stripped) = path_str.strip_suffix(".parts.verdict") {
770        match step_outputs.get(stripped) {
771            Some(agent) => (agent, "part"),
772            None => return,
773        }
774    } else {
775        return;
776    };
777
778    let Some(contract) = verdict_contracts.get(agent) else {
779        tracing::warn!(
780            agent = %agent,
781            where_ = %where_,
782            "cond references agent output but no verdict contract declared"
783        );
784        return;
785    };
786
787    let expected_channel = match contract.channel {
788        VerdictChannel::Body => "body",
789        VerdictChannel::Part => "part",
790    };
791    if expected_channel != actual_shape {
792        errors.push(CompileError::VerdictChannelMismatch {
793            where_: where_.to_string(),
794            agent: agent.clone(),
795            expected_channel: expected_channel.to_string(),
796            actual_shape: actual_shape.to_string(),
797        });
798        return;
799    }
800
801    for lit in lits {
802        let value_str = lit
803            .as_str()
804            .map(str::to_string)
805            .unwrap_or_else(|| lit.to_string());
806        if !contract.values.iter().any(|v| v == &value_str) {
807            errors.push(CompileError::VerdictValueNotInContract {
808                where_: where_.to_string(),
809                agent: agent.clone(),
810                value: value_str,
811                values: contract.values.clone(),
812            });
813        }
814    }
815}
816
817// ─── CompiledAgentTable ───────────────────────────────────────────────────────
818
819/// The compile result: an `agent name → SpawnerAdapter` lookup table.
820///
821/// Looks `routes` up by `ctx.agent` (the flow.ir `Step.ref`) and hands
822/// the spawn to the matching `SpawnerAdapter`. If the name is not
823/// registered and a `default` is configured, the default is used; if
824/// there is no default, `SpawnError::NotRegistered` is returned.
825///
826/// Layer wrapping (`AuditMiddleware` / `MainAIMiddleware` and friends) is
827/// not this type's concern — that is done separately in
828/// `service::linker::link`.
829pub struct CompiledAgentTable {
830    pub(crate) routes: HashMap<String, Arc<dyn SpawnerAdapter>>,
831    pub(crate) default: Option<Arc<dyn SpawnerAdapter>>,
832    /// GH #50: `AgentDef.name` → declared `VerdictContract`, for every
833    /// agent that declared one (built by `Compiler::compile`, alongside
834    /// `routes`). Backs the submit-time enforcement point (a follow-up).
835    pub(crate) verdict_contracts: HashMap<String, VerdictContract>,
836}
837
838impl CompiledAgentTable {
839    /// Whether the given agent name is registered in the table — i.e.,
840    /// whether its spawner has been resolved.
841    pub fn has_route(&self, agent: &str) -> bool {
842        self.routes.contains_key(agent)
843    }
844    /// List every resolved agent name.
845    pub fn routed_agents(&self) -> Vec<String> {
846        self.routes.keys().cloned().collect()
847    }
848    /// GH #50: the declared [`VerdictContract`] for `agent`, if any —
849    /// `None` both when `agent` is unresolved and when it resolved but
850    /// declared no contract (opt-in; see `AgentDef::verdict`'s doc).
851    pub fn verdict_contract_for(&self, agent: &str) -> Option<&VerdictContract> {
852        self.verdict_contracts.get(agent)
853    }
854}
855
856#[async_trait]
857impl SpawnerAdapter for CompiledAgentTable {
858    async fn spawn(
859        &self,
860        engine: &Engine,
861        ctx: &Ctx,
862        task_id: StepId,
863        attempt: u32,
864        token: CapToken,
865    ) -> Result<Box<dyn Worker>, SpawnError> {
866        let sp = self
867            .routes
868            .get(&ctx.agent)
869            .cloned()
870            .or_else(|| self.default.clone())
871            .ok_or_else(|| SpawnError::NotRegistered(ctx.agent.clone()))?;
872        sp.spawn(engine, ctx, task_id, attempt, token).await
873    }
874}
875
876// ─── default factories (three variants) ───────────────────────────────────
877
878/// Factory for `AgentKind::Subprocess`. Turns the spec into a
879/// [`ProcessSpawner`].
880///
881/// Naming convention: `<WorkerIMPL><AdapterType>SpawnerFactory`. Factory
882/// names carry both the worker implementation and the host adapter so
883/// they are not confused with each other; the old
884/// `ShellSpawnerFactory` was renamed to this.
885///
886/// Spec shape:
887/// ```jsonc
888/// { "program": "agent-block", "args": ["-s","s.lua"],
889///   "use_stdin": true,                       // optional, default = true
890///   "stream_mode": "ndjson_lines" | "sse_events" | "length_prefixed" | null  // optional, default = null (plain)
891/// }
892/// ```
893pub struct SubprocessProcessSpawnerFactory;
894
895impl SpawnerFactoryKind for SubprocessProcessSpawnerFactory {
896    const KIND: AgentKind = AgentKind::Subprocess;
897    type Worker = crate::worker::process_spawner::ProcessWorker;
898}
899
900impl SpawnerFactory for SubprocessProcessSpawnerFactory {
901    fn build(
902        &self,
903        agent_def: &AgentDef,
904        _hint: Option<&Value>,
905    ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
906        let agent_name = &agent_def.name;
907        let spec = &agent_def.spec;
908        let invalid = |msg: String| CompileError::InvalidSpec {
909            name: agent_name.to_string(),
910            msg,
911        };
912        let program = spec
913            .get("program")
914            .and_then(|v| v.as_str())
915            .ok_or_else(|| invalid("shell spec: 'program' (string) required".into()))?
916            .to_string();
917        let args: Vec<String> = spec
918            .get("args")
919            .and_then(|v| v.as_array())
920            .map(|a| {
921                a.iter()
922                    .filter_map(|x| x.as_str().map(|s| s.to_string()))
923                    .collect()
924            })
925            .unwrap_or_default();
926        let use_stdin = spec
927            .get("use_stdin")
928            .and_then(|v| v.as_bool())
929            .unwrap_or(true);
930        let stream_mode = match spec.get("stream_mode").and_then(|v| v.as_str()) {
931            Some("ndjson_lines") => Some(StreamMode::NdjsonLines),
932            Some("sse_events") => Some(StreamMode::SseEvents),
933            Some("length_prefixed") => Some(StreamMode::LengthPrefixed),
934            Some(other) => return Err(invalid(format!("unknown stream_mode: {other}"))),
935            None => None,
936        };
937
938        let mut sp = ProcessSpawner {
939            program,
940            args,
941            use_stdin,
942            stream_mode,
943        };
944        if let Some(mode) = sp.stream_mode.clone() {
945            sp = sp.stream_mode(mode);
946        }
947        Ok(Arc::new(sp))
948    }
949}
950
951/// Factory for `AgentKind::Lua`. At `build` time it inspects the
952/// `AgentDef.spec` and returns an [`InProcSpawner`] with the Lua-eval
953/// `WorkerFn` registered under `agent_name` — one `InProcSpawner`
954/// instance per agent.
955///
956/// Naming convention: `<WorkerIMPL><AdapterType>SpawnerFactory` (Lua
957/// worker on InProcess adapter). One half of the old
958/// `InProcSpawnerFactory`, split into Lua and RustFn variants.
959///
960/// Spec shape (choose one; `source` wins when both are present):
961///
962/// ```jsonc
963/// // (a) Registry lookup — Lua source id pre-registered with the
964/// //     factory via `register_lua` (used by the enhance flow's built-in
965/// //     workers). Requires the factory to know the id at construction
966/// //     time.
967/// { "fn_id": "patch-spawner" }
968///
969/// // (b) Inline source — a Lua chunk carried by the Blueprint itself,
970/// //     wrapped on the fly at `build` time. Combined with the loader's
971/// //     `$file` ref expansion (`"source": {"$file": "gates/foo.lua"}`)
972/// //     this lets a BP ship deterministic Lua gates without any
973/// //     pre-registration. `label` is optional and defaults to
974/// //     `"<agent_name>.lua"` for error messages.
975/// { "source": "return { value = 42, ok = true }",
976///   "label": "psim-gate.lua" }
977/// ```
978///
979/// Host bridges registered on the factory (see [`Self::with_bridge`])
980/// apply to both spec shapes.
981pub struct LuaInProcessSpawnerFactory {
982    registry: HashMap<String, WorkerFn>,
983    bridges: HashMap<String, HostBridge>,
984}
985
986/// Rust-side bridge function callable from Lua.
987///
988/// Inputs and outputs are both `serde_json::Value` (i.e. JSON). Lua
989/// invokes it as `host.<name>(arg_table)`. If the implementation needs
990/// to call async Rust, the caller does the sync-ification (typically
991/// `tokio::runtime::Handle::current().block_on(...)`).
992///
993/// Design intent: keep Lua scripts focused on flow control and `ctx`
994/// walking, while the heavy lifting (LLM calls, RFC 6902 apply,
995/// verifiers, and so on) stays on the Rust side. Going "pure Lua" —
996/// removing the bridge — is a carry.
997#[derive(Clone)]
998pub struct HostBridge(
999    Arc<dyn Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync>,
1000);
1001
1002impl HostBridge {
1003    /// Wrap a Rust closure as a bridge callable from Lua.
1004    pub fn new<F>(f: F) -> Self
1005    where
1006        F: Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync + 'static,
1007    {
1008        Self(Arc::new(f))
1009    }
1010
1011    /// Invoke the bridge directly — a thin trampoline over the inner
1012    /// `Fn`. The production path goes through the Lua runtime, but this
1013    /// stays `pub` so unit tests can exercise the primitive directly.
1014    pub fn call(&self, arg: serde_json::Value) -> Result<serde_json::Value, String> {
1015        (self.0)(arg)
1016    }
1017}
1018
1019/// Carrier type for Lua script sources. Paths are not required — a
1020/// source string plus an identifying label is all it holds.
1021///
1022/// Callers bring in the source (via `include_str!` or similar) and
1023/// register it with the factory through
1024/// [`LuaInProcessSpawnerFactory::register_lua`].
1025#[derive(Clone)]
1026pub struct LuaScriptSource {
1027    /// The Lua chunk source.
1028    pub source: String,
1029    /// Label used in error messages — typically the script's logical id
1030    /// (for example `"patch_spawner.lua"`).
1031    pub label: String,
1032}
1033
1034impl LuaScriptSource {
1035    /// Wrap a Lua chunk source and its error-message label.
1036    pub fn new(source: impl Into<String>, label: impl Into<String>) -> Self {
1037        Self {
1038            source: source.into(),
1039            label: label.into(),
1040        }
1041    }
1042}
1043
1044impl LuaInProcessSpawnerFactory {
1045    /// Start with no registered scripts and no host bridges.
1046    pub fn new() -> Self {
1047        Self {
1048            registry: HashMap::new(),
1049            bridges: HashMap::new(),
1050        }
1051    }
1052
1053    /// Register a host bridge. Subsequent `register_lua` calls snapshot
1054    /// the current bridge set.
1055    ///
1056    /// Ordering rule: register bridges first, then call `register_lua`;
1057    /// bridges added after `register_lua` will not be visible to that
1058    /// script.
1059    pub fn with_bridge(mut self, name: impl Into<String>, bridge: HostBridge) -> Self {
1060        self.bridges.insert(name.into(), bridge);
1061        self
1062    }
1063
1064    /// Register a **Lua-eval Worker** under `fn_id`.
1065    ///
1066    /// Each dispatch spins up a fresh `mlua::Lua` VM, injects globals
1067    /// (`_PROMPT` / `_AGENT` / `_TASK_ID` / `_ATTEMPT` / `_CTX` — the last
1068    /// is `_PROMPT` parsed as JSON, or `nil` if that fails), evaluates
1069    /// the script, and marshals the returned table into a `WorkerResult`.
1070    ///
1071    /// Marshalling rules for the return value:
1072    /// - `{ value = ..., ok = bool }` → `WorkerResult.value` /
1073    ///   `WorkerResult.ok` verbatim.
1074    /// - Anything else → `value = <returned value>`, `ok = true`.
1075    ///
1076    /// Execution runs on `tokio::task::spawn_blocking` because `mlua::Lua`
1077    /// is `!Send` and needs to stay away from the tokio async context.
1078    /// Host bridges (the Lua-to-Rust callback path) previously registered
1079    /// with [`Self::with_bridge`] are snapshotted at call time and
1080    /// injected into every dispatch inside `run_lua_worker`.
1081    pub fn register_lua(mut self, fn_id: impl Into<String>, source: LuaScriptSource) -> Self {
1082        let source = Arc::new(source);
1083        let bridges = Arc::new(self.bridges.clone());
1084        let wrapped: WorkerFn = Arc::new(move |inv| {
1085            let source = source.clone();
1086            let bridges = bridges.clone();
1087            Box::pin(run_lua_worker(source, bridges, inv))
1088        });
1089        self.registry.insert(fn_id.into(), wrapped);
1090        self
1091    }
1092}
1093
1094/// Body of a single Lua-eval invocation (called from `register_lua`).
1095async fn run_lua_worker(
1096    source: Arc<LuaScriptSource>,
1097    bridges: Arc<HashMap<String, HostBridge>>,
1098    inv: crate::worker::adapter::WorkerInvocation,
1099) -> Result<crate::worker::adapter::WorkerResult, crate::worker::adapter::WorkerError> {
1100    use crate::worker::adapter::WorkerError;
1101    use mlua::LuaSerdeExt;
1102
1103    let label = source.label.clone();
1104    let outcome =
1105        tokio::task::spawn_blocking(move || -> Result<(serde_json::Value, bool), String> {
1106            let lua = mlua::Lua::new();
1107            let g = lua.globals();
1108
1109            // 1. Base globals.
1110            g.set("_PROMPT", inv.prompt.clone())
1111                .map_err(|e| format!("set _PROMPT: {e}"))?;
1112            g.set("_AGENT", inv.agent.clone())
1113                .map_err(|e| format!("set _AGENT: {e}"))?;
1114            g.set("_TASK_ID", inv.task_id.to_string())
1115                .map_err(|e| format!("set _TASK_ID: {e}"))?;
1116            g.set("_ATTEMPT", inv.attempt as i64)
1117                .map_err(|e| format!("set _ATTEMPT: {e}"))?;
1118
1119            // 2. _CTX = JSON parse(_PROMPT); nil on parse failure (co-exists with the plain-string prompt path).
1120            if let Ok(json_val) = serde_json::from_str::<serde_json::Value>(&inv.prompt) {
1121                let lua_val = lua
1122                    .to_value(&json_val)
1123                    .map_err(|e| format!("_CTX to_value: {e}"))?;
1124                g.set("_CTX", lua_val)
1125                    .map_err(|e| format!("set _CTX: {e}"))?;
1126            }
1127
1128            // 3. Inject the host bridge (Lua can call `host.<name>(arg)`).
1129            if !bridges.is_empty() {
1130                let host = lua
1131                    .create_table()
1132                    .map_err(|e| format!("create host table: {e}"))?;
1133                for (name, bridge) in bridges.iter() {
1134                    let bridge = bridge.clone();
1135                    let bname = name.clone();
1136                    let f = lua
1137                        .create_function(move |lua, arg: mlua::Value| {
1138                            let json_arg: serde_json::Value = lua.from_value(arg).map_err(|e| {
1139                                mlua::Error::external(format!("bridge {bname} arg → json: {e}"))
1140                            })?;
1141                            let result_json =
1142                                bridge.call(json_arg).map_err(mlua::Error::external)?;
1143                            lua.to_value(&result_json).map_err(|e| {
1144                                mlua::Error::external(format!("bridge {bname} ret → lua: {e}"))
1145                            })
1146                        })
1147                        .map_err(|e| format!("create_function {name}: {e}"))?;
1148                    host.set(name.as_str(), f)
1149                        .map_err(|e| format!("host.{name} set: {e}"))?;
1150                }
1151                g.set("host", host).map_err(|e| format!("set host: {e}"))?;
1152            }
1153
1154            // 4. eval
1155            let result: mlua::Value = lua
1156                .load(&source.source)
1157                .set_name(&source.label)
1158                .eval()
1159                .map_err(|e| format!("lua eval [{}]: {e}", source.label))?;
1160
1161            // 5. Marshal: shape `{ value=..., ok=true }` or raw value.
1162            let json_result: serde_json::Value = lua
1163                .from_value(result)
1164                .map_err(|e| format!("lua → json [{}]: {e}", source.label))?;
1165
1166            let (value, ok) = match &json_result {
1167                serde_json::Value::Object(map)
1168                    if map.contains_key("value") || map.contains_key("ok") =>
1169                {
1170                    let ok = map.get("ok").and_then(|v| v.as_bool()).unwrap_or(true);
1171                    let value = map.get("value").cloned().unwrap_or(json_result.clone());
1172                    (value, ok)
1173                }
1174                _ => (json_result, true),
1175            };
1176            Ok((value, ok))
1177        })
1178        .await
1179        .map_err(|e| WorkerError::Failed(format!("spawn_blocking join [{label}]: {e}")))?
1180        .map_err(WorkerError::Failed)?;
1181
1182    Ok(crate::worker::adapter::WorkerResult {
1183        value: outcome.0,
1184        ok: outcome.1,
1185    })
1186}
1187
1188impl Default for LuaInProcessSpawnerFactory {
1189    fn default() -> Self {
1190        Self::new()
1191    }
1192}
1193
1194impl SpawnerFactoryKind for LuaInProcessSpawnerFactory {
1195    const KIND: AgentKind = AgentKind::Lua;
1196    type Worker = LuaWorker;
1197}
1198
1199impl SpawnerFactory for LuaInProcessSpawnerFactory {
1200    fn build(
1201        &self,
1202        agent_def: &AgentDef,
1203        _hint: Option<&Value>,
1204    ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
1205        // Inline `spec.source` (a Lua chunk carried by the BP itself) takes
1206        // precedence over `spec.fn_id`. This is the path a BP author uses to
1207        // ship a deterministic Lua gate without pre-registering it with the
1208        // factory — the plumbing (`run_lua_worker` / `LuaScriptSource`) is
1209        // the same, only the entry point differs.
1210        if let Some(source) = agent_def.spec.get("source").and_then(|v| v.as_str()) {
1211            let label = agent_def
1212                .spec
1213                .get("label")
1214                .and_then(|v| v.as_str())
1215                .map(str::to_string)
1216                .unwrap_or_else(|| format!("{}.lua", agent_def.name));
1217            let script = Arc::new(LuaScriptSource::new(source.to_string(), label));
1218            let bridges = Arc::new(self.bridges.clone());
1219            let wrapped: WorkerFn = Arc::new(move |inv| {
1220                let source = script.clone();
1221                let bridges = bridges.clone();
1222                Box::pin(run_lua_worker(source, bridges, inv))
1223            });
1224            let mut sp: InProcSpawner<LuaWorker> = InProcSpawner::<LuaWorker>::typed();
1225            sp.registry.insert(agent_def.name.to_string(), wrapped);
1226            return Ok(Arc::new(sp));
1227        }
1228        build_inproc_from_registry::<LuaWorker>(&self.registry, agent_def, "lua")
1229    }
1230}
1231
1232/// Factory for `AgentKind::RustFn`. At `build` time it looks the `fn_id`
1233/// up in its internal registry and returns an [`InProcSpawner`] with the
1234/// Rust closure `WorkerFn` registered under `agent_name`.
1235///
1236/// Naming convention: `<WorkerIMPL><AdapterType>SpawnerFactory` (RustFn
1237/// worker on InProcess adapter). Sibling to
1238/// [`LuaInProcessSpawnerFactory`] — the Lua-worker half of the same
1239/// split.
1240///
1241/// Spec shape:
1242/// ```jsonc
1243/// { "fn_id": "echo" }     // Rust closure id pre-registered with the factory
1244/// ```
1245pub struct RustFnInProcessSpawnerFactory {
1246    registry: HashMap<String, WorkerFn>,
1247}
1248
1249impl RustFnInProcessSpawnerFactory {
1250    /// Start with no registered closures.
1251    pub fn new() -> Self {
1252        Self {
1253            registry: HashMap::new(),
1254        }
1255    }
1256
1257    /// Register a Rust closure `WorkerFn` under `fn_id`, wrapping it so
1258    /// it matches the `WorkerFn` signature (boxed, pinned future).
1259    pub fn register_fn<F, Fut>(mut self, fn_id: impl Into<String>, f: F) -> Self
1260    where
1261        F: Fn(crate::worker::adapter::WorkerInvocation) -> Fut + Send + Sync + 'static,
1262        Fut: std::future::Future<
1263                Output = Result<
1264                    crate::worker::adapter::WorkerResult,
1265                    crate::worker::adapter::WorkerError,
1266                >,
1267            > + Send
1268            + 'static,
1269    {
1270        let f = Arc::new(f);
1271        let wrapped: WorkerFn = Arc::new(move |inv| {
1272            let f = f.clone();
1273            Box::pin(f(inv))
1274        });
1275        self.registry.insert(fn_id.into(), wrapped);
1276        self
1277    }
1278}
1279
1280impl Default for RustFnInProcessSpawnerFactory {
1281    fn default() -> Self {
1282        Self::new()
1283    }
1284}
1285
1286impl SpawnerFactoryKind for RustFnInProcessSpawnerFactory {
1287    const KIND: AgentKind = AgentKind::RustFn;
1288    type Worker = RustFnWorker;
1289}
1290
1291impl SpawnerFactory for RustFnInProcessSpawnerFactory {
1292    fn build(
1293        &self,
1294        agent_def: &AgentDef,
1295        _hint: Option<&Value>,
1296    ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
1297        build_inproc_from_registry::<RustFnWorker>(&self.registry, agent_def, "rust_fn")
1298    }
1299}
1300
1301/// Shared build helper used by both the Lua and the RustFn factories —
1302/// look `spec.fn_id` up in the registry and return an `InProcSpawner`.
1303/// The generic type parameter `W` fixes the per-kind Worker concrete
1304/// type at the type level (the build-site half of the trait's
1305/// associated-type binding across the four-layer cascade).
1306fn build_inproc_from_registry<W>(
1307    registry: &HashMap<String, WorkerFn>,
1308    agent_def: &AgentDef,
1309    kind_label: &str,
1310) -> Result<Arc<dyn SpawnerAdapter>, CompileError>
1311where
1312    W: crate::worker::Worker + From<crate::worker::WorkerJoinHandler> + Send + Sync + 'static,
1313{
1314    let agent_name = &agent_def.name;
1315    let spec = &agent_def.spec;
1316    let invalid = |msg: String| CompileError::InvalidSpec {
1317        name: agent_name.to_string(),
1318        msg,
1319    };
1320    let fn_id = spec
1321        .get("fn_id")
1322        .and_then(|v| v.as_str())
1323        .ok_or_else(|| invalid(format!("{kind_label} spec: 'fn_id' (string) required")))?;
1324    let f = registry
1325        .get(fn_id)
1326        .cloned()
1327        .ok_or_else(|| invalid(format!("fn_id '{fn_id}' not registered in factory")))?;
1328    let mut sp: InProcSpawner<W> = InProcSpawner::<W>::typed();
1329    // Register under `agent_name` (the flow's `Step.ref`). Both
1330    // `CompiledAgentTable` and the `InProcSpawner` look the function up
1331    // by name, so the same key is needed at both layers.
1332    sp.registry.insert(agent_name.to_string(), f);
1333    Ok(Arc::new(sp))
1334}
1335
1336/// Concrete Worker type for the Lua kind — a handle to a Lua-eval task
1337/// inside an mlua VM. Embeds a `WorkerJoinHandler`. Reserved as the home
1338/// for future Lua-specific extensions (an mlua VM cancellation
1339/// mechanism, Lua-side error type retention, and so on).
1340pub struct LuaWorker {
1341    /// The join handle / cancellation token for the underlying task.
1342    pub handler: crate::worker::WorkerJoinHandler,
1343}
1344
1345impl From<crate::worker::WorkerJoinHandler> for LuaWorker {
1346    fn from(handler: crate::worker::WorkerJoinHandler) -> Self {
1347        Self { handler }
1348    }
1349}
1350
1351#[async_trait::async_trait]
1352impl crate::worker::Worker for LuaWorker {
1353    fn id(&self) -> &crate::types::WorkerId {
1354        &self.handler.worker_id
1355    }
1356    fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
1357        self.handler.cancel.clone()
1358    }
1359    async fn join(self: Box<Self>) -> Result<(), crate::worker::adapter::WorkerError> {
1360        self.handler.await_completion().await
1361    }
1362}
1363
1364/// Concrete Worker type for the RustFn kind — a handle to a task that
1365/// directly calls a Rust closure. Embeds a `WorkerJoinHandler`. Being a
1366/// pure function, there is minimal kind-specific extension surface here;
1367/// the primary purpose is to nail down the type binding.
1368pub struct RustFnWorker {
1369    /// The join handle / cancellation token for the underlying task.
1370    pub handler: crate::worker::WorkerJoinHandler,
1371}
1372
1373impl From<crate::worker::WorkerJoinHandler> for RustFnWorker {
1374    fn from(handler: crate::worker::WorkerJoinHandler) -> Self {
1375        Self { handler }
1376    }
1377}
1378
1379#[async_trait::async_trait]
1380impl crate::worker::Worker for RustFnWorker {
1381    fn id(&self) -> &crate::types::WorkerId {
1382        &self.handler.worker_id
1383    }
1384    fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
1385        self.handler.cancel.clone()
1386    }
1387    async fn join(self: Box<Self>) -> Result<(), crate::worker::adapter::WorkerError> {
1388        self.handler.await_completion().await
1389    }
1390}
1391
1392/// Factory for `AgentKind::Operator`. Looks up the `Arc<dyn Operator>`
1393/// pre-registered under `spec.operator_ref` and wraps it in an
1394/// `OperatorSpawner`. Also resolves `AgentDef.profile.worker_binding` into
1395/// a `WorkerBinding` at compile time and fails loud (`CompileError::InvalidSpec`)
1396/// when the resolved operator's `Operator::requires_worker_binding` is `true`
1397/// and no binding was declared.
1398///
1399/// Spec shape:
1400/// ```jsonc
1401/// { "operator_ref": "main_ai" }     // Operator id pre-registered with the factory
1402/// ```
1403///
1404/// # Split of responsibilities with `OperatorDelegateMiddleware`
1405///
1406/// The two axes exist for different reasons:
1407///
1408/// - **This factory (`OperatorSpawnerFactory` → `OperatorSpawner`) — the
1409///   AgentSpec axis.** Bakes a separate Operator backend into each
1410///   `AgentDef`. A `kind = Operator` `AgentDef` names its backend through
1411///   `spec.operator_ref`; at `compile()` time the `Arc<dyn Operator>` is
1412///   baked into `routes[agent_name]`. Because the `agent.md` loader
1413///   (`agent_md_loader`) defaults `kind` to `Operator`, agents that flow
1414///   in through agent-profiles land here.
1415///
1416/// - **`OperatorDelegateMiddleware` — the Blueprint-global (session)
1417///   axis.** Delegates every agent to the same Operator backend. At
1418///   session-attach time you call `engine.register_operator(id, op)`
1419///   plus `attach_with_ids(.., operator_backend_id = Some(id))` to bind
1420///   it session-wide, and declare
1421///   `spawner_hints.layers = ["operator_delegate"]` to opt in. `ctx.agent`
1422///   is ignored; the operator handles every spawn in that session (a
1423///   MainAI-wide driver, a human-wide console, that sort of thing).
1424///
1425/// # Exclusivity (a double fire is structurally impossible)
1426///
1427/// When both are effective — the hint is declared, the session has an
1428/// operator backend, **and** the Blueprint has a `kind = Operator`
1429/// `AgentDef` — `OperatorDelegateMiddleware` sits at the outer end of
1430/// the stack and **completely bypasses** `inner.spawn`. The
1431/// `OperatorSpawner` is never reached, so under those conditions this
1432/// factory's routes entry is inert. This is not a double fire — the
1433/// session axis is overriding the agent axis. Consistent usage means
1434/// picking one axis per use case.
1435///
1436/// Interior mutability is provided by an `Arc<RwLock>`. Even after the
1437/// factory has been stored as `Arc<dyn SpawnerFactory>` in
1438/// `SpawnerRegistry`, a caller holding an `Arc` clone can still add
1439/// Operator backends dynamically via `register_operator(&self, id, op)`.
1440/// Typical uses: registering a `WSOperatorSession` under the session id
1441/// on WebSocket connect, binding agents that arrive via the `agent.md`
1442/// loader to arbitrary backends, and so on. `build()` performs a
1443/// `read()` lookup each time.
1444pub struct OperatorSpawnerFactory {
1445    operators: Arc<std::sync::RwLock<HashMap<String, Arc<dyn Operator>>>>,
1446}
1447
1448impl OperatorSpawnerFactory {
1449    /// Start with no registered Operator backends.
1450    pub fn new() -> Self {
1451        Self {
1452            operators: Arc::new(std::sync::RwLock::new(HashMap::new())),
1453        }
1454    }
1455
1456    /// Register an Operator backend dynamically through `&self`.
1457    /// Overwrites are allowed — later wins. Callers can still reach this
1458    /// after the factory has been stored as `Arc<dyn SpawnerFactory>` in
1459    /// `SpawnerRegistry`, as long as they hold an `Arc` clone; interior
1460    /// mutability is provided by the inner `RwLock`.
1461    pub fn register_operator(&self, id: impl Into<String>, op: Arc<dyn Operator>) -> &Self {
1462        self.operators
1463            .write()
1464            .expect("OperatorSpawnerFactory.operators RwLock poisoned")
1465            .insert(id.into(), op);
1466        self
1467    }
1468
1469    /// Dynamically unregister an id (used to clean up when a WebSocket
1470    /// disconnects, for example). A missing id is a no-op.
1471    pub fn unregister_operator(&self, id: &str) -> &Self {
1472        self.operators
1473            .write()
1474            .expect("OperatorSpawnerFactory.operators RwLock poisoned")
1475            .remove(id);
1476        self
1477    }
1478}
1479
1480impl Default for OperatorSpawnerFactory {
1481    fn default() -> Self {
1482        Self::new()
1483    }
1484}
1485
1486impl SpawnerFactoryKind for OperatorSpawnerFactory {
1487    const KIND: AgentKind = AgentKind::Operator;
1488    type Worker = crate::operator::OperatorWorker;
1489}
1490
1491impl SpawnerFactory for OperatorSpawnerFactory {
1492    fn build(
1493        &self,
1494        agent_def: &AgentDef,
1495        _hint: Option<&Value>,
1496    ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
1497        let agent_name = &agent_def.name;
1498        let spec = &agent_def.spec;
1499        // Bake AgentDef.profile.system_prompt into the OperatorSpawner at compile time.
1500        // `Some` → adopted first at spawn time; `None` → falls back to fetch_prompt (initial_directive).
1501        // Fallback path. Sibling: AgentBlockInProcessSpawnerFactory
1502        // (agent_block/runtime.rs) does the same compile-time bake by stuffing
1503        // the profile into BlockConfig.context.
1504        let system_prompt = agent_def.profile.as_ref().map(|p| p.system_prompt.clone());
1505        let invalid = |msg: String| CompileError::InvalidSpec {
1506            name: agent_name.to_string(),
1507            msg,
1508        };
1509        let op_ref = spec
1510            .get("operator_ref")
1511            .and_then(|v| v.as_str())
1512            .ok_or_else(|| invalid("operator spec: 'operator_ref' (string) required".into()))?;
1513        let operators = self
1514            .operators
1515            .read()
1516            .expect("OperatorSpawnerFactory.operators RwLock poisoned");
1517        let op = operators.get(op_ref).cloned().ok_or_else(|| {
1518            let mut names: Vec<String> = operators.keys().cloned().collect();
1519            names.sort();
1520            let names_list = if names.is_empty() {
1521                "<none>".to_string()
1522            } else {
1523                names.join(", ")
1524            };
1525            invalid(format!(
1526                "operator_ref '{op_ref}' not registered in factory. \
1527                 Registered sids: [{names_list}]. \
1528                 Hint: call mse_operator_join(roles=[...]) to mint the sid first."
1529            ))
1530        })?;
1531        drop(operators);
1532
1533        // Resolve the Blueprint-baked worker binding from
1534        // `AgentDef.profile.worker_binding` — the SoT for the
1535        // declaration↔executor binding (see `WorkerBinding` doc). Fail
1536        // loud at compile time when the operator backend requires one
1537        // and the Blueprint didn't declare it; this is a compile-time
1538        // gate, not a runtime guess.
1539        let worker_binding = agent_def
1540            .profile
1541            .as_ref()
1542            .and_then(|p| p.worker_binding.as_ref())
1543            .map(|variant| WorkerBinding {
1544                variant: variant.clone(),
1545                tools: agent_def
1546                    .profile
1547                    .as_ref()
1548                    .map(|p| p.tools.clone())
1549                    .unwrap_or_default(),
1550            });
1551        if op.requires_worker_binding() && worker_binding.is_none() {
1552            // Issue #9: the two Blueprint authoring paths (direct JSON
1553            // and `$agent_md` file ref) both land here. Old message
1554            // pointed only at the `.md` frontmatter, which was
1555            // confusing for authors on the JSON-direct path.
1556            return Err(invalid(
1557                "profile.worker_binding is required for this operator backend. \
1558                 Fix by either: \
1559                 (a) if authoring the Blueprint JSON directly, add \
1560                 `agents[N].profile.worker_binding: \"<subagent-type>\"` \
1561                 to the JSON literal; or \
1562                 (b) if using an $agent_md file ref, add \
1563                 `worker_binding: <subagent-type>` to the agent .md frontmatter."
1564                    .into(),
1565            ));
1566        }
1567        Ok(Arc::new(OperatorSpawner::new(
1568            op,
1569            system_prompt,
1570            worker_binding,
1571        )))
1572    }
1573}
1574
1575#[cfg(test)]
1576mod operator_spawner_factory_worker_binding_tests {
1577    use super::*;
1578    use crate::blueprint::AgentProfile;
1579    use crate::core::ctx::Ctx;
1580    use crate::types::CapToken;
1581    use crate::worker::adapter::{WorkerError, WorkerResult};
1582
1583    /// Minimal `Operator` stub whose `requires_worker_binding` is
1584    /// configurable — enough to exercise the compile-time fail-loud gate
1585    /// without standing up a real backend (e.g. `WSOperatorSession`,
1586    /// which lives in a downstream crate).
1587    struct StubOperator {
1588        requires_binding: bool,
1589    }
1590
1591    #[async_trait]
1592    impl Operator for StubOperator {
1593        async fn execute(
1594            &self,
1595            _ctx: &Ctx,
1596            _system: Option<String>,
1597            _prompt: Value,
1598            _worker: Option<WorkerBinding>,
1599            _worker_token: CapToken,
1600        ) -> Result<WorkerResult, WorkerError> {
1601            Ok(WorkerResult {
1602                value: Value::Null,
1603                ok: true,
1604            })
1605        }
1606
1607        fn requires_worker_binding(&self) -> bool {
1608            self.requires_binding
1609        }
1610    }
1611
1612    fn agent_def_with(profile: Option<AgentProfile>) -> AgentDef {
1613        AgentDef {
1614            name: "test-agent".to_string(),
1615            kind: AgentKind::Operator,
1616            spec: serde_json::json!({ "operator_ref": "op1" }),
1617            profile,
1618            meta: None,
1619            runner: None,
1620            runner_ref: None,
1621            verdict: None,
1622        }
1623    }
1624
1625    #[test]
1626    fn build_fails_loud_when_binding_required_but_absent() {
1627        let factory = OperatorSpawnerFactory::new();
1628        factory.register_operator(
1629            "op1",
1630            Arc::new(StubOperator {
1631                requires_binding: true,
1632            }) as Arc<dyn Operator>,
1633        );
1634        let def = agent_def_with(Some(AgentProfile::default()));
1635        match factory.build(&def, None) {
1636            Err(CompileError::InvalidSpec { name, msg }) => {
1637                assert_eq!(name, "test-agent");
1638                assert!(
1639                    msg.contains("worker_binding is required"),
1640                    "unexpected message: {msg}"
1641                );
1642                // Issue #9: the message must be actionable for both
1643                // authoring paths — the JSON-direct hint and the
1644                // $agent_md hint both surface.
1645                assert!(
1646                    msg.contains("agents[N].profile.worker_binding"),
1647                    "message missing JSON-direct hint (issue #9): {msg}"
1648                );
1649                assert!(
1650                    msg.contains("agent .md frontmatter"),
1651                    "message missing $agent_md hint: {msg}"
1652                );
1653            }
1654            Err(other) => panic!("expected InvalidSpec, got: {other:?}"),
1655            Ok(_) => panic!("expected compile-time failure, got Ok"),
1656        }
1657    }
1658
1659    #[test]
1660    fn build_succeeds_when_binding_required_and_present() {
1661        let factory = OperatorSpawnerFactory::new();
1662        factory.register_operator(
1663            "op1",
1664            Arc::new(StubOperator {
1665                requires_binding: true,
1666            }) as Arc<dyn Operator>,
1667        );
1668        let profile = AgentProfile {
1669            worker_binding: Some("mse-worker-coder".to_string()),
1670            tools: vec!["Read".to_string(), "Edit".to_string()],
1671            ..Default::default()
1672        };
1673        let def = agent_def_with(Some(profile));
1674        assert!(
1675            factory.build(&def, None).is_ok(),
1676            "expected Ok when worker_binding is declared"
1677        );
1678    }
1679
1680    #[test]
1681    fn build_succeeds_when_binding_not_required_and_absent() {
1682        let factory = OperatorSpawnerFactory::new();
1683        factory.register_operator(
1684            "op1",
1685            Arc::new(StubOperator {
1686                requires_binding: false,
1687            }) as Arc<dyn Operator>,
1688        );
1689        let def = agent_def_with(Some(AgentProfile::default()));
1690        assert!(
1691            factory.build(&def, None).is_ok(),
1692            "backends that don't require a binding must not be gated by its absence"
1693        );
1694    }
1695}
1696
1697// ─── LuaInProcessSpawnerFactory: inline `spec.source` support ─────────────
1698//
1699// Issue `ab3d1145`: BPs served by `mse serve` couldn't declare `kind: lua`
1700// without pre-registering a `fn_id` on the factory. These tests cover the
1701// new inline path — `spec.source = "<lua chunk>"` (optionally with `label`)
1702// wraps a fresh `LuaScriptSource` at `build` time and runs it through the
1703// same `run_lua_worker` plumbing as the registry path.
1704#[cfg(test)]
1705mod lua_inline_source_tests {
1706    use super::*;
1707    use crate::types::{CapToken, Role, StepId};
1708
1709    fn agent(name: &str, spec: Value) -> AgentDef {
1710        AgentDef {
1711            name: name.to_string(),
1712            kind: AgentKind::Lua,
1713            spec,
1714            profile: None,
1715            meta: None,
1716            runner: None,
1717            runner_ref: None,
1718            verdict: None,
1719        }
1720    }
1721
1722    fn test_invocation(prompt: &str) -> crate::worker::adapter::WorkerInvocation {
1723        crate::worker::adapter::WorkerInvocation {
1724            token: CapToken {
1725                agent_id: "a".into(),
1726                role: Role::Worker,
1727                scopes: vec!["*".into()],
1728                issued_at: 0,
1729                expire_at: u64::MAX / 2,
1730                max_uses: None,
1731                nonce: "test-nonce".into(),
1732                sig_hex: "".into(),
1733            },
1734            task_id: StepId::parse("ST-test").expect("StepId parse"),
1735            attempt: 1,
1736            agent: "g".into(),
1737            prompt: prompt.into(),
1738            sink: None,
1739            cancel_token: None,
1740        }
1741    }
1742
1743    #[test]
1744    fn build_accepts_inline_source_without_pre_registration() {
1745        let factory = LuaInProcessSpawnerFactory::new();
1746        let def = agent(
1747            "g",
1748            serde_json::json!({ "source": "return { value = 42, ok = true }" }),
1749        );
1750        assert!(
1751            factory.build(&def, None).is_ok(),
1752            "inline spec.source must build without a pre-registered fn_id"
1753        );
1754    }
1755
1756    #[test]
1757    fn build_rejects_when_neither_source_nor_fn_id_is_present() {
1758        let factory = LuaInProcessSpawnerFactory::new();
1759        let def = agent("g", serde_json::json!({}));
1760        match factory.build(&def, None) {
1761            Err(CompileError::InvalidSpec { msg, .. }) => {
1762                assert!(
1763                    msg.contains("fn_id"),
1764                    "empty spec must still surface the fn_id-required message: {msg}"
1765                );
1766            }
1767            Err(other) => panic!("expected InvalidSpec, got a different CompileError: {other}"),
1768            // `SpawnerAdapter` is not Debug, so we can't `unwrap_err()` /
1769            // pattern-print the Ok arm — describe the mismatch directly.
1770            Ok(_) => panic!("expected InvalidSpec, got Ok(SpawnerAdapter)"),
1771        }
1772    }
1773
1774    /// The inline path shares `run_lua_worker` with the registry path, so
1775    /// exercising the marshaller once through it is enough to prove the
1776    /// wrap is faithful.
1777    #[tokio::test]
1778    async fn inline_source_evaluates_and_marshals_result() {
1779        let source =
1780            LuaScriptSource::new("return { value = _PROMPT .. '!', ok = true }", "smoke.lua");
1781        let out = run_lua_worker(
1782            std::sync::Arc::new(source),
1783            std::sync::Arc::new(HashMap::new()),
1784            test_invocation("hello"),
1785        )
1786        .await
1787        .expect("lua worker ok");
1788        assert_eq!(out.value, serde_json::json!("hello!"));
1789        assert!(out.ok);
1790    }
1791
1792    #[tokio::test]
1793    async fn inline_source_can_signal_agent_level_failure() {
1794        // Deterministic gate pattern: return `ok = false` to flip the
1795        // dispatch outcome to `Blocked` (the flow.ir Try catch path).
1796        let source = LuaScriptSource::new("return { value = 'nope', ok = false }", "gate.lua");
1797        let out = run_lua_worker(
1798            std::sync::Arc::new(source),
1799            std::sync::Arc::new(HashMap::new()),
1800            test_invocation("input"),
1801        )
1802        .await
1803        .expect("lua worker ok");
1804        assert_eq!(out.value, serde_json::json!("nope"));
1805        assert!(!out.ok);
1806    }
1807}
1808
1809// ─── GH #21 Phase 2: `Blueprint.metas` / `AgentMeta.meta_ref` / static
1810// `$step_meta.ref` compile-time validation ─────────────────────────────────
1811#[cfg(test)]
1812mod meta_ref_validation_tests {
1813    use super::*;
1814    use crate::blueprint::{AgentMeta, MetaDef};
1815    use crate::worker::adapter::WorkerResult;
1816
1817    fn registry_with_echo() -> SpawnerRegistry {
1818        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1819            Ok(WorkerResult {
1820                value: Value::String(inv.prompt),
1821                ok: true,
1822            })
1823        });
1824        let mut reg = SpawnerRegistry::new();
1825        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
1826        reg
1827    }
1828
1829    fn rustfn_agent(name: &str) -> AgentDef {
1830        AgentDef {
1831            name: name.to_string(),
1832            kind: AgentKind::RustFn,
1833            spec: serde_json::json!({ "fn_id": "echo" }),
1834            profile: None,
1835            meta: None,
1836            runner: None,
1837            runner_ref: None,
1838            verdict: None,
1839        }
1840    }
1841
1842    fn simple_flow(agent_ref: &str, in_: Expr) -> FlowNode {
1843        FlowNode::Step {
1844            ref_: agent_ref.to_string(),
1845            in_,
1846            out: Expr::Path {
1847                at: "$.output".parse().expect("literal test path: $.output"),
1848            },
1849        }
1850    }
1851
1852    fn minimal_bp(agents: Vec<AgentDef>, metas: Vec<MetaDef>, flow: FlowNode) -> Blueprint {
1853        Blueprint {
1854            schema_version: crate::blueprint::current_schema_version(),
1855            id: "meta-ref-ut".into(),
1856            flow,
1857            agents,
1858            operators: vec![],
1859            metas,
1860            hints: Default::default(),
1861            strategy: Default::default(),
1862            metadata: BlueprintMetadata::default(),
1863            spawner_hints: Default::default(),
1864            default_agent_kind: AgentKind::Operator,
1865            default_operator_kind: None,
1866            default_init_ctx: None,
1867            default_agent_ctx: None,
1868            default_context_policy: None,
1869            projection_placement: None,
1870            audits: vec![],
1871            degradation_policy: None,
1872            runners: vec![],
1873            default_runner: None,
1874        }
1875    }
1876
1877    #[test]
1878    fn valid_meta_ref_compiles() {
1879        let mut agent = rustfn_agent("worker");
1880        agent.meta = Some(AgentMeta {
1881            meta_ref: Some("shared".to_string()),
1882            ..Default::default()
1883        });
1884        let bp = minimal_bp(
1885            vec![agent],
1886            vec![MetaDef {
1887                name: "shared".into(),
1888                ctx: serde_json::json!({ "k": "v" }),
1889            }],
1890            simple_flow(
1891                "worker",
1892                Expr::Path {
1893                    at: "$.input".parse().expect("literal test path: $.input"),
1894                },
1895            ),
1896        );
1897        let compiler = Compiler::new(registry_with_echo());
1898        assert!(
1899            compiler.compile(&bp).is_ok(),
1900            "a resolvable AgentMeta.meta_ref must compile"
1901        );
1902    }
1903
1904    #[test]
1905    fn unknown_agent_meta_ref_is_unresolved_meta_ref() {
1906        let mut agent = rustfn_agent("worker");
1907        agent.meta = Some(AgentMeta {
1908            meta_ref: Some("missing".to_string()),
1909            ..Default::default()
1910        });
1911        let bp = minimal_bp(
1912            vec![agent],
1913            vec![],
1914            simple_flow(
1915                "worker",
1916                Expr::Path {
1917                    at: "$.input".parse().expect("literal test path: $.input"),
1918                },
1919            ),
1920        );
1921        let compiler = Compiler::new(registry_with_echo());
1922        match compiler.compile(&bp) {
1923            Err(CompileError::UnresolvedMetaRef {
1924                where_,
1925                meta_ref,
1926                defined,
1927            }) => {
1928                assert!(
1929                    where_.contains("worker"),
1930                    "where_ must name the agent: {where_}"
1931                );
1932                assert_eq!(meta_ref, "missing");
1933                assert!(defined.is_empty());
1934            }
1935            Err(other) => {
1936                panic!("expected UnresolvedMetaRef, got a different CompileError: {other}")
1937            }
1938            Ok(_) => panic!("expected compile-time failure, got Ok"),
1939        }
1940    }
1941
1942    #[test]
1943    fn unknown_static_step_meta_ref_in_lit_is_unresolved_meta_ref() {
1944        let agent = rustfn_agent("worker");
1945        let in_ = Expr::Lit {
1946            value: serde_json::json!({ "$step_meta": { "ref": "missing" }, "$in": "go" }),
1947        };
1948        let bp = minimal_bp(vec![agent], vec![], simple_flow("worker", in_));
1949        let compiler = Compiler::new(registry_with_echo());
1950        match compiler.compile(&bp) {
1951            Err(CompileError::UnresolvedMetaRef {
1952                where_, meta_ref, ..
1953            }) => {
1954                assert!(
1955                    where_.contains("worker"),
1956                    "where_ must name the offending step: {where_}"
1957                );
1958                assert_eq!(meta_ref, "missing");
1959            }
1960            Err(other) => {
1961                panic!("expected UnresolvedMetaRef, got a different CompileError: {other}")
1962            }
1963            Ok(_) => panic!("expected compile-time failure, got Ok"),
1964        }
1965    }
1966
1967    #[test]
1968    fn path_op_input_with_no_static_envelope_compiles_fine() {
1969        let agent = rustfn_agent("worker");
1970        let bp = minimal_bp(
1971            vec![agent],
1972            vec![],
1973            simple_flow(
1974                "worker",
1975                Expr::Path {
1976                    at: "$.input".parse().expect("literal test path: $.input"),
1977                },
1978            ),
1979        );
1980        let compiler = Compiler::new(registry_with_echo());
1981        assert!(
1982            compiler.compile(&bp).is_ok(),
1983            "a non-Lit Step.in must not trigger the best-effort static $step_meta check"
1984        );
1985    }
1986}
1987
1988// ─── GH #34: `Blueprint.audits[].agent` compile-time validation ────────────
1989#[cfg(test)]
1990mod audit_agent_validation_tests {
1991    use super::*;
1992    use crate::worker::adapter::WorkerResult;
1993    use mlua_swarm_schema::{AuditDef, AuditMode};
1994
1995    fn registry_with_echo() -> SpawnerRegistry {
1996        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1997            Ok(WorkerResult {
1998                value: Value::String(inv.prompt),
1999                ok: true,
2000            })
2001        });
2002        let mut reg = SpawnerRegistry::new();
2003        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
2004        reg
2005    }
2006
2007    fn rustfn_agent(name: &str) -> AgentDef {
2008        AgentDef {
2009            name: name.to_string(),
2010            kind: AgentKind::RustFn,
2011            spec: serde_json::json!({ "fn_id": "echo" }),
2012            profile: None,
2013            meta: None,
2014            runner: None,
2015            runner_ref: None,
2016            verdict: None,
2017        }
2018    }
2019
2020    fn minimal_bp(agents: Vec<AgentDef>, audits: Vec<AuditDef>) -> Blueprint {
2021        Blueprint {
2022            schema_version: crate::blueprint::current_schema_version(),
2023            id: "audit-ref-ut".into(),
2024            flow: FlowNode::Step {
2025                ref_: "worker".to_string(),
2026                in_: Expr::Path {
2027                    at: "$.input".parse().expect("literal test path: $.input"),
2028                },
2029                out: Expr::Path {
2030                    at: "$.output".parse().expect("literal test path: $.output"),
2031                },
2032            },
2033            agents,
2034            operators: vec![],
2035            metas: vec![],
2036            hints: Default::default(),
2037            strategy: Default::default(),
2038            metadata: BlueprintMetadata::default(),
2039            spawner_hints: Default::default(),
2040            default_agent_kind: AgentKind::Operator,
2041            default_operator_kind: None,
2042            default_init_ctx: None,
2043            default_agent_ctx: None,
2044            default_context_policy: None,
2045            projection_placement: None,
2046            audits,
2047            degradation_policy: None,
2048            runners: vec![],
2049            default_runner: None,
2050        }
2051    }
2052
2053    #[test]
2054    fn unresolved_audit_agent_is_a_loud_compile_error() {
2055        let bp = minimal_bp(
2056            vec![rustfn_agent("worker")],
2057            vec![AuditDef {
2058                agent: "missing-auditor".to_string(),
2059                steps: None,
2060                mode: AuditMode::default(),
2061            }],
2062        );
2063        let compiler = Compiler::new(registry_with_echo());
2064        match compiler.compile(&bp) {
2065            Err(CompileError::UnresolvedAuditAgent { agent, defined }) => {
2066                assert_eq!(agent, "missing-auditor");
2067                assert_eq!(defined, vec!["worker".to_string()]);
2068            }
2069            Err(other) => {
2070                panic!("expected UnresolvedAuditAgent, got a different CompileError: {other}")
2071            }
2072            Ok(_) => panic!("expected compile-time failure, got Ok"),
2073        }
2074    }
2075
2076    #[test]
2077    fn resolved_audit_agent_compiles_fine() {
2078        let bp = minimal_bp(
2079            vec![rustfn_agent("worker"), rustfn_agent("auditor")],
2080            vec![AuditDef {
2081                agent: "auditor".to_string(),
2082                steps: None,
2083                mode: AuditMode::default(),
2084            }],
2085        );
2086        let compiler = Compiler::new(registry_with_echo());
2087        assert!(
2088            compiler.compile(&bp).is_ok(),
2089            "an audits[].agent that names a declared AgentDef must compile"
2090        );
2091    }
2092}
2093
2094// ─── GH #27 (follow-up to #23): `Blueprint.projection_placement` compile-time
2095// validation + `CompiledBlueprint.projection_placement` construction ────────
2096#[cfg(test)]
2097mod projection_placement_compile_tests {
2098    use super::*;
2099    use crate::core::projection_placement::{ProjectionPlacement, RootPreference};
2100    use crate::worker::adapter::WorkerResult;
2101    use mlua_swarm_schema::ProjectionPlacementSpec;
2102
2103    fn registry_with_echo() -> SpawnerRegistry {
2104        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2105            Ok(WorkerResult {
2106                value: Value::String(inv.prompt),
2107                ok: true,
2108            })
2109        });
2110        let mut reg = SpawnerRegistry::new();
2111        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
2112        reg
2113    }
2114
2115    fn minimal_bp(projection_placement: Option<ProjectionPlacementSpec>) -> Blueprint {
2116        Blueprint {
2117            schema_version: crate::blueprint::current_schema_version(),
2118            id: "projection-placement-ut".into(),
2119            flow: FlowNode::Step {
2120                ref_: "worker".to_string(),
2121                in_: Expr::Path {
2122                    at: "$.input".parse().expect("literal test path: $.input"),
2123                },
2124                out: Expr::Path {
2125                    at: "$.output".parse().expect("literal test path: $.output"),
2126                },
2127            },
2128            agents: vec![AgentDef {
2129                name: "worker".to_string(),
2130                kind: AgentKind::RustFn,
2131                spec: serde_json::json!({ "fn_id": "echo" }),
2132                profile: None,
2133                meta: None,
2134                runner: None,
2135                runner_ref: None,
2136                verdict: None,
2137            }],
2138            operators: vec![],
2139            metas: vec![],
2140            hints: Default::default(),
2141            strategy: Default::default(),
2142            metadata: BlueprintMetadata::default(),
2143            spawner_hints: Default::default(),
2144            default_agent_kind: AgentKind::Operator,
2145            default_operator_kind: None,
2146            default_init_ctx: None,
2147            default_agent_ctx: None,
2148            default_context_policy: None,
2149            projection_placement,
2150            audits: vec![],
2151            degradation_policy: None,
2152            runners: vec![],
2153            default_runner: None,
2154        }
2155    }
2156
2157    #[test]
2158    fn undeclared_projection_placement_compiles_to_byte_compat_default() {
2159        let bp = minimal_bp(None);
2160        let compiled = Compiler::new(registry_with_echo())
2161            .compile(&bp)
2162            .expect("undeclared projection_placement compiles");
2163        assert_eq!(
2164            *compiled.projection_placement,
2165            ProjectionPlacement::default()
2166        );
2167    }
2168
2169    #[test]
2170    fn declared_valid_projection_placement_compiles_to_matching_resolver() {
2171        let bp = minimal_bp(Some(ProjectionPlacementSpec {
2172            root: Some("project_root".to_string()),
2173            dir_template: Some("custom/{task_id}/out".to_string()),
2174        }));
2175        let compiled = Compiler::new(registry_with_echo())
2176            .compile(&bp)
2177            .expect("valid projection_placement compiles");
2178        assert_eq!(
2179            compiled.projection_placement.root_preference,
2180            RootPreference::ProjectRoot
2181        );
2182        assert_eq!(
2183            compiled.projection_placement.dir_template,
2184            "custom/{task_id}/out"
2185        );
2186    }
2187
2188    #[test]
2189    fn declared_invalid_dir_template_rejects_compile() {
2190        let bp = minimal_bp(Some(ProjectionPlacementSpec {
2191            root: None,
2192            dir_template: Some("workspace/tasks/ctx".to_string()), // missing {task_id}
2193        }));
2194        match Compiler::new(registry_with_echo()).compile(&bp) {
2195            Err(CompileError::InvalidProjectionPlacement(_)) => {}
2196            Err(other) => {
2197                panic!("expected InvalidProjectionPlacement, got a different CompileError: {other}")
2198            }
2199            Ok(_) => {
2200                panic!("expected compile-time rejection for a missing {{task_id}} placeholder")
2201            }
2202        }
2203    }
2204
2205    #[test]
2206    fn declared_invalid_root_literal_rejects_compile() {
2207        let bp = minimal_bp(Some(ProjectionPlacementSpec {
2208            root: Some("nope".to_string()),
2209            dir_template: None,
2210        }));
2211        match Compiler::new(registry_with_echo()).compile(&bp) {
2212            Err(CompileError::InvalidProjectionPlacement(_)) => {}
2213            Err(other) => {
2214                panic!("expected InvalidProjectionPlacement, got a different CompileError: {other}")
2215            }
2216            Ok(_) => panic!("expected compile-time rejection for an invalid root literal"),
2217        }
2218    }
2219}
2220
2221// ─── GH #50: `Blueprint.agents[].verdict` cond↔output-shape lint ──────────
2222#[cfg(test)]
2223mod verdict_contract_lint_tests {
2224    use super::*;
2225    use crate::worker::adapter::WorkerResult;
2226
2227    fn registry_with_echo() -> SpawnerRegistry {
2228        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2229            Ok(WorkerResult {
2230                value: Value::String(inv.prompt),
2231                ok: true,
2232            })
2233        });
2234        let mut reg = SpawnerRegistry::new();
2235        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
2236        reg
2237    }
2238
2239    fn gate_agent(verdict: Option<VerdictContract>) -> AgentDef {
2240        AgentDef {
2241            name: "gate".to_string(),
2242            kind: AgentKind::RustFn,
2243            spec: serde_json::json!({ "fn_id": "echo" }),
2244            profile: None,
2245            meta: None,
2246            runner: None,
2247            runner_ref: None,
2248            verdict,
2249        }
2250    }
2251
2252    fn minimal_bp(agent: AgentDef, flow: FlowNode) -> Blueprint {
2253        Blueprint {
2254            schema_version: crate::blueprint::current_schema_version(),
2255            id: "verdict-contract-ut".into(),
2256            flow,
2257            agents: vec![agent],
2258            operators: vec![],
2259            metas: vec![],
2260            hints: Default::default(),
2261            strategy: Default::default(),
2262            metadata: BlueprintMetadata::default(),
2263            spawner_hints: Default::default(),
2264            default_agent_kind: AgentKind::Operator,
2265            default_operator_kind: None,
2266            default_init_ctx: None,
2267            default_agent_ctx: None,
2268            default_context_policy: None,
2269            projection_placement: None,
2270            audits: vec![],
2271            degradation_policy: None,
2272            runners: vec![],
2273            default_runner: None,
2274        }
2275    }
2276
2277    fn step(ref_: &str, out_path: &str) -> FlowNode {
2278        FlowNode::Step {
2279            ref_: ref_.to_string(),
2280            in_: Expr::Lit { value: Value::Null },
2281            out: Expr::Path {
2282                at: out_path.parse().expect("literal test path"),
2283            },
2284        }
2285    }
2286
2287    fn noop() -> FlowNode {
2288        FlowNode::Seq { children: vec![] }
2289    }
2290
2291    fn eq_cond(path: &str, lit: &str) -> Expr {
2292        Expr::Eq {
2293            lhs: Box::new(Expr::Path {
2294                at: path.parse().expect("literal test path"),
2295            }),
2296            rhs: Box::new(Expr::Lit {
2297                value: Value::String(lit.to_string()),
2298            }),
2299        }
2300    }
2301
2302    fn branch(cond: Expr, then_: FlowNode, else_: FlowNode) -> FlowNode {
2303        FlowNode::Branch {
2304            cond,
2305            then_: Box::new(then_),
2306            else_: Box::new(else_),
2307        }
2308    }
2309
2310    fn body_contract(values: &[&str]) -> VerdictContract {
2311        VerdictContract {
2312            channel: VerdictChannel::Body,
2313            values: values.iter().map(|v| v.to_string()).collect(),
2314        }
2315    }
2316
2317    fn part_contract(values: &[&str]) -> VerdictContract {
2318        VerdictContract {
2319            channel: VerdictChannel::Part,
2320            values: values.iter().map(|v| v.to_string()).collect(),
2321        }
2322    }
2323
2324    #[test]
2325    fn contract_with_correct_body_channel_and_value_compiles() {
2326        let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
2327        let flow = FlowNode::Seq {
2328            children: vec![
2329                step("gate", "$.verdict"),
2330                branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
2331            ],
2332        };
2333        let bp = minimal_bp(agent, flow);
2334        assert!(
2335            Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
2336            "a cond addressing the bare step output must match a channel: \"body\" contract"
2337        );
2338    }
2339
2340    #[test]
2341    fn contract_with_correct_part_channel_and_value_compiles() {
2342        let agent = gate_agent(Some(part_contract(&["PASS", "BLOCKED"])));
2343        let flow = FlowNode::Seq {
2344            children: vec![
2345                step("gate", "$.gate"),
2346                branch(eq_cond("$.gate.parts.verdict", "BLOCKED"), noop(), noop()),
2347            ],
2348        };
2349        let bp = minimal_bp(agent, flow);
2350        assert!(
2351            Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
2352            "a cond addressing '<step>.parts.verdict' must match a channel: \"part\" contract"
2353        );
2354    }
2355
2356    #[test]
2357    fn body_channel_contract_rejects_cond_addressing_parts_verdict() {
2358        // Pattern A declared (channel: "body") but the cond addresses the
2359        // Pattern B shape ('$.gate.parts.verdict') instead of the bare
2360        // step output — GH #50 register-time enforcement point 1.
2361        let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
2362        let flow = FlowNode::Seq {
2363            children: vec![
2364                step("gate", "$.gate"),
2365                branch(eq_cond("$.gate.parts.verdict", "BLOCKED"), noop(), noop()),
2366            ],
2367        };
2368        let bp = minimal_bp(agent, flow);
2369        match Compiler::new(registry_with_echo()).compile(&bp) {
2370            Err(CompileError::VerdictChannelMismatch {
2371                where_,
2372                agent,
2373                expected_channel,
2374                actual_shape,
2375            }) => {
2376                assert_eq!(agent, "gate");
2377                assert_eq!(expected_channel, "body");
2378                assert_eq!(actual_shape, "part");
2379                assert!(where_.contains("Branch cond"), "where_: {where_}");
2380            }
2381            Err(other) => {
2382                panic!("expected VerdictChannelMismatch, got a different CompileError: {other}")
2383            }
2384            Ok(_) => panic!("expected compile-time rejection for the wrong channel shape"),
2385        }
2386    }
2387
2388    #[test]
2389    fn part_channel_contract_rejects_cond_addressing_bare_output() {
2390        // Inverse of the previous case: channel: "part" declared, but the
2391        // cond addresses the bare step output.
2392        let agent = gate_agent(Some(part_contract(&["PASS", "BLOCKED"])));
2393        let flow = FlowNode::Seq {
2394            children: vec![
2395                step("gate", "$.verdict"),
2396                branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
2397            ],
2398        };
2399        let bp = minimal_bp(agent, flow);
2400        match Compiler::new(registry_with_echo()).compile(&bp) {
2401            Err(CompileError::VerdictChannelMismatch {
2402                agent,
2403                expected_channel,
2404                actual_shape,
2405                ..
2406            }) => {
2407                assert_eq!(agent, "gate");
2408                assert_eq!(expected_channel, "part");
2409                assert_eq!(actual_shape, "body");
2410            }
2411            Err(other) => {
2412                panic!("expected VerdictChannelMismatch, got a different CompileError: {other}")
2413            }
2414            Ok(_) => panic!("expected compile-time rejection for the wrong channel shape"),
2415        }
2416    }
2417
2418    #[test]
2419    fn contract_rejects_lit_outside_declared_values() {
2420        let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
2421        let flow = FlowNode::Seq {
2422            children: vec![
2423                step("gate", "$.verdict"),
2424                branch(eq_cond("$.verdict", "UNKNOWN"), noop(), noop()),
2425            ],
2426        };
2427        let bp = minimal_bp(agent, flow);
2428        match Compiler::new(registry_with_echo()).compile(&bp) {
2429            Err(CompileError::VerdictValueNotInContract {
2430                agent,
2431                value,
2432                values,
2433                ..
2434            }) => {
2435                assert_eq!(agent, "gate");
2436                assert_eq!(value, "UNKNOWN");
2437                assert_eq!(values, vec!["PASS".to_string(), "BLOCKED".to_string()]);
2438            }
2439            Err(other) => {
2440                panic!("expected VerdictValueNotInContract, got a different CompileError: {other}")
2441            }
2442            Ok(_) => panic!("expected compile-time rejection for a Lit outside declared values"),
2443        }
2444    }
2445
2446    #[test]
2447    fn undeclared_agent_referenced_by_cond_compiles_with_warning_only() {
2448        let agent = gate_agent(None);
2449        let flow = FlowNode::Seq {
2450            children: vec![
2451                step("gate", "$.verdict"),
2452                branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
2453            ],
2454        };
2455        let bp = minimal_bp(agent, flow);
2456        assert!(
2457            Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
2458            "an undeclared verdict contract must never reject compile (opt-in, back-compat)"
2459        );
2460    }
2461
2462    #[test]
2463    fn in_expr_with_lit_haystack_members_compiles() {
2464        let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
2465        let cond = Expr::In {
2466            needle: Box::new(Expr::Path {
2467                at: "$.verdict".parse().expect("literal test path"),
2468            }),
2469            haystack: Box::new(Expr::Lit {
2470                value: serde_json::json!(["PASS", "BLOCKED"]),
2471            }),
2472        };
2473        let flow = FlowNode::Seq {
2474            children: vec![step("gate", "$.verdict"), branch(cond, noop(), noop())],
2475        };
2476        let bp = minimal_bp(agent, flow);
2477        assert!(
2478            Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
2479            "an `In` haystack whose every Lit is a declared value must compile"
2480        );
2481    }
2482
2483    /// Acceptance criterion #7 (5th case): a Blueprint shaped like the
2484    /// existing `02-verdict-loop.json` sample — a `Loop` retrying while
2485    /// `$.verdict == "BLOCKED"` plus a `Branch` on `$.verdict == "PASS"` —
2486    /// but with `verdict` omitted on every agent must compile unchanged
2487    /// (at most `tracing::warn!`) and leave `CompiledAgentTable.
2488    /// verdict_contracts` empty.
2489    #[test]
2490    fn verdict_omitted_blueprint_compiles_unchanged_with_empty_contracts() {
2491        let agent = gate_agent(None);
2492        let flow = FlowNode::Seq {
2493            children: vec![
2494                step("gate", "$.verdict"),
2495                FlowNode::Loop {
2496                    counter: Expr::Path {
2497                        at: "$.n".parse().expect("literal test path"),
2498                    },
2499                    cond: eq_cond("$.verdict", "BLOCKED"),
2500                    body: Box::new(step("gate", "$.verdict")),
2501                    max: 3,
2502                },
2503                branch(eq_cond("$.verdict", "PASS"), noop(), noop()),
2504            ],
2505        };
2506        let bp = minimal_bp(agent, flow);
2507        let compiled = Compiler::new(registry_with_echo())
2508            .compile(&bp)
2509            .expect("a verdict-omitted Blueprint must compile unchanged");
2510        assert!(
2511            compiled.router.verdict_contracts.is_empty(),
2512            "no agent declared a verdict contract"
2513        );
2514    }
2515}