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};
42use serde_json::Value;
43use std::collections::HashMap;
44use std::sync::Arc;
45use thiserror::Error;
46
47// ─── error ───────────────────────────────────────────────────────────────
48
49/// Everything that can go wrong while `Compiler::compile` turns a
50/// `Blueprint` into a `CompiledBlueprint`.
51#[derive(Debug, Error)]
52pub enum CompileError {
53    /// An `AgentDef.kind` has no matching entry in the `SpawnerRegistry`
54    /// and `Blueprint.strategy.strict_kind` is set.
55    #[error("unknown agent kind in SpawnerRegistry: {0:?}")]
56    UnknownKind(AgentKind),
57    /// The `AgentDef.spec` shape did not match what the factory for its
58    /// kind requires (missing/mistyped field, etc.).
59    #[error("agent '{name}' spec invalid: {msg}")]
60    InvalidSpec {
61        /// The offending agent's name.
62        name: String,
63        /// Human-readable description of what was wrong with the spec.
64        msg: String,
65    },
66    /// The flow references an agent name that has no corresponding
67    /// `AgentDef` (and no default spawner is configured).
68    #[error("flow references agent '{0}' but no AgentDef matches")]
69    UnresolvedRef(String),
70    /// Two `AgentDef`s in the same `Blueprint` share a name.
71    #[error("duplicate AgentDef name: {0}")]
72    DuplicateAgent(String),
73    /// A `kind = Operator` agent's `spec.operator_ref` does not match
74    /// any `OperatorDef.name` declared in `Blueprint.operators`.
75    #[error("agent '{agent}' operator_ref '{op_ref}' does not match any OperatorDef.name in Blueprint.operators (defined: {defined:?})")]
76    UnresolvedOperatorRef {
77        /// The agent whose `operator_ref` didn't resolve.
78        agent: String,
79        /// The `operator_ref` value that was looked up.
80        op_ref: String,
81        /// The `OperatorDef.name`s that *are* declared, for the error
82        /// message.
83        defined: Vec<String>,
84    },
85    /// GH #21 Phase 2: an `AgentMeta.meta_ref` or a statically-visible
86    /// `$step_meta.ref` (inside a `Step.in` **Lit** expr) does not match
87    /// any `MetaDef.name` declared in `Blueprint.metas`.
88    #[error("{where_} names an undefined MetaDef: '{meta_ref}' (defined: {defined:?})")]
89    UnresolvedMetaRef {
90        /// Human-readable description of where the reference was found
91        /// (e.g. `"AgentMeta.meta_ref of agent 'planner'"` or `"Step
92        /// 'scout' $step_meta.ref"`).
93        where_: String,
94        /// The `meta_ref` value that was looked up.
95        meta_ref: String,
96        /// The `MetaDef.name`s that *are* declared, for the error
97        /// message.
98        defined: Vec<String>,
99    },
100    /// GH #23: two Steps' canonical/alias projection names collide and at
101    /// least one side declared `AgentMeta.projection_name` — see
102    /// [`crate::core::step_naming::StepNaming::from_blueprint`]'s doc for
103    /// the full resolution rule (an undeclared/undeclared clash is a soft
104    /// warning instead, logged but not rejected).
105    #[error("StepNaming collision: {0}")]
106    StepNamingCollision(#[from] StepNamingError),
107    /// GH #27 (follow-up to #23): `Blueprint.projection_placement` failed
108    /// validation — see
109    /// [`crate::core::projection_placement::ProjectionPlacement::from_spec`]'s
110    /// doc for the rejection rules (`dir_template` empty / missing the
111    /// `{task_id}` placeholder / absolute / containing a `..` segment, or
112    /// `root` not `"work_dir"`/`"project_root"`).
113    #[error("invalid projection_placement: {0}")]
114    InvalidProjectionPlacement(#[from] ProjectionPlacementError),
115    /// GH #34: an `audits[].agent` name does not match any `AgentDef.name`
116    /// declared in `Blueprint.agents` — mirrors the `operator_ref`
117    /// validation above (same "design-time reference must resolve"
118    /// discipline).
119    #[error("audits[].agent '{agent}' does not match any AgentDef.name in Blueprint.agents (defined: {defined:?})")]
120    UnresolvedAuditAgent {
121        /// The `audits[].agent` value that was looked up.
122        agent: String,
123        /// The `AgentDef.name`s that *are* declared, for the error
124        /// message.
125        defined: Vec<String>,
126    },
127}
128
129// ─── SpawnerFactory + Registry ───────────────────────────────────────────
130
131/// Factory trait that interprets an `AgentDef` and builds the concrete
132/// `SpawnerAdapter`. Register one per kind. Parsing the spec,
133/// validating it, and baking the profile are the implementation's job.
134///
135/// The signature was widened in v9 from `(name, spec, hint)` to
136/// `(&AgentDef, hint)` so the profile can be passed through. Most
137/// implementations still just pull `&agent_def.name` and
138/// `&agent_def.spec`, but Operator-backend factories consume
139/// `agent_def.profile` to bake the persona in.
140pub trait SpawnerFactory: Send + Sync {
141    /// Build the concrete `SpawnerAdapter` for one `AgentDef`. `hint` is
142    /// the matching entry (if any) from `Blueprint.hints.per_agent`.
143    fn build(
144        &self,
145        agent_def: &AgentDef,
146        hint: Option<&Value>,
147    ) -> Result<Arc<dyn SpawnerAdapter>, CompileError>;
148}
149
150/// Companion trait that carries the **type-side source of truth** for
151/// the Adapter ↔ `AgentKind` correspondence.
152///
153/// The base [`SpawnerFactory`] trait deliberately does not carry an
154/// associated const so it stays dyn-compatible — that is, so it can be
155/// stored and dispatched as `Arc<dyn SpawnerFactory>`. This companion
156/// trait splits `const KIND: AgentKind` out, and
157/// [`SpawnerRegistry::register`] uses `F::KIND` as the `HashMap` key.
158/// That physically removes the string-lookup failure mode at the type
159/// layer.
160///
161/// The three built-in factories (`Shell` / `InProc` / `Operator`)
162/// implement this. Extension backends (say, `AgentBlockSpawnerFactory`)
163/// follow the same explicit two-step recipe: add a new `AgentKind`
164/// variant and implement this trait.
165pub trait SpawnerFactoryKind: SpawnerFactory {
166    /// The `AgentKind` this factory handles — used as the `HashMap` key
167    /// by `SpawnerRegistry::register`.
168    const KIND: AgentKind;
169    /// The concrete Worker type produced by this `AgentKind` — this
170    /// binds the type chain all the way from `AgentKind` down to `Worker`.
171    /// Every factory declares it so the `AgentKind → Worker` mapping is
172    /// explicit across all four layers. It is the source of truth for
173    /// preserving the concrete type right up until `SpawnerAdapter::spawn`
174    /// erases it into `Box<dyn Worker>`.
175    type Worker: crate::worker::Worker;
176}
177
178/// `AgentKind → SpawnerFactory` mapping. The compiler looks entries up
179/// during `compile()`.
180#[derive(Clone)]
181pub struct SpawnerRegistry {
182    factories: HashMap<AgentKind, Arc<dyn SpawnerFactory>>,
183}
184
185impl SpawnerRegistry {
186    /// Start with an empty `AgentKind → SpawnerFactory` mapping.
187    pub fn new() -> Self {
188        Self {
189            factories: HashMap::new(),
190        }
191    }
192    /// **Type-driven registration** — takes `F::KIND` and uses it as the
193    /// `HashMap` key.
194    ///
195    /// Callers use the form
196    /// `reg.register::<SubprocessProcessSpawnerFactory>(Arc::new(...))`
197    /// and never have to pass an `AgentKind` literal. The Adapter ↔ Kind
198    /// correspondence is enforced at the type layer, physically removing
199    /// the string / enum-literal lookup failure mode.
200    pub fn register<F: SpawnerFactoryKind + 'static>(&mut self, factory: Arc<F>) -> &mut Self {
201        let f: Arc<dyn SpawnerFactory> = factory;
202        self.factories.insert(F::KIND, f);
203        self
204    }
205}
206
207impl Default for SpawnerRegistry {
208    fn default() -> Self {
209        Self::new()
210    }
211}
212
213// ─── Compiler ────────────────────────────────────────────────────────────
214
215/// Turns a `Blueprint` into a `CompiledBlueprint` by resolving every
216/// `AgentDef` against a `SpawnerRegistry`. One-shot: build a fresh
217/// `Compiler` per `compile()` call (or reuse it — it holds no
218/// per-compile state).
219pub struct Compiler {
220    registry: SpawnerRegistry,
221    default_spawner: Option<Arc<dyn SpawnerAdapter>>,
222}
223
224/// The result of `Compiler::compile` — a routing table plus the
225/// unmodified flow and metadata, ready to hand to
226/// `EngineDispatcher::with_spawner` / `mlua_flow_ir::eval_async`.
227pub struct CompiledBlueprint {
228    /// `ctx.agent → SpawnerAdapter` lookup table.
229    pub router: Arc<CompiledAgentTable>,
230    /// The flow.ir source, copied verbatim from `Blueprint.flow`.
231    pub flow: FlowNode,
232    /// Copied verbatim from `Blueprint.metadata`.
233    pub metadata: BlueprintMetadata,
234    /// GH #23: the Blueprint's [`StepNaming`] addressing-space table,
235    /// built once here (the sole construction site — see
236    /// [`StepNaming::from_blueprint`]'s doc) and threaded through
237    /// `EngineDispatcher::with_step_naming` for `EngineState` storage.
238    pub step_naming: Arc<StepNaming>,
239    /// GH #27 (follow-up to #23): the Blueprint's [`ProjectionPlacement`]
240    /// resolver, built once here (the sole construction site — see
241    /// [`ProjectionPlacement::from_spec`]'s doc) and threaded through
242    /// `EngineDispatcher::with_projection_placement` for `EngineState`
243    /// storage.
244    pub projection_placement: Arc<ProjectionPlacement>,
245}
246
247impl Compiler {
248    /// Build a `Compiler` around the given `SpawnerRegistry`, with no
249    /// default spawner (unresolved flow refs are an error unless
250    /// `with_default` is chained on).
251    pub fn new(registry: SpawnerRegistry) -> Self {
252        Self {
253            registry,
254            default_spawner: None,
255        }
256    }
257
258    /// Set a default spawner — used for flow refs (and unregistered
259    /// `AgentKind`s under non-strict strategy) that don't resolve
260    /// against any `AgentDef`/`SpawnerRegistry` entry.
261    pub fn with_default(mut self, sp: Arc<dyn SpawnerAdapter>) -> Self {
262        self.default_spawner = Some(sp);
263        self
264    }
265
266    /// Resolve every `Blueprint.agents` entry through the registry,
267    /// validate `operator_ref`s and flow refs per `Blueprint.strategy`,
268    /// and return the routing table alongside the untouched flow and
269    /// metadata.
270    pub fn compile(&self, bp: &Blueprint) -> Result<CompiledBlueprint, CompileError> {
271        let mut routes: HashMap<String, Arc<dyn SpawnerAdapter>> = HashMap::new();
272        let mut seen: HashMap<String, ()> = HashMap::new();
273
274        // Design-time validation (OperatorDef as a first-class value):
275        // every `kind = Operator` agent's `spec.operator_ref` must point at
276        // one of `bp.operators[].name`. A Blueprint with any Operator agent
277        // must therefore declare its operators up front; the empty-operators
278        // backward-compat bypass is retired.
279        let defined: Vec<String> = bp.operators.iter().map(|o| o.name.clone()).collect();
280        for ad in &bp.agents {
281            if !matches!(ad.kind, AgentKind::Operator) {
282                continue;
283            }
284            let op_ref = ad.spec.get("operator_ref").and_then(|v| v.as_str());
285            if let Some(op_ref) = op_ref {
286                if !defined.iter().any(|n| n == op_ref) {
287                    return Err(CompileError::UnresolvedOperatorRef {
288                        agent: ad.name.clone(),
289                        op_ref: op_ref.to_string(),
290                        defined: defined.clone(),
291                    });
292                }
293            }
294            // A missing `op_ref` is reported through OperatorSpawnerFactory.build under a different error.
295        }
296
297        // GH #21 Phase 2: named `MetaDef` pool (`Blueprint.metas`) —
298        // validate every reference against it, mirroring the
299        // `operator_ref` validation above.
300        let metas_defined: Vec<String> = bp.metas.iter().map(|m| m.name.clone()).collect();
301        for ad in &bp.agents {
302            let meta_ref = ad.meta.as_ref().and_then(|m| m.meta_ref.as_ref());
303            if let Some(meta_ref) = meta_ref {
304                if !metas_defined.iter().any(|n| n == meta_ref) {
305                    return Err(CompileError::UnresolvedMetaRef {
306                        where_: format!("AgentMeta.meta_ref of agent '{}'", ad.name),
307                        meta_ref: meta_ref.clone(),
308                        defined: metas_defined.clone(),
309                    });
310                }
311            }
312        }
313        // Best-effort static walk of the flow for `$step_meta.ref`
314        // envelopes embedded in a Step's **Lit** `in` expr — this is a
315        // design-time hint only: a non-`Lit` `Step.in` (e.g. `Path`) is
316        // invisible here and skipped silently; `EngineDispatcher::dispatch`
317        // is the authoritative, loud validation line for those.
318        let mut static_step_meta_refs: Vec<(String, String)> = Vec::new();
319        collect_step_meta_refs(&bp.flow, &mut static_step_meta_refs);
320        for (where_, meta_ref) in static_step_meta_refs {
321            if !metas_defined.iter().any(|n| n == &meta_ref) {
322                return Err(CompileError::UnresolvedMetaRef {
323                    where_,
324                    meta_ref,
325                    defined: metas_defined.clone(),
326                });
327            }
328        }
329
330        // GH #34: `audits[].agent` must name an entry in `Blueprint.agents`
331        // — mirrors the `operator_ref` validation above (design-time
332        // reference must resolve at compile time, before any spawner is
333        // built).
334        let agents_defined: Vec<String> = bp.agents.iter().map(|a| a.name.clone()).collect();
335        for audit in &bp.audits {
336            if !agents_defined.iter().any(|n| n == &audit.agent) {
337                return Err(CompileError::UnresolvedAuditAgent {
338                    agent: audit.agent.clone(),
339                    defined: agents_defined.clone(),
340                });
341            }
342        }
343
344        for ad in &bp.agents {
345            if seen.contains_key(&ad.name) {
346                return Err(CompileError::DuplicateAgent(ad.name.clone()));
347            }
348            seen.insert(ad.name.clone(), ());
349
350            let factory = match self.registry.factories.get(&ad.kind) {
351                Some(f) => f.clone(),
352                None => {
353                    if bp.strategy.strict_kind {
354                        return Err(CompileError::UnknownKind(ad.kind.clone()));
355                    } else {
356                        tracing::warn!(
357                            agent = %ad.name,
358                            kind = ?ad.kind,
359                            "no spawner factory registered for agent kind; \
360                             dropping agent from routing table (strict_kind=false)"
361                        );
362                        continue;
363                    }
364                }
365            };
366            let hint = bp.hints.per_agent.get(&ad.name);
367            let spawner = factory.build(ad, hint)?;
368            routes.insert(ad.name.clone(), spawner);
369        }
370
371        if bp.strategy.strict_refs {
372            verify_refs(&bp.flow, &routes, self.default_spawner.is_some())?;
373        }
374
375        // GH #23: build the StepNaming addressing-space table once, here
376        // (the sole construction site). A hard collision (either side
377        // declares `AgentMeta.projection_name`) rejects the compile via
378        // `?` (`StepNamingError` → `CompileError::StepNamingCollision`,
379        // same family as the other Blueprint validation checks above); a
380        // soft undeclared/undeclared collision is logged and compilation
381        // proceeds (pre-GH-#23 union-rule behavior preserved).
382        let (step_naming, step_naming_warnings) = StepNaming::from_blueprint(bp)?;
383        for warning in &step_naming_warnings {
384            tracing::warn!(
385                name = %warning.name,
386                first_step_ref = %warning.first_step_ref,
387                second_step_ref = %warning.second_step_ref,
388                "StepNaming: undeclared steps' canonical/alias names collide; \
389                 the step whose own ref matches the name keeps it (data-plane priority)"
390            );
391        }
392
393        // GH #27 (follow-up to #23): build the ProjectionPlacement resolver
394        // once, here (the sole construction site) — an invalid
395        // `dir_template` / `root` literal rejects the compile via `?`
396        // (`ProjectionPlacementError` → `CompileError::InvalidProjectionPlacement`,
397        // same family as the other Blueprint validation checks above). No
398        // declared `projection_placement` (the pre-#27 default) resolves
399        // to `ProjectionPlacement::default()` unchanged.
400        let projection_placement =
401            ProjectionPlacement::from_spec(bp.projection_placement.as_ref())?;
402
403        let router = Arc::new(CompiledAgentTable {
404            routes,
405            default: self.default_spawner.clone(),
406        });
407        Ok(CompiledBlueprint {
408            router,
409            flow: bp.flow.clone(),
410            metadata: bp.metadata.clone(),
411            step_naming: Arc::new(step_naming),
412            projection_placement: Arc::new(projection_placement),
413        })
414    }
415}
416
417/// Walk the flow `Node`, collect every `Step.ref`, and check that no ref
418/// is unresolved against `routes` (or the default, when one exists).
419fn verify_refs(
420    node: &FlowNode,
421    routes: &HashMap<String, Arc<dyn SpawnerAdapter>>,
422    has_default: bool,
423) -> Result<(), CompileError> {
424    let mut refs: Vec<String> = Vec::new();
425    collect_refs(node, &mut refs);
426    for r in refs {
427        if !routes.contains_key(&r) && !has_default {
428            return Err(CompileError::UnresolvedRef(r));
429        }
430    }
431    Ok(())
432}
433
434fn collect_refs(node: &FlowNode, out: &mut Vec<String>) {
435    match node {
436        FlowNode::Step { ref_, .. } => out.push(ref_.clone()),
437        FlowNode::Seq { children } => {
438            for c in children {
439                collect_refs(c, out);
440            }
441        }
442        FlowNode::Branch { then_, else_, .. } => {
443            collect_refs(then_, out);
444            collect_refs(else_, out);
445        }
446        FlowNode::Fanout { body, .. } => collect_refs(body, out),
447        FlowNode::Loop { body, .. } => collect_refs(body, out),
448        FlowNode::Try { body, catch, .. } => {
449            collect_refs(body, out);
450            collect_refs(catch, out);
451        }
452        FlowNode::Assign { .. } => {} // The Assign node carries no ref.
453    }
454}
455
456/// GH #21 Phase 2: walk the flow `Node` (same recursion shape as
457/// [`collect_refs`]) and collect every statically-visible `$step_meta.ref`
458/// found inside a Step's `in` **Lit** expr, as `(where_, meta_ref)` pairs
459/// for [`CompileError::UnresolvedMetaRef`] reporting. Non-`Lit` `in`
460/// exprs (e.g. `Expr::Path`) cannot be inspected statically and are
461/// silently skipped — `EngineDispatcher::dispatch` (the `mlua-swarm` core
462/// crate) is the authoritative, loud validation line for those.
463fn collect_step_meta_refs(node: &FlowNode, out: &mut Vec<(String, String)>) {
464    match node {
465        FlowNode::Step { ref_, in_, .. } => {
466            if let Expr::Lit { value } = in_ {
467                if let Some(meta_ref) = static_step_meta_ref(value) {
468                    out.push((format!("Step '{ref_}' $step_meta.ref"), meta_ref));
469                }
470            }
471        }
472        FlowNode::Seq { children } => {
473            for c in children {
474                collect_step_meta_refs(c, out);
475            }
476        }
477        FlowNode::Branch { then_, else_, .. } => {
478            collect_step_meta_refs(then_, out);
479            collect_step_meta_refs(else_, out);
480        }
481        FlowNode::Fanout { body, .. } => collect_step_meta_refs(body, out),
482        FlowNode::Loop { body, .. } => collect_step_meta_refs(body, out),
483        FlowNode::Try { body, catch, .. } => {
484            collect_step_meta_refs(body, out);
485            collect_step_meta_refs(catch, out);
486        }
487        FlowNode::Assign { .. } => {} // The Assign node carries no `in`.
488    }
489}
490
491/// Extract the `$step_meta.ref` string out of a literal `Step.in` value,
492/// if present and well-formed: `{"$step_meta": {"ref": "<name>", ...},
493/// ...}`. Any other shape (no `$step_meta` key, `ref` absent/null, `ref`
494/// not a string) yields `None` — this is a best-effort static hint only;
495/// a malformed envelope is caught loudly at dispatch time instead (see
496/// `EngineDispatcher::dispatch`'s doc in the `mlua-swarm` core crate).
497fn static_step_meta_ref(value: &Value) -> Option<String> {
498    value
499        .as_object()?
500        .get("$step_meta")?
501        .as_object()?
502        .get("ref")?
503        .as_str()
504        .map(str::to_string)
505}
506
507// ─── CompiledAgentTable ───────────────────────────────────────────────────────
508
509/// The compile result: an `agent name → SpawnerAdapter` lookup table.
510///
511/// Looks `routes` up by `ctx.agent` (the flow.ir `Step.ref`) and hands
512/// the spawn to the matching `SpawnerAdapter`. If the name is not
513/// registered and a `default` is configured, the default is used; if
514/// there is no default, `SpawnError::NotRegistered` is returned.
515///
516/// Layer wrapping (`AuditMiddleware` / `MainAIMiddleware` and friends) is
517/// not this type's concern — that is done separately in
518/// `service::linker::link`.
519pub struct CompiledAgentTable {
520    pub(crate) routes: HashMap<String, Arc<dyn SpawnerAdapter>>,
521    pub(crate) default: Option<Arc<dyn SpawnerAdapter>>,
522}
523
524impl CompiledAgentTable {
525    /// Whether the given agent name is registered in the table — i.e.,
526    /// whether its spawner has been resolved.
527    pub fn has_route(&self, agent: &str) -> bool {
528        self.routes.contains_key(agent)
529    }
530    /// List every resolved agent name.
531    pub fn routed_agents(&self) -> Vec<String> {
532        self.routes.keys().cloned().collect()
533    }
534}
535
536#[async_trait]
537impl SpawnerAdapter for CompiledAgentTable {
538    async fn spawn(
539        &self,
540        engine: &Engine,
541        ctx: &Ctx,
542        task_id: StepId,
543        attempt: u32,
544        token: CapToken,
545    ) -> Result<Box<dyn Worker>, SpawnError> {
546        let sp = self
547            .routes
548            .get(&ctx.agent)
549            .cloned()
550            .or_else(|| self.default.clone())
551            .ok_or_else(|| SpawnError::NotRegistered(ctx.agent.clone()))?;
552        sp.spawn(engine, ctx, task_id, attempt, token).await
553    }
554}
555
556// ─── default factories (three variants) ───────────────────────────────────
557
558/// Factory for `AgentKind::Subprocess`. Turns the spec into a
559/// [`ProcessSpawner`].
560///
561/// Naming convention: `<WorkerIMPL><AdapterType>SpawnerFactory`. Factory
562/// names carry both the worker implementation and the host adapter so
563/// they are not confused with each other; the old
564/// `ShellSpawnerFactory` was renamed to this.
565///
566/// Spec shape:
567/// ```jsonc
568/// { "program": "agent-block", "args": ["-s","s.lua"],
569///   "use_stdin": true,                       // optional, default = true
570///   "stream_mode": "ndjson_lines" | "sse_events" | "length_prefixed" | null  // optional, default = null (plain)
571/// }
572/// ```
573pub struct SubprocessProcessSpawnerFactory;
574
575impl SpawnerFactoryKind for SubprocessProcessSpawnerFactory {
576    const KIND: AgentKind = AgentKind::Subprocess;
577    type Worker = crate::worker::process_spawner::ProcessWorker;
578}
579
580impl SpawnerFactory for SubprocessProcessSpawnerFactory {
581    fn build(
582        &self,
583        agent_def: &AgentDef,
584        _hint: Option<&Value>,
585    ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
586        let agent_name = &agent_def.name;
587        let spec = &agent_def.spec;
588        let invalid = |msg: String| CompileError::InvalidSpec {
589            name: agent_name.to_string(),
590            msg,
591        };
592        let program = spec
593            .get("program")
594            .and_then(|v| v.as_str())
595            .ok_or_else(|| invalid("shell spec: 'program' (string) required".into()))?
596            .to_string();
597        let args: Vec<String> = spec
598            .get("args")
599            .and_then(|v| v.as_array())
600            .map(|a| {
601                a.iter()
602                    .filter_map(|x| x.as_str().map(|s| s.to_string()))
603                    .collect()
604            })
605            .unwrap_or_default();
606        let use_stdin = spec
607            .get("use_stdin")
608            .and_then(|v| v.as_bool())
609            .unwrap_or(true);
610        let stream_mode = match spec.get("stream_mode").and_then(|v| v.as_str()) {
611            Some("ndjson_lines") => Some(StreamMode::NdjsonLines),
612            Some("sse_events") => Some(StreamMode::SseEvents),
613            Some("length_prefixed") => Some(StreamMode::LengthPrefixed),
614            Some(other) => return Err(invalid(format!("unknown stream_mode: {other}"))),
615            None => None,
616        };
617
618        let mut sp = ProcessSpawner {
619            program,
620            args,
621            use_stdin,
622            stream_mode,
623        };
624        if let Some(mode) = sp.stream_mode.clone() {
625            sp = sp.stream_mode(mode);
626        }
627        Ok(Arc::new(sp))
628    }
629}
630
631/// Factory for `AgentKind::Lua`. At `build` time it inspects the
632/// `AgentDef.spec` and returns an [`InProcSpawner`] with the Lua-eval
633/// `WorkerFn` registered under `agent_name` — one `InProcSpawner`
634/// instance per agent.
635///
636/// Naming convention: `<WorkerIMPL><AdapterType>SpawnerFactory` (Lua
637/// worker on InProcess adapter). One half of the old
638/// `InProcSpawnerFactory`, split into Lua and RustFn variants.
639///
640/// Spec shape (choose one; `source` wins when both are present):
641///
642/// ```jsonc
643/// // (a) Registry lookup — Lua source id pre-registered with the
644/// //     factory via `register_lua` (used by the enhance flow's built-in
645/// //     workers). Requires the factory to know the id at construction
646/// //     time.
647/// { "fn_id": "patch-spawner" }
648///
649/// // (b) Inline source — a Lua chunk carried by the Blueprint itself,
650/// //     wrapped on the fly at `build` time. Combined with the loader's
651/// //     `$file` ref expansion (`"source": {"$file": "gates/foo.lua"}`)
652/// //     this lets a BP ship deterministic Lua gates without any
653/// //     pre-registration. `label` is optional and defaults to
654/// //     `"<agent_name>.lua"` for error messages.
655/// { "source": "return { value = 42, ok = true }",
656///   "label": "psim-gate.lua" }
657/// ```
658///
659/// Host bridges registered on the factory (see [`Self::with_bridge`])
660/// apply to both spec shapes.
661pub struct LuaInProcessSpawnerFactory {
662    registry: HashMap<String, WorkerFn>,
663    bridges: HashMap<String, HostBridge>,
664}
665
666/// Rust-side bridge function callable from Lua.
667///
668/// Inputs and outputs are both `serde_json::Value` (i.e. JSON). Lua
669/// invokes it as `host.<name>(arg_table)`. If the implementation needs
670/// to call async Rust, the caller does the sync-ification (typically
671/// `tokio::runtime::Handle::current().block_on(...)`).
672///
673/// Design intent: keep Lua scripts focused on flow control and `ctx`
674/// walking, while the heavy lifting (LLM calls, RFC 6902 apply,
675/// verifiers, and so on) stays on the Rust side. Going "pure Lua" —
676/// removing the bridge — is a carry.
677#[derive(Clone)]
678pub struct HostBridge(
679    Arc<dyn Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync>,
680);
681
682impl HostBridge {
683    /// Wrap a Rust closure as a bridge callable from Lua.
684    pub fn new<F>(f: F) -> Self
685    where
686        F: Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync + 'static,
687    {
688        Self(Arc::new(f))
689    }
690
691    /// Invoke the bridge directly — a thin trampoline over the inner
692    /// `Fn`. The production path goes through the Lua runtime, but this
693    /// stays `pub` so unit tests can exercise the primitive directly.
694    pub fn call(&self, arg: serde_json::Value) -> Result<serde_json::Value, String> {
695        (self.0)(arg)
696    }
697}
698
699/// Carrier type for Lua script sources. Paths are not required — a
700/// source string plus an identifying label is all it holds.
701///
702/// Callers bring in the source (via `include_str!` or similar) and
703/// register it with the factory through
704/// [`LuaInProcessSpawnerFactory::register_lua`].
705#[derive(Clone)]
706pub struct LuaScriptSource {
707    /// The Lua chunk source.
708    pub source: String,
709    /// Label used in error messages — typically the script's logical id
710    /// (for example `"patch_spawner.lua"`).
711    pub label: String,
712}
713
714impl LuaScriptSource {
715    /// Wrap a Lua chunk source and its error-message label.
716    pub fn new(source: impl Into<String>, label: impl Into<String>) -> Self {
717        Self {
718            source: source.into(),
719            label: label.into(),
720        }
721    }
722}
723
724impl LuaInProcessSpawnerFactory {
725    /// Start with no registered scripts and no host bridges.
726    pub fn new() -> Self {
727        Self {
728            registry: HashMap::new(),
729            bridges: HashMap::new(),
730        }
731    }
732
733    /// Register a host bridge. Subsequent `register_lua` calls snapshot
734    /// the current bridge set.
735    ///
736    /// Ordering rule: register bridges first, then call `register_lua`;
737    /// bridges added after `register_lua` will not be visible to that
738    /// script.
739    pub fn with_bridge(mut self, name: impl Into<String>, bridge: HostBridge) -> Self {
740        self.bridges.insert(name.into(), bridge);
741        self
742    }
743
744    /// Register a **Lua-eval Worker** under `fn_id`.
745    ///
746    /// Each dispatch spins up a fresh `mlua::Lua` VM, injects globals
747    /// (`_PROMPT` / `_AGENT` / `_TASK_ID` / `_ATTEMPT` / `_CTX` — the last
748    /// is `_PROMPT` parsed as JSON, or `nil` if that fails), evaluates
749    /// the script, and marshals the returned table into a `WorkerResult`.
750    ///
751    /// Marshalling rules for the return value:
752    /// - `{ value = ..., ok = bool }` → `WorkerResult.value` /
753    ///   `WorkerResult.ok` verbatim.
754    /// - Anything else → `value = <returned value>`, `ok = true`.
755    ///
756    /// Execution runs on `tokio::task::spawn_blocking` because `mlua::Lua`
757    /// is `!Send` and needs to stay away from the tokio async context.
758    /// Host bridges (the Lua-to-Rust callback path) previously registered
759    /// with [`Self::with_bridge`] are snapshotted at call time and
760    /// injected into every dispatch inside `run_lua_worker`.
761    pub fn register_lua(mut self, fn_id: impl Into<String>, source: LuaScriptSource) -> Self {
762        let source = Arc::new(source);
763        let bridges = Arc::new(self.bridges.clone());
764        let wrapped: WorkerFn = Arc::new(move |inv| {
765            let source = source.clone();
766            let bridges = bridges.clone();
767            Box::pin(run_lua_worker(source, bridges, inv))
768        });
769        self.registry.insert(fn_id.into(), wrapped);
770        self
771    }
772}
773
774/// Body of a single Lua-eval invocation (called from `register_lua`).
775async fn run_lua_worker(
776    source: Arc<LuaScriptSource>,
777    bridges: Arc<HashMap<String, HostBridge>>,
778    inv: crate::worker::adapter::WorkerInvocation,
779) -> Result<crate::worker::adapter::WorkerResult, crate::worker::adapter::WorkerError> {
780    use crate::worker::adapter::WorkerError;
781    use mlua::LuaSerdeExt;
782
783    let label = source.label.clone();
784    let outcome =
785        tokio::task::spawn_blocking(move || -> Result<(serde_json::Value, bool), String> {
786            let lua = mlua::Lua::new();
787            let g = lua.globals();
788
789            // 1. Base globals.
790            g.set("_PROMPT", inv.prompt.clone())
791                .map_err(|e| format!("set _PROMPT: {e}"))?;
792            g.set("_AGENT", inv.agent.clone())
793                .map_err(|e| format!("set _AGENT: {e}"))?;
794            g.set("_TASK_ID", inv.task_id.to_string())
795                .map_err(|e| format!("set _TASK_ID: {e}"))?;
796            g.set("_ATTEMPT", inv.attempt as i64)
797                .map_err(|e| format!("set _ATTEMPT: {e}"))?;
798
799            // 2. _CTX = JSON parse(_PROMPT); nil on parse failure (co-exists with the plain-string prompt path).
800            if let Ok(json_val) = serde_json::from_str::<serde_json::Value>(&inv.prompt) {
801                let lua_val = lua
802                    .to_value(&json_val)
803                    .map_err(|e| format!("_CTX to_value: {e}"))?;
804                g.set("_CTX", lua_val)
805                    .map_err(|e| format!("set _CTX: {e}"))?;
806            }
807
808            // 3. Inject the host bridge (Lua can call `host.<name>(arg)`).
809            if !bridges.is_empty() {
810                let host = lua
811                    .create_table()
812                    .map_err(|e| format!("create host table: {e}"))?;
813                for (name, bridge) in bridges.iter() {
814                    let bridge = bridge.clone();
815                    let bname = name.clone();
816                    let f = lua
817                        .create_function(move |lua, arg: mlua::Value| {
818                            let json_arg: serde_json::Value = lua.from_value(arg).map_err(|e| {
819                                mlua::Error::external(format!("bridge {bname} arg → json: {e}"))
820                            })?;
821                            let result_json =
822                                bridge.call(json_arg).map_err(mlua::Error::external)?;
823                            lua.to_value(&result_json).map_err(|e| {
824                                mlua::Error::external(format!("bridge {bname} ret → lua: {e}"))
825                            })
826                        })
827                        .map_err(|e| format!("create_function {name}: {e}"))?;
828                    host.set(name.as_str(), f)
829                        .map_err(|e| format!("host.{name} set: {e}"))?;
830                }
831                g.set("host", host).map_err(|e| format!("set host: {e}"))?;
832            }
833
834            // 4. eval
835            let result: mlua::Value = lua
836                .load(&source.source)
837                .set_name(&source.label)
838                .eval()
839                .map_err(|e| format!("lua eval [{}]: {e}", source.label))?;
840
841            // 5. Marshal: shape `{ value=..., ok=true }` or raw value.
842            let json_result: serde_json::Value = lua
843                .from_value(result)
844                .map_err(|e| format!("lua → json [{}]: {e}", source.label))?;
845
846            let (value, ok) = match &json_result {
847                serde_json::Value::Object(map)
848                    if map.contains_key("value") || map.contains_key("ok") =>
849                {
850                    let ok = map.get("ok").and_then(|v| v.as_bool()).unwrap_or(true);
851                    let value = map.get("value").cloned().unwrap_or(json_result.clone());
852                    (value, ok)
853                }
854                _ => (json_result, true),
855            };
856            Ok((value, ok))
857        })
858        .await
859        .map_err(|e| WorkerError::Failed(format!("spawn_blocking join [{label}]: {e}")))?
860        .map_err(WorkerError::Failed)?;
861
862    Ok(crate::worker::adapter::WorkerResult {
863        value: outcome.0,
864        ok: outcome.1,
865    })
866}
867
868impl Default for LuaInProcessSpawnerFactory {
869    fn default() -> Self {
870        Self::new()
871    }
872}
873
874impl SpawnerFactoryKind for LuaInProcessSpawnerFactory {
875    const KIND: AgentKind = AgentKind::Lua;
876    type Worker = LuaWorker;
877}
878
879impl SpawnerFactory for LuaInProcessSpawnerFactory {
880    fn build(
881        &self,
882        agent_def: &AgentDef,
883        _hint: Option<&Value>,
884    ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
885        // Inline `spec.source` (a Lua chunk carried by the BP itself) takes
886        // precedence over `spec.fn_id`. This is the path a BP author uses to
887        // ship a deterministic Lua gate without pre-registering it with the
888        // factory — the plumbing (`run_lua_worker` / `LuaScriptSource`) is
889        // the same, only the entry point differs.
890        if let Some(source) = agent_def.spec.get("source").and_then(|v| v.as_str()) {
891            let label = agent_def
892                .spec
893                .get("label")
894                .and_then(|v| v.as_str())
895                .map(str::to_string)
896                .unwrap_or_else(|| format!("{}.lua", agent_def.name));
897            let script = Arc::new(LuaScriptSource::new(source.to_string(), label));
898            let bridges = Arc::new(self.bridges.clone());
899            let wrapped: WorkerFn = Arc::new(move |inv| {
900                let source = script.clone();
901                let bridges = bridges.clone();
902                Box::pin(run_lua_worker(source, bridges, inv))
903            });
904            let mut sp: InProcSpawner<LuaWorker> = InProcSpawner::<LuaWorker>::typed();
905            sp.registry.insert(agent_def.name.to_string(), wrapped);
906            return Ok(Arc::new(sp));
907        }
908        build_inproc_from_registry::<LuaWorker>(&self.registry, agent_def, "lua")
909    }
910}
911
912/// Factory for `AgentKind::RustFn`. At `build` time it looks the `fn_id`
913/// up in its internal registry and returns an [`InProcSpawner`] with the
914/// Rust closure `WorkerFn` registered under `agent_name`.
915///
916/// Naming convention: `<WorkerIMPL><AdapterType>SpawnerFactory` (RustFn
917/// worker on InProcess adapter). Sibling to
918/// [`LuaInProcessSpawnerFactory`] — the Lua-worker half of the same
919/// split.
920///
921/// Spec shape:
922/// ```jsonc
923/// { "fn_id": "echo" }     // Rust closure id pre-registered with the factory
924/// ```
925pub struct RustFnInProcessSpawnerFactory {
926    registry: HashMap<String, WorkerFn>,
927}
928
929impl RustFnInProcessSpawnerFactory {
930    /// Start with no registered closures.
931    pub fn new() -> Self {
932        Self {
933            registry: HashMap::new(),
934        }
935    }
936
937    /// Register a Rust closure `WorkerFn` under `fn_id`, wrapping it so
938    /// it matches the `WorkerFn` signature (boxed, pinned future).
939    pub fn register_fn<F, Fut>(mut self, fn_id: impl Into<String>, f: F) -> Self
940    where
941        F: Fn(crate::worker::adapter::WorkerInvocation) -> Fut + Send + Sync + 'static,
942        Fut: std::future::Future<
943                Output = Result<
944                    crate::worker::adapter::WorkerResult,
945                    crate::worker::adapter::WorkerError,
946                >,
947            > + Send
948            + 'static,
949    {
950        let f = Arc::new(f);
951        let wrapped: WorkerFn = Arc::new(move |inv| {
952            let f = f.clone();
953            Box::pin(f(inv))
954        });
955        self.registry.insert(fn_id.into(), wrapped);
956        self
957    }
958}
959
960impl Default for RustFnInProcessSpawnerFactory {
961    fn default() -> Self {
962        Self::new()
963    }
964}
965
966impl SpawnerFactoryKind for RustFnInProcessSpawnerFactory {
967    const KIND: AgentKind = AgentKind::RustFn;
968    type Worker = RustFnWorker;
969}
970
971impl SpawnerFactory for RustFnInProcessSpawnerFactory {
972    fn build(
973        &self,
974        agent_def: &AgentDef,
975        _hint: Option<&Value>,
976    ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
977        build_inproc_from_registry::<RustFnWorker>(&self.registry, agent_def, "rust_fn")
978    }
979}
980
981/// Shared build helper used by both the Lua and the RustFn factories —
982/// look `spec.fn_id` up in the registry and return an `InProcSpawner`.
983/// The generic type parameter `W` fixes the per-kind Worker concrete
984/// type at the type level (the build-site half of the trait's
985/// associated-type binding across the four-layer cascade).
986fn build_inproc_from_registry<W>(
987    registry: &HashMap<String, WorkerFn>,
988    agent_def: &AgentDef,
989    kind_label: &str,
990) -> Result<Arc<dyn SpawnerAdapter>, CompileError>
991where
992    W: crate::worker::Worker + From<crate::worker::WorkerJoinHandler> + Send + Sync + 'static,
993{
994    let agent_name = &agent_def.name;
995    let spec = &agent_def.spec;
996    let invalid = |msg: String| CompileError::InvalidSpec {
997        name: agent_name.to_string(),
998        msg,
999    };
1000    let fn_id = spec
1001        .get("fn_id")
1002        .and_then(|v| v.as_str())
1003        .ok_or_else(|| invalid(format!("{kind_label} spec: 'fn_id' (string) required")))?;
1004    let f = registry
1005        .get(fn_id)
1006        .cloned()
1007        .ok_or_else(|| invalid(format!("fn_id '{fn_id}' not registered in factory")))?;
1008    let mut sp: InProcSpawner<W> = InProcSpawner::<W>::typed();
1009    // Register under `agent_name` (the flow's `Step.ref`). Both
1010    // `CompiledAgentTable` and the `InProcSpawner` look the function up
1011    // by name, so the same key is needed at both layers.
1012    sp.registry.insert(agent_name.to_string(), f);
1013    Ok(Arc::new(sp))
1014}
1015
1016/// Concrete Worker type for the Lua kind — a handle to a Lua-eval task
1017/// inside an mlua VM. Embeds a `WorkerJoinHandler`. Reserved as the home
1018/// for future Lua-specific extensions (an mlua VM cancellation
1019/// mechanism, Lua-side error type retention, and so on).
1020pub struct LuaWorker {
1021    /// The join handle / cancellation token for the underlying task.
1022    pub handler: crate::worker::WorkerJoinHandler,
1023}
1024
1025impl From<crate::worker::WorkerJoinHandler> for LuaWorker {
1026    fn from(handler: crate::worker::WorkerJoinHandler) -> Self {
1027        Self { handler }
1028    }
1029}
1030
1031#[async_trait::async_trait]
1032impl crate::worker::Worker for LuaWorker {
1033    fn id(&self) -> &crate::types::WorkerId {
1034        &self.handler.worker_id
1035    }
1036    fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
1037        self.handler.cancel.clone()
1038    }
1039    async fn join(self: Box<Self>) -> Result<(), crate::worker::adapter::WorkerError> {
1040        self.handler.await_completion().await
1041    }
1042}
1043
1044/// Concrete Worker type for the RustFn kind — a handle to a task that
1045/// directly calls a Rust closure. Embeds a `WorkerJoinHandler`. Being a
1046/// pure function, there is minimal kind-specific extension surface here;
1047/// the primary purpose is to nail down the type binding.
1048pub struct RustFnWorker {
1049    /// The join handle / cancellation token for the underlying task.
1050    pub handler: crate::worker::WorkerJoinHandler,
1051}
1052
1053impl From<crate::worker::WorkerJoinHandler> for RustFnWorker {
1054    fn from(handler: crate::worker::WorkerJoinHandler) -> Self {
1055        Self { handler }
1056    }
1057}
1058
1059#[async_trait::async_trait]
1060impl crate::worker::Worker for RustFnWorker {
1061    fn id(&self) -> &crate::types::WorkerId {
1062        &self.handler.worker_id
1063    }
1064    fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
1065        self.handler.cancel.clone()
1066    }
1067    async fn join(self: Box<Self>) -> Result<(), crate::worker::adapter::WorkerError> {
1068        self.handler.await_completion().await
1069    }
1070}
1071
1072/// Factory for `AgentKind::Operator`. Looks up the `Arc<dyn Operator>`
1073/// pre-registered under `spec.operator_ref` and wraps it in an
1074/// `OperatorSpawner`. Also resolves `AgentDef.profile.worker_binding` into
1075/// a `WorkerBinding` at compile time and fails loud (`CompileError::InvalidSpec`)
1076/// when the resolved operator's `Operator::requires_worker_binding` is `true`
1077/// and no binding was declared.
1078///
1079/// Spec shape:
1080/// ```jsonc
1081/// { "operator_ref": "main_ai" }     // Operator id pre-registered with the factory
1082/// ```
1083///
1084/// # Split of responsibilities with `OperatorDelegateMiddleware`
1085///
1086/// The two axes exist for different reasons:
1087///
1088/// - **This factory (`OperatorSpawnerFactory` → `OperatorSpawner`) — the
1089///   AgentSpec axis.** Bakes a separate Operator backend into each
1090///   `AgentDef`. A `kind = Operator` `AgentDef` names its backend through
1091///   `spec.operator_ref`; at `compile()` time the `Arc<dyn Operator>` is
1092///   baked into `routes[agent_name]`. Because the `agent.md` loader
1093///   (`agent_md_loader`) defaults `kind` to `Operator`, agents that flow
1094///   in through agent-profiles land here.
1095///
1096/// - **`OperatorDelegateMiddleware` — the Blueprint-global (session)
1097///   axis.** Delegates every agent to the same Operator backend. At
1098///   session-attach time you call `engine.register_operator(id, op)`
1099///   plus `attach_with_ids(.., operator_backend_id = Some(id))` to bind
1100///   it session-wide, and declare
1101///   `spawner_hints.layers = ["operator_delegate"]` to opt in. `ctx.agent`
1102///   is ignored; the operator handles every spawn in that session (a
1103///   MainAI-wide driver, a human-wide console, that sort of thing).
1104///
1105/// # Exclusivity (a double fire is structurally impossible)
1106///
1107/// When both are effective — the hint is declared, the session has an
1108/// operator backend, **and** the Blueprint has a `kind = Operator`
1109/// `AgentDef` — `OperatorDelegateMiddleware` sits at the outer end of
1110/// the stack and **completely bypasses** `inner.spawn`. The
1111/// `OperatorSpawner` is never reached, so under those conditions this
1112/// factory's routes entry is inert. This is not a double fire — the
1113/// session axis is overriding the agent axis. Consistent usage means
1114/// picking one axis per use case.
1115///
1116/// Interior mutability is provided by an `Arc<RwLock>`. Even after the
1117/// factory has been stored as `Arc<dyn SpawnerFactory>` in
1118/// `SpawnerRegistry`, a caller holding an `Arc` clone can still add
1119/// Operator backends dynamically via `register_operator(&self, id, op)`.
1120/// Typical uses: registering a `WSOperatorSession` under the session id
1121/// on WebSocket connect, binding agents that arrive via the `agent.md`
1122/// loader to arbitrary backends, and so on. `build()` performs a
1123/// `read()` lookup each time.
1124pub struct OperatorSpawnerFactory {
1125    operators: Arc<std::sync::RwLock<HashMap<String, Arc<dyn Operator>>>>,
1126}
1127
1128impl OperatorSpawnerFactory {
1129    /// Start with no registered Operator backends.
1130    pub fn new() -> Self {
1131        Self {
1132            operators: Arc::new(std::sync::RwLock::new(HashMap::new())),
1133        }
1134    }
1135
1136    /// Register an Operator backend dynamically through `&self`.
1137    /// Overwrites are allowed — later wins. Callers can still reach this
1138    /// after the factory has been stored as `Arc<dyn SpawnerFactory>` in
1139    /// `SpawnerRegistry`, as long as they hold an `Arc` clone; interior
1140    /// mutability is provided by the inner `RwLock`.
1141    pub fn register_operator(&self, id: impl Into<String>, op: Arc<dyn Operator>) -> &Self {
1142        self.operators
1143            .write()
1144            .expect("OperatorSpawnerFactory.operators RwLock poisoned")
1145            .insert(id.into(), op);
1146        self
1147    }
1148
1149    /// Dynamically unregister an id (used to clean up when a WebSocket
1150    /// disconnects, for example). A missing id is a no-op.
1151    pub fn unregister_operator(&self, id: &str) -> &Self {
1152        self.operators
1153            .write()
1154            .expect("OperatorSpawnerFactory.operators RwLock poisoned")
1155            .remove(id);
1156        self
1157    }
1158}
1159
1160impl Default for OperatorSpawnerFactory {
1161    fn default() -> Self {
1162        Self::new()
1163    }
1164}
1165
1166impl SpawnerFactoryKind for OperatorSpawnerFactory {
1167    const KIND: AgentKind = AgentKind::Operator;
1168    type Worker = crate::operator::OperatorWorker;
1169}
1170
1171impl SpawnerFactory for OperatorSpawnerFactory {
1172    fn build(
1173        &self,
1174        agent_def: &AgentDef,
1175        _hint: Option<&Value>,
1176    ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
1177        let agent_name = &agent_def.name;
1178        let spec = &agent_def.spec;
1179        // Bake AgentDef.profile.system_prompt into the OperatorSpawner at compile time.
1180        // `Some` → adopted first at spawn time; `None` → falls back to fetch_prompt (initial_directive).
1181        // Fallback path. Sibling: AgentBlockInProcessSpawnerFactory
1182        // (agent_block/runtime.rs) does the same compile-time bake by stuffing
1183        // the profile into BlockConfig.context.
1184        let system_prompt = agent_def.profile.as_ref().map(|p| p.system_prompt.clone());
1185        let invalid = |msg: String| CompileError::InvalidSpec {
1186            name: agent_name.to_string(),
1187            msg,
1188        };
1189        let op_ref = spec
1190            .get("operator_ref")
1191            .and_then(|v| v.as_str())
1192            .ok_or_else(|| invalid("operator spec: 'operator_ref' (string) required".into()))?;
1193        let operators = self
1194            .operators
1195            .read()
1196            .expect("OperatorSpawnerFactory.operators RwLock poisoned");
1197        let op = operators.get(op_ref).cloned().ok_or_else(|| {
1198            let mut names: Vec<String> = operators.keys().cloned().collect();
1199            names.sort();
1200            let names_list = if names.is_empty() {
1201                "<none>".to_string()
1202            } else {
1203                names.join(", ")
1204            };
1205            invalid(format!(
1206                "operator_ref '{op_ref}' not registered in factory. \
1207                 Registered sids: [{names_list}]. \
1208                 Hint: call mse_operator_join(roles=[...]) to mint the sid first."
1209            ))
1210        })?;
1211        drop(operators);
1212
1213        // Resolve the Blueprint-baked worker binding from
1214        // `AgentDef.profile.worker_binding` — the SoT for the
1215        // declaration↔executor binding (see `WorkerBinding` doc). Fail
1216        // loud at compile time when the operator backend requires one
1217        // and the Blueprint didn't declare it; this is a compile-time
1218        // gate, not a runtime guess.
1219        let worker_binding = agent_def
1220            .profile
1221            .as_ref()
1222            .and_then(|p| p.worker_binding.as_ref())
1223            .map(|variant| WorkerBinding {
1224                variant: variant.clone(),
1225                tools: agent_def
1226                    .profile
1227                    .as_ref()
1228                    .map(|p| p.tools.clone())
1229                    .unwrap_or_default(),
1230            });
1231        if op.requires_worker_binding() && worker_binding.is_none() {
1232            // Issue #9: the two Blueprint authoring paths (direct JSON
1233            // and `$agent_md` file ref) both land here. Old message
1234            // pointed only at the `.md` frontmatter, which was
1235            // confusing for authors on the JSON-direct path.
1236            return Err(invalid(
1237                "profile.worker_binding is required for this operator backend. \
1238                 Fix by either: \
1239                 (a) if authoring the Blueprint JSON directly, add \
1240                 `agents[N].profile.worker_binding: \"<subagent-type>\"` \
1241                 to the JSON literal; or \
1242                 (b) if using an $agent_md file ref, add \
1243                 `worker_binding: <subagent-type>` to the agent .md frontmatter."
1244                    .into(),
1245            ));
1246        }
1247        Ok(Arc::new(OperatorSpawner::new(
1248            op,
1249            system_prompt,
1250            worker_binding,
1251        )))
1252    }
1253}
1254
1255#[cfg(test)]
1256mod operator_spawner_factory_worker_binding_tests {
1257    use super::*;
1258    use crate::blueprint::AgentProfile;
1259    use crate::core::ctx::Ctx;
1260    use crate::types::CapToken;
1261    use crate::worker::adapter::{WorkerError, WorkerResult};
1262
1263    /// Minimal `Operator` stub whose `requires_worker_binding` is
1264    /// configurable — enough to exercise the compile-time fail-loud gate
1265    /// without standing up a real backend (e.g. `WSOperatorSession`,
1266    /// which lives in a downstream crate).
1267    struct StubOperator {
1268        requires_binding: bool,
1269    }
1270
1271    #[async_trait]
1272    impl Operator for StubOperator {
1273        async fn execute(
1274            &self,
1275            _ctx: &Ctx,
1276            _system: Option<String>,
1277            _prompt: Value,
1278            _worker: Option<WorkerBinding>,
1279            _worker_token: CapToken,
1280        ) -> Result<WorkerResult, WorkerError> {
1281            Ok(WorkerResult {
1282                value: Value::Null,
1283                ok: true,
1284            })
1285        }
1286
1287        fn requires_worker_binding(&self) -> bool {
1288            self.requires_binding
1289        }
1290    }
1291
1292    fn agent_def_with(profile: Option<AgentProfile>) -> AgentDef {
1293        AgentDef {
1294            name: "test-agent".to_string(),
1295            kind: AgentKind::Operator,
1296            spec: serde_json::json!({ "operator_ref": "op1" }),
1297            profile,
1298            meta: None,
1299        }
1300    }
1301
1302    #[test]
1303    fn build_fails_loud_when_binding_required_but_absent() {
1304        let factory = OperatorSpawnerFactory::new();
1305        factory.register_operator(
1306            "op1",
1307            Arc::new(StubOperator {
1308                requires_binding: true,
1309            }) as Arc<dyn Operator>,
1310        );
1311        let def = agent_def_with(Some(AgentProfile::default()));
1312        match factory.build(&def, None) {
1313            Err(CompileError::InvalidSpec { name, msg }) => {
1314                assert_eq!(name, "test-agent");
1315                assert!(
1316                    msg.contains("worker_binding is required"),
1317                    "unexpected message: {msg}"
1318                );
1319                // Issue #9: the message must be actionable for both
1320                // authoring paths — the JSON-direct hint and the
1321                // $agent_md hint both surface.
1322                assert!(
1323                    msg.contains("agents[N].profile.worker_binding"),
1324                    "message missing JSON-direct hint (issue #9): {msg}"
1325                );
1326                assert!(
1327                    msg.contains("agent .md frontmatter"),
1328                    "message missing $agent_md hint: {msg}"
1329                );
1330            }
1331            Err(other) => panic!("expected InvalidSpec, got: {other:?}"),
1332            Ok(_) => panic!("expected compile-time failure, got Ok"),
1333        }
1334    }
1335
1336    #[test]
1337    fn build_succeeds_when_binding_required_and_present() {
1338        let factory = OperatorSpawnerFactory::new();
1339        factory.register_operator(
1340            "op1",
1341            Arc::new(StubOperator {
1342                requires_binding: true,
1343            }) as Arc<dyn Operator>,
1344        );
1345        let profile = AgentProfile {
1346            worker_binding: Some("mse-worker-coder".to_string()),
1347            tools: vec!["Read".to_string(), "Edit".to_string()],
1348            ..Default::default()
1349        };
1350        let def = agent_def_with(Some(profile));
1351        assert!(
1352            factory.build(&def, None).is_ok(),
1353            "expected Ok when worker_binding is declared"
1354        );
1355    }
1356
1357    #[test]
1358    fn build_succeeds_when_binding_not_required_and_absent() {
1359        let factory = OperatorSpawnerFactory::new();
1360        factory.register_operator(
1361            "op1",
1362            Arc::new(StubOperator {
1363                requires_binding: false,
1364            }) as Arc<dyn Operator>,
1365        );
1366        let def = agent_def_with(Some(AgentProfile::default()));
1367        assert!(
1368            factory.build(&def, None).is_ok(),
1369            "backends that don't require a binding must not be gated by its absence"
1370        );
1371    }
1372}
1373
1374// ─── LuaInProcessSpawnerFactory: inline `spec.source` support ─────────────
1375//
1376// Issue `ab3d1145`: BPs served by `mse serve` couldn't declare `kind: lua`
1377// without pre-registering a `fn_id` on the factory. These tests cover the
1378// new inline path — `spec.source = "<lua chunk>"` (optionally with `label`)
1379// wraps a fresh `LuaScriptSource` at `build` time and runs it through the
1380// same `run_lua_worker` plumbing as the registry path.
1381#[cfg(test)]
1382mod lua_inline_source_tests {
1383    use super::*;
1384    use crate::types::{CapToken, Role, StepId};
1385
1386    fn agent(name: &str, spec: Value) -> AgentDef {
1387        AgentDef {
1388            name: name.to_string(),
1389            kind: AgentKind::Lua,
1390            spec,
1391            profile: None,
1392            meta: None,
1393        }
1394    }
1395
1396    fn test_invocation(prompt: &str) -> crate::worker::adapter::WorkerInvocation {
1397        crate::worker::adapter::WorkerInvocation {
1398            token: CapToken {
1399                agent_id: "a".into(),
1400                role: Role::Worker,
1401                scopes: vec!["*".into()],
1402                issued_at: 0,
1403                expire_at: u64::MAX / 2,
1404                max_uses: None,
1405                nonce: "test-nonce".into(),
1406                sig_hex: "".into(),
1407            },
1408            task_id: StepId::parse("ST-test").expect("StepId parse"),
1409            attempt: 1,
1410            agent: "g".into(),
1411            prompt: prompt.into(),
1412            sink: None,
1413            cancel_token: None,
1414        }
1415    }
1416
1417    #[test]
1418    fn build_accepts_inline_source_without_pre_registration() {
1419        let factory = LuaInProcessSpawnerFactory::new();
1420        let def = agent(
1421            "g",
1422            serde_json::json!({ "source": "return { value = 42, ok = true }" }),
1423        );
1424        assert!(
1425            factory.build(&def, None).is_ok(),
1426            "inline spec.source must build without a pre-registered fn_id"
1427        );
1428    }
1429
1430    #[test]
1431    fn build_rejects_when_neither_source_nor_fn_id_is_present() {
1432        let factory = LuaInProcessSpawnerFactory::new();
1433        let def = agent("g", serde_json::json!({}));
1434        match factory.build(&def, None) {
1435            Err(CompileError::InvalidSpec { msg, .. }) => {
1436                assert!(
1437                    msg.contains("fn_id"),
1438                    "empty spec must still surface the fn_id-required message: {msg}"
1439                );
1440            }
1441            Err(other) => panic!("expected InvalidSpec, got a different CompileError: {other}"),
1442            // `SpawnerAdapter` is not Debug, so we can't `unwrap_err()` /
1443            // pattern-print the Ok arm — describe the mismatch directly.
1444            Ok(_) => panic!("expected InvalidSpec, got Ok(SpawnerAdapter)"),
1445        }
1446    }
1447
1448    /// The inline path shares `run_lua_worker` with the registry path, so
1449    /// exercising the marshaller once through it is enough to prove the
1450    /// wrap is faithful.
1451    #[tokio::test]
1452    async fn inline_source_evaluates_and_marshals_result() {
1453        let source =
1454            LuaScriptSource::new("return { value = _PROMPT .. '!', ok = true }", "smoke.lua");
1455        let out = run_lua_worker(
1456            std::sync::Arc::new(source),
1457            std::sync::Arc::new(HashMap::new()),
1458            test_invocation("hello"),
1459        )
1460        .await
1461        .expect("lua worker ok");
1462        assert_eq!(out.value, serde_json::json!("hello!"));
1463        assert!(out.ok);
1464    }
1465
1466    #[tokio::test]
1467    async fn inline_source_can_signal_agent_level_failure() {
1468        // Deterministic gate pattern: return `ok = false` to flip the
1469        // dispatch outcome to `Blocked` (the flow.ir Try catch path).
1470        let source = LuaScriptSource::new("return { value = 'nope', ok = false }", "gate.lua");
1471        let out = run_lua_worker(
1472            std::sync::Arc::new(source),
1473            std::sync::Arc::new(HashMap::new()),
1474            test_invocation("input"),
1475        )
1476        .await
1477        .expect("lua worker ok");
1478        assert_eq!(out.value, serde_json::json!("nope"));
1479        assert!(!out.ok);
1480    }
1481}
1482
1483// ─── GH #21 Phase 2: `Blueprint.metas` / `AgentMeta.meta_ref` / static
1484// `$step_meta.ref` compile-time validation ─────────────────────────────────
1485#[cfg(test)]
1486mod meta_ref_validation_tests {
1487    use super::*;
1488    use crate::blueprint::{AgentMeta, MetaDef};
1489    use crate::worker::adapter::WorkerResult;
1490
1491    fn registry_with_echo() -> SpawnerRegistry {
1492        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1493            Ok(WorkerResult {
1494                value: Value::String(inv.prompt),
1495                ok: true,
1496            })
1497        });
1498        let mut reg = SpawnerRegistry::new();
1499        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
1500        reg
1501    }
1502
1503    fn rustfn_agent(name: &str) -> AgentDef {
1504        AgentDef {
1505            name: name.to_string(),
1506            kind: AgentKind::RustFn,
1507            spec: serde_json::json!({ "fn_id": "echo" }),
1508            profile: None,
1509            meta: None,
1510        }
1511    }
1512
1513    fn simple_flow(agent_ref: &str, in_: Expr) -> FlowNode {
1514        FlowNode::Step {
1515            ref_: agent_ref.to_string(),
1516            in_,
1517            out: Expr::Path {
1518                at: "$.output".parse().expect("literal test path: $.output"),
1519            },
1520        }
1521    }
1522
1523    fn minimal_bp(agents: Vec<AgentDef>, metas: Vec<MetaDef>, flow: FlowNode) -> Blueprint {
1524        Blueprint {
1525            schema_version: crate::blueprint::current_schema_version(),
1526            id: "meta-ref-ut".into(),
1527            flow,
1528            agents,
1529            operators: vec![],
1530            metas,
1531            hints: Default::default(),
1532            strategy: Default::default(),
1533            metadata: BlueprintMetadata::default(),
1534            spawner_hints: Default::default(),
1535            default_agent_kind: AgentKind::Operator,
1536            default_operator_kind: None,
1537            default_init_ctx: None,
1538            default_agent_ctx: None,
1539            default_context_policy: None,
1540            projection_placement: None,
1541            audits: vec![],
1542            degradation_policy: None,
1543        }
1544    }
1545
1546    #[test]
1547    fn valid_meta_ref_compiles() {
1548        let mut agent = rustfn_agent("worker");
1549        agent.meta = Some(AgentMeta {
1550            meta_ref: Some("shared".to_string()),
1551            ..Default::default()
1552        });
1553        let bp = minimal_bp(
1554            vec![agent],
1555            vec![MetaDef {
1556                name: "shared".into(),
1557                ctx: serde_json::json!({ "k": "v" }),
1558            }],
1559            simple_flow(
1560                "worker",
1561                Expr::Path {
1562                    at: "$.input".parse().expect("literal test path: $.input"),
1563                },
1564            ),
1565        );
1566        let compiler = Compiler::new(registry_with_echo());
1567        assert!(
1568            compiler.compile(&bp).is_ok(),
1569            "a resolvable AgentMeta.meta_ref must compile"
1570        );
1571    }
1572
1573    #[test]
1574    fn unknown_agent_meta_ref_is_unresolved_meta_ref() {
1575        let mut agent = rustfn_agent("worker");
1576        agent.meta = Some(AgentMeta {
1577            meta_ref: Some("missing".to_string()),
1578            ..Default::default()
1579        });
1580        let bp = minimal_bp(
1581            vec![agent],
1582            vec![],
1583            simple_flow(
1584                "worker",
1585                Expr::Path {
1586                    at: "$.input".parse().expect("literal test path: $.input"),
1587                },
1588            ),
1589        );
1590        let compiler = Compiler::new(registry_with_echo());
1591        match compiler.compile(&bp) {
1592            Err(CompileError::UnresolvedMetaRef {
1593                where_,
1594                meta_ref,
1595                defined,
1596            }) => {
1597                assert!(
1598                    where_.contains("worker"),
1599                    "where_ must name the agent: {where_}"
1600                );
1601                assert_eq!(meta_ref, "missing");
1602                assert!(defined.is_empty());
1603            }
1604            Err(other) => {
1605                panic!("expected UnresolvedMetaRef, got a different CompileError: {other}")
1606            }
1607            Ok(_) => panic!("expected compile-time failure, got Ok"),
1608        }
1609    }
1610
1611    #[test]
1612    fn unknown_static_step_meta_ref_in_lit_is_unresolved_meta_ref() {
1613        let agent = rustfn_agent("worker");
1614        let in_ = Expr::Lit {
1615            value: serde_json::json!({ "$step_meta": { "ref": "missing" }, "$in": "go" }),
1616        };
1617        let bp = minimal_bp(vec![agent], vec![], simple_flow("worker", in_));
1618        let compiler = Compiler::new(registry_with_echo());
1619        match compiler.compile(&bp) {
1620            Err(CompileError::UnresolvedMetaRef {
1621                where_, meta_ref, ..
1622            }) => {
1623                assert!(
1624                    where_.contains("worker"),
1625                    "where_ must name the offending step: {where_}"
1626                );
1627                assert_eq!(meta_ref, "missing");
1628            }
1629            Err(other) => {
1630                panic!("expected UnresolvedMetaRef, got a different CompileError: {other}")
1631            }
1632            Ok(_) => panic!("expected compile-time failure, got Ok"),
1633        }
1634    }
1635
1636    #[test]
1637    fn path_op_input_with_no_static_envelope_compiles_fine() {
1638        let agent = rustfn_agent("worker");
1639        let bp = minimal_bp(
1640            vec![agent],
1641            vec![],
1642            simple_flow(
1643                "worker",
1644                Expr::Path {
1645                    at: "$.input".parse().expect("literal test path: $.input"),
1646                },
1647            ),
1648        );
1649        let compiler = Compiler::new(registry_with_echo());
1650        assert!(
1651            compiler.compile(&bp).is_ok(),
1652            "a non-Lit Step.in must not trigger the best-effort static $step_meta check"
1653        );
1654    }
1655}
1656
1657// ─── GH #34: `Blueprint.audits[].agent` compile-time validation ────────────
1658#[cfg(test)]
1659mod audit_agent_validation_tests {
1660    use super::*;
1661    use crate::worker::adapter::WorkerResult;
1662    use mlua_swarm_schema::{AuditDef, AuditMode};
1663
1664    fn registry_with_echo() -> SpawnerRegistry {
1665        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1666            Ok(WorkerResult {
1667                value: Value::String(inv.prompt),
1668                ok: true,
1669            })
1670        });
1671        let mut reg = SpawnerRegistry::new();
1672        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
1673        reg
1674    }
1675
1676    fn rustfn_agent(name: &str) -> AgentDef {
1677        AgentDef {
1678            name: name.to_string(),
1679            kind: AgentKind::RustFn,
1680            spec: serde_json::json!({ "fn_id": "echo" }),
1681            profile: None,
1682            meta: None,
1683        }
1684    }
1685
1686    fn minimal_bp(agents: Vec<AgentDef>, audits: Vec<AuditDef>) -> Blueprint {
1687        Blueprint {
1688            schema_version: crate::blueprint::current_schema_version(),
1689            id: "audit-ref-ut".into(),
1690            flow: FlowNode::Step {
1691                ref_: "worker".to_string(),
1692                in_: Expr::Path {
1693                    at: "$.input".parse().expect("literal test path: $.input"),
1694                },
1695                out: Expr::Path {
1696                    at: "$.output".parse().expect("literal test path: $.output"),
1697                },
1698            },
1699            agents,
1700            operators: vec![],
1701            metas: vec![],
1702            hints: Default::default(),
1703            strategy: Default::default(),
1704            metadata: BlueprintMetadata::default(),
1705            spawner_hints: Default::default(),
1706            default_agent_kind: AgentKind::Operator,
1707            default_operator_kind: None,
1708            default_init_ctx: None,
1709            default_agent_ctx: None,
1710            default_context_policy: None,
1711            projection_placement: None,
1712            audits,
1713            degradation_policy: None,
1714        }
1715    }
1716
1717    #[test]
1718    fn unresolved_audit_agent_is_a_loud_compile_error() {
1719        let bp = minimal_bp(
1720            vec![rustfn_agent("worker")],
1721            vec![AuditDef {
1722                agent: "missing-auditor".to_string(),
1723                steps: None,
1724                mode: AuditMode::default(),
1725            }],
1726        );
1727        let compiler = Compiler::new(registry_with_echo());
1728        match compiler.compile(&bp) {
1729            Err(CompileError::UnresolvedAuditAgent { agent, defined }) => {
1730                assert_eq!(agent, "missing-auditor");
1731                assert_eq!(defined, vec!["worker".to_string()]);
1732            }
1733            Err(other) => {
1734                panic!("expected UnresolvedAuditAgent, got a different CompileError: {other}")
1735            }
1736            Ok(_) => panic!("expected compile-time failure, got Ok"),
1737        }
1738    }
1739
1740    #[test]
1741    fn resolved_audit_agent_compiles_fine() {
1742        let bp = minimal_bp(
1743            vec![rustfn_agent("worker"), rustfn_agent("auditor")],
1744            vec![AuditDef {
1745                agent: "auditor".to_string(),
1746                steps: None,
1747                mode: AuditMode::default(),
1748            }],
1749        );
1750        let compiler = Compiler::new(registry_with_echo());
1751        assert!(
1752            compiler.compile(&bp).is_ok(),
1753            "an audits[].agent that names a declared AgentDef must compile"
1754        );
1755    }
1756}
1757
1758// ─── GH #27 (follow-up to #23): `Blueprint.projection_placement` compile-time
1759// validation + `CompiledBlueprint.projection_placement` construction ────────
1760#[cfg(test)]
1761mod projection_placement_compile_tests {
1762    use super::*;
1763    use crate::core::projection_placement::{ProjectionPlacement, RootPreference};
1764    use crate::worker::adapter::WorkerResult;
1765    use mlua_swarm_schema::ProjectionPlacementSpec;
1766
1767    fn registry_with_echo() -> SpawnerRegistry {
1768        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1769            Ok(WorkerResult {
1770                value: Value::String(inv.prompt),
1771                ok: true,
1772            })
1773        });
1774        let mut reg = SpawnerRegistry::new();
1775        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
1776        reg
1777    }
1778
1779    fn minimal_bp(projection_placement: Option<ProjectionPlacementSpec>) -> Blueprint {
1780        Blueprint {
1781            schema_version: crate::blueprint::current_schema_version(),
1782            id: "projection-placement-ut".into(),
1783            flow: FlowNode::Step {
1784                ref_: "worker".to_string(),
1785                in_: Expr::Path {
1786                    at: "$.input".parse().expect("literal test path: $.input"),
1787                },
1788                out: Expr::Path {
1789                    at: "$.output".parse().expect("literal test path: $.output"),
1790                },
1791            },
1792            agents: vec![AgentDef {
1793                name: "worker".to_string(),
1794                kind: AgentKind::RustFn,
1795                spec: serde_json::json!({ "fn_id": "echo" }),
1796                profile: None,
1797                meta: None,
1798            }],
1799            operators: vec![],
1800            metas: vec![],
1801            hints: Default::default(),
1802            strategy: Default::default(),
1803            metadata: BlueprintMetadata::default(),
1804            spawner_hints: Default::default(),
1805            default_agent_kind: AgentKind::Operator,
1806            default_operator_kind: None,
1807            default_init_ctx: None,
1808            default_agent_ctx: None,
1809            default_context_policy: None,
1810            projection_placement,
1811            audits: vec![],
1812            degradation_policy: None,
1813        }
1814    }
1815
1816    #[test]
1817    fn undeclared_projection_placement_compiles_to_byte_compat_default() {
1818        let bp = minimal_bp(None);
1819        let compiled = Compiler::new(registry_with_echo())
1820            .compile(&bp)
1821            .expect("undeclared projection_placement compiles");
1822        assert_eq!(
1823            *compiled.projection_placement,
1824            ProjectionPlacement::default()
1825        );
1826    }
1827
1828    #[test]
1829    fn declared_valid_projection_placement_compiles_to_matching_resolver() {
1830        let bp = minimal_bp(Some(ProjectionPlacementSpec {
1831            root: Some("project_root".to_string()),
1832            dir_template: Some("custom/{task_id}/out".to_string()),
1833        }));
1834        let compiled = Compiler::new(registry_with_echo())
1835            .compile(&bp)
1836            .expect("valid projection_placement compiles");
1837        assert_eq!(
1838            compiled.projection_placement.root_preference,
1839            RootPreference::ProjectRoot
1840        );
1841        assert_eq!(
1842            compiled.projection_placement.dir_template,
1843            "custom/{task_id}/out"
1844        );
1845    }
1846
1847    #[test]
1848    fn declared_invalid_dir_template_rejects_compile() {
1849        let bp = minimal_bp(Some(ProjectionPlacementSpec {
1850            root: None,
1851            dir_template: Some("workspace/tasks/ctx".to_string()), // missing {task_id}
1852        }));
1853        match Compiler::new(registry_with_echo()).compile(&bp) {
1854            Err(CompileError::InvalidProjectionPlacement(_)) => {}
1855            Err(other) => {
1856                panic!("expected InvalidProjectionPlacement, got a different CompileError: {other}")
1857            }
1858            Ok(_) => {
1859                panic!("expected compile-time rejection for a missing {{task_id}} placeholder")
1860            }
1861        }
1862    }
1863
1864    #[test]
1865    fn declared_invalid_root_literal_rejects_compile() {
1866        let bp = minimal_bp(Some(ProjectionPlacementSpec {
1867            root: Some("nope".to_string()),
1868            dir_template: None,
1869        }));
1870        match Compiler::new(registry_with_echo()).compile(&bp) {
1871            Err(CompileError::InvalidProjectionPlacement(_)) => {}
1872            Err(other) => {
1873                panic!("expected InvalidProjectionPlacement, got a different CompileError: {other}")
1874            }
1875            Ok(_) => panic!("expected compile-time rejection for an invalid root literal"),
1876        }
1877    }
1878}