Skip to main content

mlua_swarm/service/
task_launch.rs

1//! `TaskLaunchService` — the domain service that runs a Blueprint flow
2//! to completion through the engine.
3//!
4//! Responsibilities:
5//! 1. Compile the Blueprint and link it into a `SpawnerAdapter` (via
6//!    `service::linker::link`, wrapped by `EngineDispatcher::with_spawner`).
7//! 2. Acquire an Operator session (via `engine.attach`).
8//! 3. Run flow.ir's `eval_async_externs` through an `EngineDispatcher`
9//!    (threading the service-held `call_extern` registry) and return
10//!    the final `ctx`.
11//! 4. If any step fails (dispatcher error), the eval errors and
12//!    the failure propagates as-is.
13//!
14//! Callers on the Application layer never touch the engine directly —
15//! `bind`, `start_task`, and `eval_async` all stay inside the Service.
16//!
17//! A single-task-spawn API (calling `start_task` directly) is
18//! deliberately absent here: a single spawn can be modeled as a
19//! one-Step flow, and we do not want two interfaces for the same
20//! shape.
21
22use crate::blueprint::compiler::{CompileError, Compiler};
23use crate::blueprint::{Blueprint, EngineDispatcher};
24use crate::core::agent_context::ContextPolicy;
25use crate::core::ctx::OperatorKind;
26use crate::core::engine::Engine;
27use crate::core::errors::EngineError;
28use crate::middleware::agent_context::AgentContextMiddleware;
29use crate::middleware::project_name_alias::ProjectNameAliasMiddleware;
30use crate::middleware::task_input::TaskInputMiddleware;
31use crate::middleware::worker_binding::WorkerBindingMiddleware;
32use crate::middleware::SpawnerStack;
33use crate::operator::WorkerBinding;
34use crate::service::linker;
35use crate::store::run::RunContext;
36use crate::types::{CapToken, Role};
37use mlua_flow_ir::{Externs, NoExterns};
38use serde::{Deserialize, Serialize};
39use serde_json::Value;
40use std::collections::HashMap;
41use std::sync::Arc;
42use std::time::Duration;
43use thiserror::Error;
44
45/// Derive the "BP Agent-level" tier of the `OperatorKind` cascade from a
46/// Blueprint: for every `AgentDef` whose `spec.operator_ref` resolves to an
47/// `OperatorDef` with a `Some` `kind`, map `AgentDef.name -> OperatorKind`.
48///
49/// Deliberately **not** filtered by `AgentDef.kind == AgentKind::Operator`:
50/// the `OperatorKind` cascade is a middleware-level cross-cutting concern
51/// (spawn_hook / senior_bridge / operator-delegate gating via `Ctx.operator`),
52/// orthogonal to the Worker IMPL axis that `AgentKind` expresses (see the
53/// crate root doc, "Operator is delivered as a cross-cutting overlay through
54/// `Ctx` plus middleware"). A `RustFn` / `Lua` / `Subprocess` agent can
55/// equally declare `spec.operator_ref` to opt into a BP-declared
56/// `OperatorKind` without changing its Worker IMPL. Agents without an
57/// `operator_ref`, an unresolved `operator_ref`, or an `OperatorDef.kind =
58/// None` are simply absent from the map (= that tier falls through for
59/// them). This is a separate, independent consumer of `Blueprint.operators`
60/// from the design-time `operator_ref` validation in
61/// `blueprint::compiler::Compiler::compile` (issue: `OperatorDef`
62/// first-class treatment), which only checks the reference resolves for
63/// `AgentKind::Operator` agents and is unaffected by this function.
64/// Build the `agent name → WorkerBinding` map from
65/// `Blueprint.agents[].profile.worker_binding` — the launch-time sibling of
66/// the compile-time resolution in `OperatorSpawnerFactory::build`. Consumed
67/// by `WorkerBindingMiddleware` so the delegate axis
68/// (`OperatorDelegateMiddleware`) can resolve the binding via `ctx.agent`
69/// like every other agent-keyed table (`CompiledAgentTable.routes` idiom).
70/// Agents without a declared binding are simply absent (no silent default).
71fn derive_worker_bindings(blueprint: &Blueprint) -> HashMap<String, WorkerBinding> {
72    blueprint
73        .agents
74        .iter()
75        .filter_map(|ad| {
76            let profile = ad.profile.as_ref()?;
77            let variant = profile.worker_binding.as_ref()?;
78            Some((
79                ad.name.clone(),
80                WorkerBinding {
81                    variant: variant.clone(),
82                    tools: profile.tools.clone(),
83                },
84            ))
85        })
86        .collect()
87}
88
89/// Issue #21 Phase 1: build the agent-context supply axis's "BP Global" +
90/// "BP Agent-level" context tiers from a Blueprint — the launch-time
91/// sibling of [`derive_worker_bindings`] (same "no silent default"
92/// discipline: an agent's entry is present only when it declares one).
93/// Consumed by `AgentContextMiddleware`, which shallow-merges the two
94/// tiers per spawn (agent wins) and inserts the result into
95/// `ctx.meta.runtime` only-if-absent (see
96/// `crate::middleware::agent_context`'s module doc for the full merge +
97/// precedence narrative).
98///
99/// - `.0` (global) = [`Blueprint::default_agent_ctx`], unchanged.
100/// - `.1` (per-agent) = `AgentDef.name -> AgentMeta.ctx`, entry present
101///   only for agents whose `meta` is `Some` and who declare a `ctx`
102///   (directly via `meta.ctx`, and/or indirectly via
103///   [`AgentMeta::meta_ref`] — GH #21 Phase 2, see below).
104///
105/// # GH #21 Phase 2: `AgentMeta.meta_ref` resolution
106///
107/// When an agent declares `meta.meta_ref`, it is resolved against
108/// [`derive_step_metas`]'s pool and used as the BASE layer UNDER the
109/// agent's own inline `meta.ctx` (inline wins on key collision, shallow
110/// merge — see [`shallow_merge_inline_wins`]). An unresolved `meta_ref`
111/// at this point means the caller launched a Blueprint that bypassed
112/// `Compiler::compile`'s validation (the loud gate for this case, see
113/// `blueprint::compiler::Compiler::compile`'s `UnresolvedMetaRef` check);
114/// this function stays defensive and never panics — it logs a warning and
115/// skips the base layer, letting the agent's own inline `ctx` (if any)
116/// stand alone.
117fn derive_agent_ctx(blueprint: &Blueprint) -> (Option<Value>, HashMap<String, Value>) {
118    let global = blueprint.default_agent_ctx.clone();
119    let meta_pool = derive_step_metas(blueprint);
120    let per_agent = blueprint
121        .agents
122        .iter()
123        .filter_map(|ad| {
124            let meta = ad.meta.as_ref()?;
125            let inline = meta.ctx.clone();
126            let base = meta.meta_ref.as_ref().and_then(|name| {
127                let resolved = meta_pool.get(name).cloned();
128                if resolved.is_none() {
129                    tracing::warn!(
130                        agent = %ad.name,
131                        meta_ref = %name,
132                        "derive_agent_ctx: AgentMeta.meta_ref names an undefined Blueprint.metas entry; skipping the base layer"
133                    );
134                }
135                resolved
136            });
137            let merged = match (base, inline) {
138                (None, None) => None,
139                (Some(base), None) => Some(base),
140                (None, Some(inline)) => Some(inline),
141                (Some(base), Some(inline)) => Some(shallow_merge_inline_wins(base, inline)),
142            };
143            merged.map(|ctx| (ad.name.clone(), ctx))
144        })
145        .collect();
146    (global, per_agent)
147}
148
149/// GH #21 Phase 2: shallow-merge `base` with `inline`, `inline` winning
150/// key collisions. Both sides being JSON `Object`s is the meaningful case
151/// (per-key merge); a non-`Object` `inline` is used as-is (it "wins"
152/// entirely — the malformed-shape case is left to
153/// `AgentContextMiddleware`'s own tier merge, which already warns + skips
154/// a non-`Object` tier value downstream, never failing the spawn).
155fn shallow_merge_inline_wins(base: Value, inline: Value) -> Value {
156    match (base, inline) {
157        (Value::Object(mut base), Value::Object(inline)) => {
158            for (k, v) in inline {
159                base.insert(k, v);
160            }
161            Value::Object(base)
162        }
163        (_, inline) => inline,
164    }
165}
166
167/// GH #21 Phase 2: build the `Blueprint.metas` named pool (`MetaDef.name
168/// -> MetaDef.ctx`) — the launch-time sibling of [`derive_worker_bindings`]
169/// / [`derive_agent_ctx`], resolving the Step tier's shared pool instead
170/// of a per-agent map. Consumed by `EngineDispatcher::with_step_metas`
171/// (the Step tier's `$step_meta.ref` resolver) and, indirectly, by
172/// [`derive_agent_ctx`]'s `AgentMeta.meta_ref` resolution (the Agent
173/// tier shares the same pool).
174fn derive_step_metas(blueprint: &Blueprint) -> HashMap<String, Value> {
175    blueprint
176        .metas
177        .iter()
178        .map(|m| (m.name.clone(), m.ctx.clone()))
179        .collect()
180}
181
182/// Issue #21 Phase 1: build the [`ContextPolicy`] cascade's "BP Global" +
183/// "BP Agent-level" tiers from a Blueprint — same shape and discipline as
184/// [`derive_agent_ctx`], from `Blueprint.default_context_policy` /
185/// `AgentMeta.context_policy` instead. Consumed by
186/// `AgentContextMiddleware`, which resolves the effective policy per spawn
187/// (per-agent tier outranks the BP-global one; pass-all when neither is
188/// declared for the dispatching agent).
189fn derive_context_policies(
190    blueprint: &Blueprint,
191) -> (Option<ContextPolicy>, HashMap<String, ContextPolicy>) {
192    let default_policy = blueprint.default_context_policy.clone();
193    let per_agent = blueprint
194        .agents
195        .iter()
196        .filter_map(|ad| {
197            let meta = ad.meta.as_ref()?;
198            let policy = meta.context_policy.clone()?;
199            Some((ad.name.clone(), policy))
200        })
201        .collect();
202    (default_policy, per_agent)
203}
204
205/// Issue #19 ST3: shallow-merge the "BP Global" default `init_ctx`
206/// (`Blueprint.default_init_ctx`) with the Task-level `init_ctx` — the
207/// second layer of the (eventual 4-layer) init-ctx cascade, following the
208/// same "BP default, Task overrides" shape as the `OperatorKind` cascade
209/// (see `derive_bp_agent_kinds` / `TaskLaunchInput::operator_kind`).
210///
211/// Semantics (deliberately a single rule, no deep merge / JSON Patch):
212///
213/// - `bp_default = None` → `task_init_ctx` passes through unchanged
214///   (pre-#19 Blueprints keep today's exact behavior).
215/// - Both sides are `Value::Object` → shallow key-wise merge, Task wins
216///   on collision (`task_init_ctx`'s keys are applied last).
217/// - `task_init_ctx` is present but not an `Object` (`Null` / `String` /
218///   `Array` / `Number` / `Bool`) → Task fully replaces the BP default;
219///   the caller's non-Object seed is respected as-is.
220fn merge_init_ctx(bp_default: Option<&Value>, task_init_ctx: &Value) -> Value {
221    match (bp_default, task_init_ctx) {
222        (Some(Value::Object(bp_map)), Value::Object(task_map)) => {
223            let mut merged = bp_map.clone();
224            for (k, v) in task_map {
225                merged.insert(k.clone(), v.clone());
226            }
227            Value::Object(merged)
228        }
229        (None, _) => task_init_ctx.clone(),
230        (_, task) => task.clone(),
231    }
232}
233
234/// Issue #19 ST4: 3-layer shallow-merge of the init-ctx cascade — BP
235/// default → Task → Run (lowest to highest priority). Built by chaining
236/// [`merge_init_ctx`] twice rather than introducing a distinct 3-way merge
237/// algorithm, so the Run layer inherits exactly the same "shallow Object
238/// merge, non-Object fully replaces" rule [`merge_init_ctx`] already
239/// established for the BP/Task pair (see its doc for the full semantics).
240///
241/// - `run_override: None` is a no-op — the BP+Task merge passes through
242///   unchanged, so `POST /v1/tasks/:id/runs` with no body (or a body that
243///   omits `init_ctx_override`) preserves today's rekick behavior
244///   byte-for-byte.
245/// - `run_override: Some(_)` layers on top exactly like `task_init_ctx`
246///   layers on top of `bp_default` above: both `Object` → shallow
247///   key-wise merge with Run winning collisions; Run non-`Object` →
248///   fully replaces the BP+Task merge.
249pub fn merge_init_ctx_3layer(
250    bp_default: Option<&Value>,
251    task_init_ctx: &Value,
252    run_override: Option<&Value>,
253) -> Value {
254    let bp_task = merge_init_ctx(bp_default, task_init_ctx);
255    match run_override {
256        Some(run) => merge_init_ctx(Some(&bp_task), run),
257        None => bp_task,
258    }
259}
260
261fn derive_bp_agent_kinds(blueprint: &Blueprint) -> HashMap<String, OperatorKind> {
262    let mut out = HashMap::new();
263    if blueprint.operators.is_empty() {
264        return out;
265    }
266    for agent in &blueprint.agents {
267        let Some(op_ref) = agent.spec.get("operator_ref").and_then(|v| v.as_str()) else {
268            continue;
269        };
270        let Some(op_def) = blueprint.operators.iter().find(|o| o.name == op_ref) else {
271            continue;
272        };
273        if let Some(kind) = op_def.kind {
274            out.insert(agent.name.clone(), OperatorKind::from(kind));
275        }
276    }
277    out
278}
279
280/// Failure modes of [`TaskLaunchService::launch`].
281#[derive(Debug, Error)]
282pub enum TaskLaunchError {
283    /// `Compiler::compile` rejected the Blueprint.
284    #[error("compile: {0}")]
285    Compile(#[from] CompileError),
286    /// `Engine::attach_with_ids` failed.
287    #[error("engine: {0}")]
288    Engine(#[from] EngineError),
289    /// A `Step` inside `flow.ir`'s `eval_async` produced a dispatcher
290    /// error, or a sub-flow raised.
291    #[error("flow eval: {0}")]
292    FlowEval(String),
293}
294
295/// Canonical bag of Task-level fields (`project_root` / `work_dir` /
296/// `task_metadata`) — [`TaskLaunchInput::task_input`]'s type.
297///
298/// Issue #19 ST2: replaces the ST1 `resolve_task_level_init_ctx`
299/// fold-back-into-`init_ctx` bridge (removed from
300/// `mlua-swarm-server`'s `run_flow_form`). Callers resolve these three
301/// fields once at the wire boundary — sibling body field first, falling
302/// back to the legacy shape (same three keys nested directly inside
303/// `init_ctx`) only there — and hand the result straight through here;
304/// `init_ctx` itself is no longer mutated to carry them, so it stays a
305/// pure flow-ir eval seed identical to whatever the caller sent.
306///
307/// Each field is independently optional — see
308/// [`crate::middleware::task_input::TaskInputMiddleware::new_from_fields`],
309/// which this is built for.
310///
311/// Issue #19 ST4: also `Serialize`/`Deserialize` so it can travel over the
312/// wire as `RunKickRequest.task_input_override` (`mlua-swarm-server`'s
313/// `tasks` module) and be snapshotted into `TaskRecord.task_input_spec`
314/// (JSON) for rekick to resolve back out of. Every field is
315/// `#[serde(default)]` so a caller may omit any subset (or send `{}`) and
316/// still deserialize.
317#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
318pub struct TaskInputSpec {
319    /// Task-level project root path.
320    #[serde(default)]
321    pub project_root: Option<String>,
322    /// Task-level working directory path.
323    #[serde(default)]
324    pub work_dir: Option<String>,
325    /// Task-level arbitrary metadata bag (a JSON object, or `None`).
326    #[serde(default)]
327    #[schemars(with = "Option<Value>")]
328    pub task_metadata: Option<Value>,
329}
330
331/// Input to [`TaskLaunchService::launch`].
332#[derive(Debug, Clone)]
333pub struct TaskLaunchInput {
334    /// The Blueprint to compile, link, and run.
335    pub blueprint: Blueprint,
336    /// Caller-supplied id for the Operator that owns this run.
337    pub operator_id: String,
338    /// The Operator's role for this run.
339    pub role: Role,
340    /// How long the attached session is allowed to live.
341    pub ttl: Duration,
342    /// "Runtime Global" tier of the `OperatorKind` cascade. `Some(_)` is
343    /// always an explicit request — including `Some(OperatorKind::Automate)`
344    /// — that outranks the BP-level tiers (`OperatorDef.kind` /
345    /// `Blueprint.default_operator_kind`); `None` leaves it unspecified so
346    /// those tiers / the final default decide. Under `MainAi` or
347    /// `Composite`, `MainAIMiddleware`'s `spawn_hook` before/after
348    /// callbacks become effective. See
349    /// `crate::core::ctx::collapse_operator_kind`.
350    pub operator_kind: Option<OperatorKind>,
351    /// `SeniorBridge` registry ID. `None` — no bridge; `Some(id)` —
352    /// attach a bridge previously registered via
353    /// `engine.register_senior_bridge`.
354    pub bridge_id: Option<String>,
355    /// `SpawnHook` registry ID. Same shape as above, via
356    /// `engine.register_spawn_hook`.
357    pub hook_id: Option<String>,
358    /// Operator registry ID — used on the path that hands the whole
359    /// spawn off to an external Operator. Name previously registered
360    /// with `engine.register_operator`; resolved by
361    /// `OperatorDelegateMiddleware`, which — for `kind = MainAi` or
362    /// `Composite` — bypasses `inner.spawn` and calls
363    /// `operator.execute`.
364    pub operator_backend_id: Option<String>,
365    /// "Runtime Agent-level" tier (highest priority) of the `OperatorKind`
366    /// cascade — per-agent override, keyed by `AgentDef.name`. Empty by
367    /// default (no override for any agent). See
368    /// `crate::core::ctx::collapse_operator_kind` for the full tier list.
369    pub operator_kind_overrides: HashMap<String, OperatorKind>,
370    /// The initial `ctx` (JSON `Value`) that flow.ir's `eval_async`
371    /// starts from. Every `Step.in` `$.<path>` reference reads from
372    /// here. Issue #19 ST2: a pure flow-ir eval seed — no Task-level
373    /// field is folded into it anymore; see [`Self::task_input`].
374    pub init_ctx: Value,
375    /// Task-level canonical fields (issue #19 ST2). `Some` layers a
376    /// [`crate::middleware::task_input::TaskInputMiddleware`] (built via
377    /// [`crate::middleware::task_input::TaskInputMiddleware::new_from_fields`])
378    /// onto the spawner stack just before spawn; `None` is a no-op,
379    /// identical to today's behavior for callers with no Task-level
380    /// fields to propagate.
381    pub task_input: Option<TaskInputSpec>,
382    /// Issue #13 run_id propagation: when `Some`, every step this launch
383    /// dispatches is traced into `RunRecord.step_entries` and exposes its
384    /// `run_id` via `Ctx.meta.runtime["run_id"]` (see
385    /// `EngineDispatcher::with_run`). `None` (the default via
386    /// [`Self::automate`]) preserves the pre-existing behavior — no run
387    /// tracing.
388    pub run_ctx: Option<RunContext>,
389}
390
391impl TaskLaunchInput {
392    /// Helper for existing callers on the default path — no hooks and no
393    /// per-agent `OperatorKind` overrides. Leaves the "Runtime Global" tier
394    /// unspecified (`None`), so the BP-level tiers / final default
395    /// (`OperatorKind::Automate`) decide — this preserves today's
396    /// behaviour for every existing caller without silently forcing
397    /// `Automate` as an explicit override that would outrank a BP-declared
398    /// `MainAi`/`Composite` kind. `run_ctx` and `task_input` both default
399    /// to `None` (no run tracing, no Task-level fields); construct the
400    /// struct literal directly to set either.
401    pub fn automate(
402        blueprint: Blueprint,
403        operator_id: impl Into<String>,
404        role: Role,
405        ttl: Duration,
406        init_ctx: Value,
407    ) -> Self {
408        Self {
409            blueprint,
410            operator_id: operator_id.into(),
411            role,
412            ttl,
413            operator_kind: None,
414            bridge_id: None,
415            hook_id: None,
416            operator_backend_id: None,
417            operator_kind_overrides: HashMap::new(),
418            init_ctx,
419            task_input: None,
420            run_ctx: None,
421        }
422    }
423}
424
425/// Result of a successful [`TaskLaunchService::launch`] call.
426#[derive(Debug, Clone)]
427pub struct TaskLaunchOutput {
428    /// The capability token for the attached session.
429    pub token: CapToken,
430    /// The final `ctx` after the flow ran — every `Step.out` has
431    /// been written. Application-layer callers pull the outcome out
432    /// of this `Value` and fold it into a domain status.
433    pub final_ctx: Value,
434}
435
436/// Domain service that compiles, links, and runs a Blueprint's flow to
437/// completion through the [`Engine`]. See the module doc for the full
438/// responsibility list.
439pub struct TaskLaunchService {
440    engine: Engine,
441    compiler: Compiler,
442    /// `call_extern` registry threaded into flow eval. Defaults to
443    /// [`NoExterns`] (= every `call_extern` in a Blueprint raises
444    /// `ExternError`); hosts opt in via [`Self::with_externs`] with an
445    /// `ExternMap` of pure value-shape functions.
446    externs: Arc<dyn Externs + Send + Sync>,
447}
448
449impl TaskLaunchService {
450    /// Build a service bound to one `Engine` and one `Compiler`.
451    pub fn new(engine: Engine, compiler: Compiler) -> Self {
452        Self {
453            engine,
454            compiler,
455            externs: Arc::new(NoExterns),
456        }
457    }
458
459    /// Replace the `call_extern` registry (builder style). Entries MUST be
460    /// pure functions — no side effects, no flow control; effectful work
461    /// belongs to `Step` / agents, not externs (flow-ir canonical contract).
462    pub fn with_externs(mut self, externs: Arc<dyn Externs + Send + Sync>) -> Self {
463        self.externs = externs;
464        self
465    }
466
467    /// The bound `Engine`.
468    pub fn engine(&self) -> &Engine {
469        &self.engine
470    }
471
472    /// The bound `Compiler`.
473    pub fn compiler(&self) -> &Compiler {
474        &self.compiler
475    }
476
477    /// Run the Blueprint's flow to completion and return the final
478    /// `ctx`.
479    ///
480    /// Failure paths:
481    ///
482    /// - `compiler.compile` failure → `TaskLaunchError::Compile`.
483    /// - `engine.attach` failure → `TaskLaunchError::Engine`.
484    /// - A `Step` inside `flow eval` producing a dispatcher error, or
485    ///   a sub-flow raising, → `TaskLaunchError::FlowEval`. There is
486    ///   no silent partial-success completion; failures always
487    ///   propagate.
488    pub async fn launch(
489        &self,
490        input: TaskLaunchInput,
491    ) -> Result<TaskLaunchOutput, TaskLaunchError> {
492        // After the stateless-executor refactor, the
493        // caller (Service) does compile + link +
494        // `EngineDispatcher::with_spawner` itself; the engine no longer
495        // holds any global spawner state to touch. The link path (base
496        // `SpawnerAdapter` +
497        // `LayerRegistry` resolution + `SpawnerStack` wrapping) is
498        // concentrated inside `service::linker::link` — Service
499        // scatter is intentionally prevented.
500        let compiled = self.compiler.compile(&input.blueprint)?;
501        let spawner = linker::link(
502            compiled.router.clone(),
503            &input.blueprint.spawner_hints.layers,
504            &self.engine,
505        );
506        // GH #20 Contract C: materialize an `AgentContextView` exactly
507        // once per spawn, innermost relative to every other layer below
508        // (alias / worker-binding / task-input all insert `ctx.meta.runtime`
509        // keys this layer must observe, so it is added FIRST — later
510        // `.layer()` calls become outer, see `middleware::SpawnerStack`).
511        // Unconditional (always layered): every Blueprint gets this layer
512        // even when it declares no agent-context supply tiers at all
513        // (`derive_agent_ctx` / `derive_context_policies` both return
514        // empty state then, matching the pre-#21 `AgentContextMiddleware`
515        // `Default` behavior byte-for-byte). GH #21 Phase 1: the
516        // receptacle named in the #20 comment above is now wired —
517        // `Blueprint.default_agent_ctx` / `default_context_policy` and
518        // `AgentMeta.ctx` / `context_policy` feed this layer's merge +
519        // policy resolution (see `middleware::agent_context`'s module doc
520        // for the full narrative).
521        let (agent_ctx_global, agent_ctx_per_agent) = derive_agent_ctx(&input.blueprint);
522        let (context_policy_default, context_policy_per_agent) =
523            derive_context_policies(&input.blueprint);
524        let spawner = SpawnerStack::new(spawner)
525            .layer(AgentContextMiddleware::new(
526                agent_ctx_global,
527                agent_ctx_per_agent,
528                context_policy_default,
529                context_policy_per_agent,
530            ))
531            .build();
532        // When `Blueprint.metadata.project_name_alias` is Some, layer a
533        // `ProjectNameAliasMiddleware` on top of the stack that injects the
534        // alias into `Ctx.meta.runtime.project_name_alias` just before spawn.
535        // Downstream operators (for example, the server crate's
536        // `Operator.execute`) read `ctx.meta.runtime.get("project_name_alias")`
537        // and expand it into the Spawn directive prompt body.
538        let spawner = if let Some(alias) = input.blueprint.metadata.project_name_alias.as_deref() {
539            SpawnerStack::new(spawner)
540                .layer(ProjectNameAliasMiddleware::new(alias))
541                .build()
542        } else {
543            spawner
544        };
545        // Layer the Blueprint-baked worker bindings (same ctx.meta.runtime
546        // inject shape as the alias layer above) so the delegate axis can
547        // resolve per-agent variants — see `derive_worker_bindings`.
548        let worker_bindings = derive_worker_bindings(&input.blueprint);
549        let spawner = if worker_bindings.is_empty() {
550            spawner
551        } else {
552            SpawnerStack::new(spawner)
553                .layer(WorkerBindingMiddleware::new(worker_bindings))
554                .build()
555        };
556
557        // Task-level execution context (`project_root` / `work_dir` /
558        // `task_metadata`) — same conditional-layering shape as the alias /
559        // worker-binding blocks above. Issue #19 ST2: read directly off
560        // `input.task_input` (already resolved by the caller) instead of
561        // extracting it back out of `input.init_ctx` — `init_ctx` is a pure
562        // flow-ir eval seed now, never folded with these keys.
563        let spawner = match input.task_input.as_ref().and_then(|spec| {
564            TaskInputMiddleware::new_from_fields(
565                spec.project_root.clone(),
566                spec.work_dir.clone(),
567                spec.task_metadata.clone(),
568            )
569        }) {
570            Some(task_input) => SpawnerStack::new(spawner).layer(task_input).build(),
571            None => spawner,
572        };
573
574        // "BP Agent-level" (`OperatorDef.kind` via `operator_ref`) + "BP
575        // Global" (`Blueprint.default_operator_kind`) tiers of the
576        // `OperatorKind` cascade, baked here (the only point that has both
577        // the resolved Blueprint and the launch-time overrides in scope).
578        let bp_agent_kinds = derive_bp_agent_kinds(&input.blueprint);
579        let bp_global_kind = input
580            .blueprint
581            .default_operator_kind
582            .map(OperatorKind::from);
583
584        let token = self
585            .engine
586            .attach_with_ids(
587                input.operator_id,
588                input.role,
589                input.ttl,
590                input.operator_kind,
591                input.bridge_id,
592                input.hook_id,
593                input.operator_backend_id,
594                input.operator_kind_overrides,
595                bp_agent_kinds,
596                bp_global_kind,
597            )
598            .await?;
599        let dispatcher =
600            EngineDispatcher::with_spawner(self.engine.clone(), token.clone(), spawner);
601        let dispatcher = match input.run_ctx {
602            Some(run_ctx) => dispatcher.with_run(run_ctx),
603            None => dispatcher,
604        };
605        // GH #21 Phase 2: attach the Step tier's named `MetaDef` pool.
606        // Unconditional — an empty map (every pre-#21-Phase-2 Blueprint)
607        // is a no-op, matching `EngineDispatcher::with_spawner`'s default.
608        let dispatcher = dispatcher.with_step_metas(derive_step_metas(&input.blueprint));
609        // GH #23: attach the `StepNaming` table `Compiler::compile` already
610        // built once for this Blueprint (the sole construction site — see
611        // `core::step_naming::StepNaming::from_blueprint`'s doc).
612        // Unconditional — every compile produces one, undeclared Blueprints
613        // included (canonical falls back to `Step.ref` byte-for-byte).
614        let dispatcher = dispatcher.with_step_naming(compiled.step_naming.clone());
615        // GH #27 (follow-up to #23): attach the `ProjectionPlacement`
616        // resolver `Compiler::compile` already built once for this
617        // Blueprint (the sole construction site — see
618        // `core::projection_placement::ProjectionPlacement::from_spec`'s
619        // doc). Unconditional — every compile produces one, undeclared
620        // Blueprints included (resolves to `ProjectionPlacement::default()`).
621        let dispatcher =
622            dispatcher.with_projection_placement(compiled.projection_placement.clone());
623        // Issue #19 ST3: BP default + Task init_ctx → merged init_ctx (the
624        // 2-layer slice of the eventual 4-layer cascade; Run override is
625        // ST4 carry). `input.blueprint.default_init_ctx` is `None` for
626        // every pre-#19 Blueprint, so `merge_init_ctx` is a no-op then and
627        // this preserves today's behavior byte-for-byte.
628        let merged_init_ctx =
629            merge_init_ctx(input.blueprint.default_init_ctx.as_ref(), &input.init_ctx);
630        let final_ctx = mlua_flow_ir::eval_async_externs(
631            &input.blueprint.flow,
632            merged_init_ctx,
633            &dispatcher,
634            &*self.externs,
635        )
636        .await
637        .map_err(|e| TaskLaunchError::FlowEval(e.to_string()))?;
638        Ok(TaskLaunchOutput { token, final_ctx })
639    }
640}
641
642// ──────────────────────────────────────────────────────────────────────────
643// UT
644// ──────────────────────────────────────────────────────────────────────────
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649    use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerRegistry};
650    use crate::blueprint::{
651        current_schema_version, AgentDef, AgentKind, AgentMeta, BlueprintMetadata, CompilerHints,
652        CompilerStrategy, MetaDef,
653    };
654    use crate::core::config::EngineCfg;
655    use crate::worker::adapter::{WorkerError, WorkerResult};
656    use mlua_flow_ir::{Expr, JoinMode, Node as FlowNode};
657    use serde_json::json;
658    use std::sync::Arc;
659
660    fn path(s: &str) -> Expr {
661        Expr::Path { at: s.to_string() }
662    }
663    fn step(ref_: &str, in_: Expr, out: Expr) -> FlowNode {
664        FlowNode::Step {
665            ref_: ref_.to_string(),
666            in_,
667            out,
668        }
669    }
670
671    fn agent(name: &str, fn_id: &str) -> AgentDef {
672        AgentDef {
673            name: name.to_string(),
674            kind: AgentKind::RustFn,
675            spec: json!({ "fn_id": fn_id }),
676            profile: None,
677            meta: Some(AgentMeta::default()),
678        }
679    }
680
681    fn build_service(factory: RustFnInProcessSpawnerFactory) -> TaskLaunchService {
682        let engine = Engine::new(EngineCfg::default());
683        let mut reg = SpawnerRegistry::new();
684        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
685        let compiler = Compiler::new(reg);
686        TaskLaunchService::new(engine, compiler)
687    }
688
689    fn bp(flow: FlowNode, agents: Vec<AgentDef>) -> Blueprint {
690        Blueprint {
691            schema_version: current_schema_version(),
692            id: "ut".into(),
693            flow,
694            agents,
695            operators: vec![],
696            metas: vec![],
697            hints: CompilerHints::default(),
698            strategy: CompilerStrategy::default(),
699            metadata: BlueprintMetadata::default(),
700            spawner_hints: Default::default(),
701            default_agent_kind: AgentKind::Operator,
702            default_operator_kind: None,
703            default_init_ctx: None,
704            default_agent_ctx: None,
705            default_context_policy: None,
706            projection_placement: None,
707        }
708    }
709
710    fn launch_input(blueprint: Blueprint, init_ctx: Value) -> TaskLaunchInput {
711        TaskLaunchInput::automate(
712            blueprint,
713            "ut-op",
714            Role::Operator,
715            Duration::from_secs(30),
716            init_ctx,
717        )
718    }
719
720    #[tokio::test]
721    async fn launch_single_step_writes_out_path() {
722        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
723            Ok(WorkerResult {
724                value: json!({ "echoed": inv.prompt }),
725                ok: true,
726            })
727        });
728        let svc = build_service(factory);
729        let blueprint = bp(
730            step("echo", path("$.input"), path("$.out")),
731            vec![agent("echo", "echo")],
732        );
733        let out = svc
734            .launch(launch_input(blueprint, json!({ "input": "hi" })))
735            .await
736            .expect("launch ok");
737        assert_eq!(out.final_ctx["out"]["echoed"], "hi");
738    }
739
740    #[tokio::test]
741    async fn launch_three_step_seq_threads_ctx_forward() {
742        let factory = RustFnInProcessSpawnerFactory::new()
743            .register_fn("upper", |inv| async move {
744                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
745                Ok(WorkerResult {
746                    value: json!(s.to_uppercase()),
747                    ok: true,
748                })
749            })
750            .register_fn("suffix", |inv| async move {
751                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
752                Ok(WorkerResult {
753                    value: json!(format!("{s}!")),
754                    ok: true,
755                })
756            })
757            .register_fn("wrap", |inv| async move {
758                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
759                Ok(WorkerResult {
760                    value: json!(format!("[{s}]")),
761                    ok: true,
762                })
763            });
764        let svc = build_service(factory);
765        let flow = FlowNode::Seq {
766            children: vec![
767                step("upper", path("$.in"), path("$.s1")),
768                step("suffix", path("$.s1"), path("$.s2")),
769                step("wrap", path("$.s2"), path("$.s3")),
770            ],
771        };
772        let blueprint = bp(
773            flow,
774            vec![
775                agent("upper", "upper"),
776                agent("suffix", "suffix"),
777                agent("wrap", "wrap"),
778            ],
779        );
780        let out = svc
781            .launch(launch_input(blueprint, json!({ "in": "hello" })))
782            .await
783            .expect("launch ok");
784        assert_eq!(out.final_ctx["s1"], "HELLO");
785        assert_eq!(out.final_ctx["s2"], "HELLO!");
786        assert_eq!(out.final_ctx["s3"], "[HELLO!]");
787    }
788
789    #[tokio::test]
790    async fn launch_fanout_join_all_parallel_completes() {
791        use std::sync::atomic::{AtomicU32, Ordering};
792        let counter = Arc::new(AtomicU32::new(0));
793        let max_seen = Arc::new(AtomicU32::new(0));
794        let counter_clone = counter.clone();
795        let max_clone = max_seen.clone();
796
797        // Each worker bumps the inflight counter up, sleeps 50ms, then bumps it down.
798        // When parallel execution is working, max inflight exceeds 1.
799        let factory = RustFnInProcessSpawnerFactory::new().register_fn("para", move |inv| {
800            let counter = counter_clone.clone();
801            let max_seen = max_clone.clone();
802            async move {
803                let now = counter.fetch_add(1, Ordering::SeqCst) + 1;
804                let mut prev = max_seen.load(Ordering::SeqCst);
805                while now > prev {
806                    match max_seen.compare_exchange(prev, now, Ordering::SeqCst, Ordering::SeqCst) {
807                        Ok(_) => break,
808                        Err(p) => prev = p,
809                    }
810                }
811                tokio::time::sleep(Duration::from_millis(50)).await;
812                counter.fetch_sub(1, Ordering::SeqCst);
813                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
814                Ok(WorkerResult {
815                    value: json!(format!("did:{s}")),
816                    ok: true,
817                })
818            }
819        });
820        let svc = build_service(factory);
821        let flow = FlowNode::Fanout {
822            items: path("$.items"),
823            bind: path("$.item"),
824            body: Box::new(step("para", path("$.item"), path("$.r"))),
825            join: JoinMode::All,
826            out: path("$.results"),
827        };
828        let blueprint = bp(flow, vec![agent("para", "para")]);
829        let out = svc
830            .launch(launch_input(
831                blueprint,
832                json!({ "items": ["a", "b", "c", "d"] }),
833            ))
834            .await
835            .expect("launch ok");
836        let results = out.final_ctx["results"].as_array().expect("array");
837        assert_eq!(results.len(), 4);
838        for (i, expected) in ["a", "b", "c", "d"].iter().enumerate() {
839            assert_eq!(results[i]["r"], json!(format!("did:{expected}")));
840        }
841        let max = max_seen.load(Ordering::SeqCst);
842        assert!(
843            max >= 2,
844            "expected parallel execution (max inflight >= 2), got {max}"
845        );
846    }
847
848    #[tokio::test]
849    async fn launch_propagates_worker_error_as_flow_eval_err() {
850        let factory = RustFnInProcessSpawnerFactory::new()
851            .register_fn("ok", |inv| async move {
852                Ok(WorkerResult {
853                    value: json!(inv.prompt),
854                    ok: true,
855                })
856            })
857            .register_fn("boom", |_inv| async move {
858                Err(WorkerError::Failed("intentional boom".into()))
859            });
860        let svc = build_service(factory);
861        let flow = FlowNode::Seq {
862            children: vec![
863                step("ok", path("$.input"), path("$.s1")),
864                step("boom", path("$.s1"), path("$.s2")),
865                step("ok", path("$.s2"), path("$.s3")),
866            ],
867        };
868        let blueprint = bp(flow, vec![agent("ok", "ok"), agent("boom", "boom")]);
869        let err = svc
870            .launch(launch_input(blueprint, json!({ "input": "x" })))
871            .await
872            .expect_err("expected fail");
873        match err {
874            TaskLaunchError::FlowEval(msg) => {
875                assert!(
876                    msg.contains("boom") || msg.contains("intentional"),
877                    "expected error to mention worker failure, got: {msg}"
878                );
879            }
880            other => panic!("expected FlowEval error, got {other:?}"),
881        }
882    }
883
884    #[tokio::test]
885    async fn launch_resolves_call_extern_via_registered_externs() {
886        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
887            Ok(WorkerResult {
888                value: json!({ "echoed": inv.prompt }),
889                ok: true,
890            })
891        });
892        let mut externs = mlua_flow_ir::ExternMap::new();
893        externs.register("fmt.greet", |args: &[Value]| {
894            let name = args[0].as_str().unwrap_or("?");
895            Ok(json!(format!("hello, {name}")))
896        });
897        let svc = build_service(factory).with_externs(Arc::new(externs));
898        let flow = step(
899            "echo",
900            Expr::CallExtern {
901                ref_: "fmt.greet".into(),
902                args: vec![path("$.who")],
903            },
904            path("$.out"),
905        );
906        let blueprint = bp(flow, vec![agent("echo", "echo")]);
907        let out = svc
908            .launch(launch_input(blueprint, json!({ "who": "swarm" })))
909            .await
910            .expect("launch ok");
911        assert_eq!(out.final_ctx["out"]["echoed"], json!("hello, swarm"));
912    }
913
914    #[tokio::test]
915    async fn launch_call_extern_without_registry_fails_as_flow_eval() {
916        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
917            Ok(WorkerResult {
918                value: json!(inv.prompt),
919                ok: true,
920            })
921        });
922        let svc = build_service(factory); // default NoExterns
923        let flow = step(
924            "echo",
925            Expr::CallExtern {
926                ref_: "fmt.greet".into(),
927                args: vec![],
928            },
929            path("$.out"),
930        );
931        let blueprint = bp(flow, vec![agent("echo", "echo")]);
932        let err = svc
933            .launch(launch_input(blueprint, json!({})))
934            .await
935            .expect_err("expected fail");
936        match err {
937            TaskLaunchError::FlowEval(msg) => {
938                assert!(msg.contains("extern"), "expected extern error, got: {msg}");
939            }
940            other => panic!("expected FlowEval error, got {other:?}"),
941        }
942    }
943
944    // ──────────────────────────────────────────────────────────────────
945    // issue #13 run_id propagation (`TaskLaunchInput.run_ctx`)
946    // ──────────────────────────────────────────────────────────────────
947
948    #[tokio::test]
949    async fn launch_with_run_ctx_appends_one_step_entry_per_dispatched_step() {
950        use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
951        use crate::types::{RunId, TaskId};
952
953        let factory = RustFnInProcessSpawnerFactory::new()
954            .register_fn("upper", |inv| async move {
955                Ok(WorkerResult {
956                    value: json!(inv.prompt.to_uppercase()),
957                    ok: true,
958                })
959            })
960            .register_fn("suffix", |inv| async move {
961                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
962                Ok(WorkerResult {
963                    value: json!(format!("{s}!")),
964                    ok: true,
965                })
966            });
967        let svc = build_service(factory);
968        let flow = FlowNode::Seq {
969            children: vec![
970                step("upper", path("$.in"), path("$.s1")),
971                step("suffix", path("$.s1"), path("$.s2")),
972            ],
973        };
974        let blueprint = bp(
975            flow,
976            vec![agent("upper", "upper"), agent("suffix", "suffix")],
977        );
978
979        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
980        let run_id = RunId::new();
981        run_store
982            .create(RunRecord {
983                id: run_id.clone(),
984                task_id: TaskId::new(),
985                status: RunStatus::Running,
986                step_entries: Vec::new(),
987                operator_sid: None,
988                result_ref: None,
989                created_at: 0,
990                updated_at: 0,
991            })
992            .await
993            .expect("seed RunRecord");
994
995        let mut input = launch_input(blueprint, json!({ "in": "hi" }));
996        input.run_ctx = Some(RunContext {
997            run_id: run_id.clone(),
998            run_store: run_store.clone(),
999        });
1000
1001        let out = svc.launch(input).await.expect("launch ok");
1002        assert_eq!(out.final_ctx["s2"], "HI!");
1003
1004        let run = run_store.get(&run_id).await.expect("run present");
1005        assert_eq!(
1006            run.step_entries.len(),
1007            2,
1008            "expected one step_entry per dispatched step, got {:?}",
1009            run.step_entries
1010        );
1011        assert_eq!(run.step_entries[0].step_ref, Some("upper".to_string()));
1012        assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
1013        assert_eq!(run.step_entries[1].step_ref, Some("suffix".to_string()));
1014        assert_eq!(run.step_entries[1].status, Some("passed".to_string()));
1015    }
1016
1017    #[tokio::test]
1018    async fn launch_without_run_ctx_appends_no_step_entries() {
1019        // `run_ctx: None` (the `automate()` default) must not touch any
1020        // `RunStore` — this is the pre-existing no-tracing behavior, kept
1021        // as a regression guard alongside the `Some` case above.
1022        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1023            Ok(WorkerResult {
1024                value: json!(inv.prompt),
1025                ok: true,
1026            })
1027        });
1028        let svc = build_service(factory);
1029        let blueprint = bp(
1030            step("echo", path("$.input"), path("$.out")),
1031            vec![agent("echo", "echo")],
1032        );
1033        let input = launch_input(blueprint, json!({ "input": "hi" }));
1034        assert!(
1035            input.run_ctx.is_none(),
1036            "automate() defaults run_ctx to None"
1037        );
1038        let out = svc.launch(input).await.expect("launch ok");
1039        assert_eq!(out.final_ctx["out"], "hi");
1040    }
1041
1042    // ──────────────────────────────────────────────────────────────────
1043    // issue #19 ST2: `TaskLaunchInput.task_input` (direct-sibling-read
1044    // replacement for the ST1 `from_init_ctx(&input.init_ctx)` call)
1045    // ──────────────────────────────────────────────────────────────────
1046
1047    #[tokio::test]
1048    async fn launch_with_task_input_leaves_init_ctx_object_seed_unmutated() {
1049        // Issue #19 ST2 invariant: `init_ctx` is a pure flow-ir eval seed —
1050        // `task_input` must not be folded into it. Regression guard for the
1051        // ST1 `resolve_task_level_init_ctx` fold-back this subtask removes:
1052        // if it ever crept back in here, `project_root` / `work_dir` /
1053        // `task_metadata` would leak into `final_ctx` as extra top-level
1054        // keys nobody wrote via a `Step.out`.
1055        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1056            Ok(WorkerResult {
1057                value: json!({ "echoed": inv.prompt }),
1058                ok: true,
1059            })
1060        });
1061        let svc = build_service(factory);
1062        let blueprint = bp(
1063            step("echo", path("$.input"), path("$.out")),
1064            vec![agent("echo", "echo")],
1065        );
1066        let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1067        input.task_input = Some(TaskInputSpec {
1068            project_root: Some("/repo".to_string()),
1069            work_dir: Some("/repo/work".to_string()),
1070            task_metadata: Some(json!({ "issue": 19 })),
1071        });
1072        let out = svc.launch(input).await.expect("launch ok");
1073        assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1074        assert!(
1075            out.final_ctx.get("project_root").is_none(),
1076            "task_input must not be folded into the flow-ir ctx seed, got {:?}",
1077            out.final_ctx
1078        );
1079        assert!(out.final_ctx.get("work_dir").is_none());
1080        assert!(out.final_ctx.get("task_metadata").is_none());
1081    }
1082
1083    #[tokio::test]
1084    async fn launch_with_task_input_none_is_a_no_op() {
1085        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1086            Ok(WorkerResult {
1087                value: json!(inv.prompt),
1088                ok: true,
1089            })
1090        });
1091        let svc = build_service(factory);
1092        let blueprint = bp(
1093            step("echo", path("$.input"), path("$.out")),
1094            vec![agent("echo", "echo")],
1095        );
1096        let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1097        assert!(input.task_input.is_none(), "automate() defaults to None");
1098        input.task_input = None;
1099        let out = svc.launch(input).await.expect("launch ok");
1100        assert_eq!(out.final_ctx["out"], "hi");
1101    }
1102
1103    #[tokio::test]
1104    async fn launch_with_task_input_all_fields_absent_is_a_no_op() {
1105        // `Some(TaskInputSpec::default())` — outer Some, all 3 inner fields
1106        // None — must behave identically to `task_input: None` (mirrors
1107        // `TaskInputMiddleware::new_from_fields`'s own no-op contract).
1108        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1109            Ok(WorkerResult {
1110                value: json!(inv.prompt),
1111                ok: true,
1112            })
1113        });
1114        let svc = build_service(factory);
1115        let blueprint = bp(
1116            step("echo", path("$.input"), path("$.out")),
1117            vec![agent("echo", "echo")],
1118        );
1119        let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1120        input.task_input = Some(TaskInputSpec::default());
1121        let out = svc.launch(input).await.expect("launch ok");
1122        assert_eq!(out.final_ctx["out"], "hi");
1123    }
1124
1125    // ──────────────────────────────────────────────────────────────────
1126    // issue #19 ST3: `merge_init_ctx` (BP default + Task init_ctx)
1127    // ──────────────────────────────────────────────────────────────────
1128
1129    #[test]
1130    fn merge_init_ctx_bp_default_only_passes_through_when_task_is_empty_object() {
1131        let bp_default = json!({ "seeded": "from-bp" });
1132        let task = json!({});
1133        let merged = merge_init_ctx(Some(&bp_default), &task);
1134        assert_eq!(merged, json!({ "seeded": "from-bp" }));
1135    }
1136
1137    #[test]
1138    fn merge_init_ctx_task_only_passes_through_when_bp_default_is_empty_object() {
1139        let bp_default = json!({});
1140        let task = json!({ "seeded": "from-task" });
1141        let merged = merge_init_ctx(Some(&bp_default), &task);
1142        assert_eq!(merged, json!({ "seeded": "from-task" }));
1143    }
1144
1145    #[test]
1146    fn merge_init_ctx_both_objects_task_wins_on_key_collision() {
1147        let bp_default = json!({ "a": "bp", "b": "bp-only" });
1148        let task = json!({ "a": "task", "c": "task-only" });
1149        let merged = merge_init_ctx(Some(&bp_default), &task);
1150        assert_eq!(
1151            merged,
1152            json!({ "a": "task", "b": "bp-only", "c": "task-only" })
1153        );
1154    }
1155
1156    #[test]
1157    fn merge_init_ctx_non_object_task_fully_replaces_bp_default() {
1158        let bp_default = json!({ "seeded": "from-bp" });
1159        let task = json!("plain-string-seed");
1160        let merged = merge_init_ctx(Some(&bp_default), &task);
1161        assert_eq!(merged, json!("plain-string-seed"));
1162    }
1163
1164    #[test]
1165    fn merge_init_ctx_no_bp_default_is_a_no_op() {
1166        let task = json!({ "input": "hi" });
1167        let merged = merge_init_ctx(None, &task);
1168        assert_eq!(merged, task);
1169    }
1170
1171    // ──────────────────────────────────────────────────────────────────
1172    // issue #19 ST4: `merge_init_ctx_3layer` (BP default + Task + Run)
1173    // ──────────────────────────────────────────────────────────────────
1174
1175    #[test]
1176    fn merge_init_ctx_3layer_no_run_override_equals_bp_task_merge_only() {
1177        // `run_override: None` must be a pure pass-through of the BP+Task
1178        // merge — this is the `POST /v1/tasks/:id/runs` no-body rekick
1179        // path, which must preserve pre-#19 behavior byte-for-byte.
1180        let bp_default = json!({ "a": "bp", "b": "bp-only" });
1181        let task = json!({ "a": "task", "c": "task-only" });
1182        let three_layer = merge_init_ctx_3layer(Some(&bp_default), &task, None);
1183        let two_layer = merge_init_ctx(Some(&bp_default), &task);
1184        assert_eq!(three_layer, two_layer);
1185        assert_eq!(
1186            three_layer,
1187            json!({ "a": "task", "b": "bp-only", "c": "task-only" })
1188        );
1189    }
1190
1191    #[test]
1192    fn merge_init_ctx_3layer_run_object_wins_on_key_collision_over_bp_and_task() {
1193        let bp_default = json!({ "a": "bp", "b": "bp-only" });
1194        let task = json!({ "a": "task", "c": "task-only" });
1195        let run_override = json!({ "a": "run", "d": "run-only" });
1196        let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
1197        assert_eq!(
1198            merged,
1199            json!({ "a": "run", "b": "bp-only", "c": "task-only", "d": "run-only" }),
1200            "Run wins on collision (a); BP-only (b) and Task-only (c) keys survive"
1201        );
1202    }
1203
1204    #[test]
1205    fn merge_init_ctx_3layer_run_non_object_fully_replaces_bp_task_merge() {
1206        let bp_default = json!({ "seeded": "from-bp" });
1207        let task = json!({ "seeded": "from-task" });
1208        let run_override = json!("plain-string-run-seed");
1209        let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
1210        assert_eq!(merged, json!("plain-string-run-seed"));
1211    }
1212
1213    #[test]
1214    fn merge_init_ctx_3layer_no_bp_default_and_no_run_override_is_task_passthrough() {
1215        let task = json!({ "input": "hi" });
1216        let merged = merge_init_ctx_3layer(None, &task, None);
1217        assert_eq!(merged, task);
1218    }
1219
1220    #[tokio::test]
1221    async fn launch_merges_bp_default_init_ctx_into_task_init_ctx() {
1222        // End-to-end guard: `Blueprint.default_init_ctx` actually reaches
1223        // `eval_async_externs` — not merely unit-tested in isolation.
1224        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1225            Ok(WorkerResult {
1226                value: json!(inv.prompt),
1227                ok: true,
1228            })
1229        });
1230        let svc = build_service(factory);
1231        let mut blueprint = bp(
1232            step("echo", path("$.greeting"), path("$.out")),
1233            vec![agent("echo", "echo")],
1234        );
1235        blueprint.default_init_ctx = Some(json!({ "greeting": "hello from bp" }));
1236        // Task supplies an empty object — BP default alone seeds `$.greeting`.
1237        let out = svc
1238            .launch(launch_input(blueprint, json!({})))
1239            .await
1240            .expect("launch ok");
1241        assert_eq!(out.final_ctx["out"], "hello from bp");
1242    }
1243
1244    // ──────────────────────────────────────────────────────────────────
1245    // issue #21 Phase 1: `derive_agent_ctx` / `derive_context_policies`
1246    // ──────────────────────────────────────────────────────────────────
1247
1248    fn agent_with_meta(name: &str, fn_id: &str, meta: AgentMeta) -> AgentDef {
1249        AgentDef {
1250            name: name.to_string(),
1251            kind: AgentKind::RustFn,
1252            spec: json!({ "fn_id": fn_id }),
1253            profile: None,
1254            meta: Some(meta),
1255        }
1256    }
1257
1258    #[test]
1259    fn derive_agent_ctx_empty_blueprint_yields_empty_state() {
1260        let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1261        let (global, per_agent) = derive_agent_ctx(&blueprint);
1262        assert_eq!(global, None);
1263        assert!(per_agent.is_empty());
1264    }
1265
1266    #[test]
1267    fn derive_agent_ctx_populated_blueprint_yields_correct_maps() {
1268        let mut blueprint = bp(
1269            step("echo", path("$.in"), path("$.out")),
1270            vec![
1271                agent_with_meta(
1272                    "with-ctx",
1273                    "echo",
1274                    AgentMeta {
1275                        ctx: Some(json!({ "org_conventions": "x" })),
1276                        ..Default::default()
1277                    },
1278                ),
1279                agent("no-ctx", "echo"),
1280            ],
1281        );
1282        blueprint.default_agent_ctx = Some(json!({ "seeded": "from-bp" }));
1283        let (global, per_agent) = derive_agent_ctx(&blueprint);
1284        assert_eq!(global, Some(json!({ "seeded": "from-bp" })));
1285        assert_eq!(
1286            per_agent.len(),
1287            1,
1288            "agents without AgentMeta.ctx are absent, not defaulted to null: {per_agent:?}"
1289        );
1290        assert_eq!(
1291            per_agent.get("with-ctx"),
1292            Some(&json!({ "org_conventions": "x" }))
1293        );
1294        assert!(!per_agent.contains_key("no-ctx"));
1295    }
1296
1297    #[test]
1298    fn derive_context_policies_empty_blueprint_yields_empty_state() {
1299        let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1300        let (default_policy, per_agent) = derive_context_policies(&blueprint);
1301        assert_eq!(default_policy, None);
1302        assert!(per_agent.is_empty());
1303    }
1304
1305    #[test]
1306    fn derive_context_policies_populated_blueprint_yields_correct_maps() {
1307        let mut blueprint = bp(
1308            step("echo", path("$.in"), path("$.out")),
1309            vec![
1310                agent_with_meta(
1311                    "with-policy",
1312                    "echo",
1313                    AgentMeta {
1314                        context_policy: Some(ContextPolicy {
1315                            include: None,
1316                            exclude: vec!["work_dir".to_string()],
1317                            ..Default::default()
1318                        }),
1319                        ..Default::default()
1320                    },
1321                ),
1322                agent("no-policy", "echo"),
1323            ],
1324        );
1325        blueprint.default_context_policy = Some(ContextPolicy {
1326            include: Some(vec!["project_root".to_string()]),
1327            exclude: vec![],
1328            ..Default::default()
1329        });
1330        let (default_policy, per_agent) = derive_context_policies(&blueprint);
1331        assert_eq!(
1332            default_policy,
1333            Some(ContextPolicy {
1334                include: Some(vec!["project_root".to_string()]),
1335                exclude: vec![],
1336                ..Default::default()
1337            })
1338        );
1339        assert_eq!(per_agent.len(), 1);
1340        assert_eq!(
1341            per_agent.get("with-policy"),
1342            Some(&ContextPolicy {
1343                include: None,
1344                exclude: vec!["work_dir".to_string()],
1345                ..Default::default()
1346            })
1347        );
1348        assert!(!per_agent.contains_key("no-policy"));
1349    }
1350
1351    // ──────────────────────────────────────────────────────────────────
1352    // issue #21 Phase 2: `derive_step_metas` / `AgentMeta.meta_ref`
1353    // resolution inside `derive_agent_ctx`
1354    // ──────────────────────────────────────────────────────────────────
1355
1356    #[test]
1357    fn derive_step_metas_empty_blueprint_yields_empty_map() {
1358        let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1359        assert!(derive_step_metas(&blueprint).is_empty());
1360    }
1361
1362    #[test]
1363    fn derive_step_metas_populated_blueprint_yields_name_to_ctx_map() {
1364        let mut blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1365        blueprint.metas = vec![
1366            MetaDef {
1367                name: "heavy-scan".to_string(),
1368                ctx: json!({ "work_dir": "/x" }),
1369            },
1370            MetaDef {
1371                name: "light-scan".to_string(),
1372                ctx: json!({ "work_dir": "/y" }),
1373            },
1374        ];
1375        let metas = derive_step_metas(&blueprint);
1376        assert_eq!(metas.len(), 2);
1377        assert_eq!(metas.get("heavy-scan"), Some(&json!({ "work_dir": "/x" })));
1378        assert_eq!(metas.get("light-scan"), Some(&json!({ "work_dir": "/y" })));
1379    }
1380
1381    #[test]
1382    fn derive_agent_ctx_meta_ref_resolves_as_base_under_inline_ctx() {
1383        let mut blueprint = bp(
1384            step("echo", path("$.in"), path("$.out")),
1385            vec![agent_with_meta(
1386                "with-meta-ref",
1387                "echo",
1388                AgentMeta {
1389                    ctx: Some(json!({ "work_dir": "/inline-wins" })),
1390                    meta_ref: Some("shared".to_string()),
1391                    ..Default::default()
1392                },
1393            )],
1394        );
1395        blueprint.metas = vec![MetaDef {
1396            name: "shared".to_string(),
1397            ctx: json!({ "work_dir": "/base", "extra": "from-pool" }),
1398        }];
1399        let (_, per_agent) = derive_agent_ctx(&blueprint);
1400        assert_eq!(
1401            per_agent.get("with-meta-ref"),
1402            Some(&json!({ "work_dir": "/inline-wins", "extra": "from-pool" })),
1403            "inline ctx must win the collided key while pool-only keys survive the merge"
1404        );
1405    }
1406
1407    #[test]
1408    fn derive_agent_ctx_meta_ref_alone_uses_pool_ctx_verbatim() {
1409        let mut blueprint = bp(
1410            step("echo", path("$.in"), path("$.out")),
1411            vec![agent_with_meta(
1412                "with-meta-ref-only",
1413                "echo",
1414                AgentMeta {
1415                    meta_ref: Some("shared".to_string()),
1416                    ..Default::default()
1417                },
1418            )],
1419        );
1420        blueprint.metas = vec![MetaDef {
1421            name: "shared".to_string(),
1422            ctx: json!({ "work_dir": "/base" }),
1423        }];
1424        let (_, per_agent) = derive_agent_ctx(&blueprint);
1425        assert_eq!(
1426            per_agent.get("with-meta-ref-only"),
1427            Some(&json!({ "work_dir": "/base" }))
1428        );
1429    }
1430
1431    #[test]
1432    fn derive_agent_ctx_unresolved_meta_ref_never_panics_and_falls_back_to_inline() {
1433        let blueprint = bp(
1434            step("echo", path("$.in"), path("$.out")),
1435            vec![agent_with_meta(
1436                "with-unresolved-meta-ref",
1437                "echo",
1438                AgentMeta {
1439                    ctx: Some(json!({ "work_dir": "/inline-only" })),
1440                    meta_ref: Some("missing".to_string()),
1441                    ..Default::default()
1442                },
1443            )],
1444        );
1445        // No `blueprint.metas` entries at all — `meta_ref` unresolved.
1446        let (_, per_agent) = derive_agent_ctx(&blueprint);
1447        assert_eq!(
1448            per_agent.get("with-unresolved-meta-ref"),
1449            Some(&json!({ "work_dir": "/inline-only" })),
1450            "an unresolved meta_ref must never panic; the agent's own inline ctx still applies"
1451        );
1452    }
1453}