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        // Issue #19 ST3: BP default + Task init_ctx → merged init_ctx (the
610        // 2-layer slice of the eventual 4-layer cascade; Run override is
611        // ST4 carry). `input.blueprint.default_init_ctx` is `None` for
612        // every pre-#19 Blueprint, so `merge_init_ctx` is a no-op then and
613        // this preserves today's behavior byte-for-byte.
614        let merged_init_ctx =
615            merge_init_ctx(input.blueprint.default_init_ctx.as_ref(), &input.init_ctx);
616        let final_ctx = mlua_flow_ir::eval_async_externs(
617            &input.blueprint.flow,
618            merged_init_ctx,
619            &dispatcher,
620            &*self.externs,
621        )
622        .await
623        .map_err(|e| TaskLaunchError::FlowEval(e.to_string()))?;
624        Ok(TaskLaunchOutput { token, final_ctx })
625    }
626}
627
628// ──────────────────────────────────────────────────────────────────────────
629// UT
630// ──────────────────────────────────────────────────────────────────────────
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerRegistry};
636    use crate::blueprint::{
637        current_schema_version, AgentDef, AgentKind, AgentMeta, BlueprintMetadata, CompilerHints,
638        CompilerStrategy, MetaDef,
639    };
640    use crate::core::config::EngineCfg;
641    use crate::worker::adapter::{WorkerError, WorkerResult};
642    use mlua_flow_ir::{Expr, JoinMode, Node as FlowNode};
643    use serde_json::json;
644    use std::sync::Arc;
645
646    fn path(s: &str) -> Expr {
647        Expr::Path { at: s.to_string() }
648    }
649    fn step(ref_: &str, in_: Expr, out: Expr) -> FlowNode {
650        FlowNode::Step {
651            ref_: ref_.to_string(),
652            in_,
653            out,
654        }
655    }
656
657    fn agent(name: &str, fn_id: &str) -> AgentDef {
658        AgentDef {
659            name: name.to_string(),
660            kind: AgentKind::RustFn,
661            spec: json!({ "fn_id": fn_id }),
662            profile: None,
663            meta: Some(AgentMeta::default()),
664        }
665    }
666
667    fn build_service(factory: RustFnInProcessSpawnerFactory) -> TaskLaunchService {
668        let engine = Engine::new(EngineCfg::default());
669        let mut reg = SpawnerRegistry::new();
670        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
671        let compiler = Compiler::new(reg);
672        TaskLaunchService::new(engine, compiler)
673    }
674
675    fn bp(flow: FlowNode, agents: Vec<AgentDef>) -> Blueprint {
676        Blueprint {
677            schema_version: current_schema_version(),
678            id: "ut".into(),
679            flow,
680            agents,
681            operators: vec![],
682            metas: vec![],
683            hints: CompilerHints::default(),
684            strategy: CompilerStrategy::default(),
685            metadata: BlueprintMetadata::default(),
686            spawner_hints: Default::default(),
687            default_agent_kind: AgentKind::Operator,
688            default_operator_kind: None,
689            default_init_ctx: None,
690            default_agent_ctx: None,
691            default_context_policy: None,
692        }
693    }
694
695    fn launch_input(blueprint: Blueprint, init_ctx: Value) -> TaskLaunchInput {
696        TaskLaunchInput::automate(
697            blueprint,
698            "ut-op",
699            Role::Operator,
700            Duration::from_secs(30),
701            init_ctx,
702        )
703    }
704
705    #[tokio::test]
706    async fn launch_single_step_writes_out_path() {
707        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
708            Ok(WorkerResult {
709                value: json!({ "echoed": inv.prompt }),
710                ok: true,
711            })
712        });
713        let svc = build_service(factory);
714        let blueprint = bp(
715            step("echo", path("$.input"), path("$.out")),
716            vec![agent("echo", "echo")],
717        );
718        let out = svc
719            .launch(launch_input(blueprint, json!({ "input": "hi" })))
720            .await
721            .expect("launch ok");
722        assert_eq!(out.final_ctx["out"]["echoed"], "hi");
723    }
724
725    #[tokio::test]
726    async fn launch_three_step_seq_threads_ctx_forward() {
727        let factory = RustFnInProcessSpawnerFactory::new()
728            .register_fn("upper", |inv| async move {
729                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
730                Ok(WorkerResult {
731                    value: json!(s.to_uppercase()),
732                    ok: true,
733                })
734            })
735            .register_fn("suffix", |inv| async move {
736                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
737                Ok(WorkerResult {
738                    value: json!(format!("{s}!")),
739                    ok: true,
740                })
741            })
742            .register_fn("wrap", |inv| async move {
743                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
744                Ok(WorkerResult {
745                    value: json!(format!("[{s}]")),
746                    ok: true,
747                })
748            });
749        let svc = build_service(factory);
750        let flow = FlowNode::Seq {
751            children: vec![
752                step("upper", path("$.in"), path("$.s1")),
753                step("suffix", path("$.s1"), path("$.s2")),
754                step("wrap", path("$.s2"), path("$.s3")),
755            ],
756        };
757        let blueprint = bp(
758            flow,
759            vec![
760                agent("upper", "upper"),
761                agent("suffix", "suffix"),
762                agent("wrap", "wrap"),
763            ],
764        );
765        let out = svc
766            .launch(launch_input(blueprint, json!({ "in": "hello" })))
767            .await
768            .expect("launch ok");
769        assert_eq!(out.final_ctx["s1"], "HELLO");
770        assert_eq!(out.final_ctx["s2"], "HELLO!");
771        assert_eq!(out.final_ctx["s3"], "[HELLO!]");
772    }
773
774    #[tokio::test]
775    async fn launch_fanout_join_all_parallel_completes() {
776        use std::sync::atomic::{AtomicU32, Ordering};
777        let counter = Arc::new(AtomicU32::new(0));
778        let max_seen = Arc::new(AtomicU32::new(0));
779        let counter_clone = counter.clone();
780        let max_clone = max_seen.clone();
781
782        // Each worker bumps the inflight counter up, sleeps 50ms, then bumps it down.
783        // When parallel execution is working, max inflight exceeds 1.
784        let factory = RustFnInProcessSpawnerFactory::new().register_fn("para", move |inv| {
785            let counter = counter_clone.clone();
786            let max_seen = max_clone.clone();
787            async move {
788                let now = counter.fetch_add(1, Ordering::SeqCst) + 1;
789                let mut prev = max_seen.load(Ordering::SeqCst);
790                while now > prev {
791                    match max_seen.compare_exchange(prev, now, Ordering::SeqCst, Ordering::SeqCst) {
792                        Ok(_) => break,
793                        Err(p) => prev = p,
794                    }
795                }
796                tokio::time::sleep(Duration::from_millis(50)).await;
797                counter.fetch_sub(1, Ordering::SeqCst);
798                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
799                Ok(WorkerResult {
800                    value: json!(format!("did:{s}")),
801                    ok: true,
802                })
803            }
804        });
805        let svc = build_service(factory);
806        let flow = FlowNode::Fanout {
807            items: path("$.items"),
808            bind: path("$.item"),
809            body: Box::new(step("para", path("$.item"), path("$.r"))),
810            join: JoinMode::All,
811            out: path("$.results"),
812        };
813        let blueprint = bp(flow, vec![agent("para", "para")]);
814        let out = svc
815            .launch(launch_input(
816                blueprint,
817                json!({ "items": ["a", "b", "c", "d"] }),
818            ))
819            .await
820            .expect("launch ok");
821        let results = out.final_ctx["results"].as_array().expect("array");
822        assert_eq!(results.len(), 4);
823        for (i, expected) in ["a", "b", "c", "d"].iter().enumerate() {
824            assert_eq!(results[i]["r"], json!(format!("did:{expected}")));
825        }
826        let max = max_seen.load(Ordering::SeqCst);
827        assert!(
828            max >= 2,
829            "expected parallel execution (max inflight >= 2), got {max}"
830        );
831    }
832
833    #[tokio::test]
834    async fn launch_propagates_worker_error_as_flow_eval_err() {
835        let factory = RustFnInProcessSpawnerFactory::new()
836            .register_fn("ok", |inv| async move {
837                Ok(WorkerResult {
838                    value: json!(inv.prompt),
839                    ok: true,
840                })
841            })
842            .register_fn("boom", |_inv| async move {
843                Err(WorkerError::Failed("intentional boom".into()))
844            });
845        let svc = build_service(factory);
846        let flow = FlowNode::Seq {
847            children: vec![
848                step("ok", path("$.input"), path("$.s1")),
849                step("boom", path("$.s1"), path("$.s2")),
850                step("ok", path("$.s2"), path("$.s3")),
851            ],
852        };
853        let blueprint = bp(flow, vec![agent("ok", "ok"), agent("boom", "boom")]);
854        let err = svc
855            .launch(launch_input(blueprint, json!({ "input": "x" })))
856            .await
857            .expect_err("expected fail");
858        match err {
859            TaskLaunchError::FlowEval(msg) => {
860                assert!(
861                    msg.contains("boom") || msg.contains("intentional"),
862                    "expected error to mention worker failure, got: {msg}"
863                );
864            }
865            other => panic!("expected FlowEval error, got {other:?}"),
866        }
867    }
868
869    #[tokio::test]
870    async fn launch_resolves_call_extern_via_registered_externs() {
871        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
872            Ok(WorkerResult {
873                value: json!({ "echoed": inv.prompt }),
874                ok: true,
875            })
876        });
877        let mut externs = mlua_flow_ir::ExternMap::new();
878        externs.register("fmt.greet", |args: &[Value]| {
879            let name = args[0].as_str().unwrap_or("?");
880            Ok(json!(format!("hello, {name}")))
881        });
882        let svc = build_service(factory).with_externs(Arc::new(externs));
883        let flow = step(
884            "echo",
885            Expr::CallExtern {
886                ref_: "fmt.greet".into(),
887                args: vec![path("$.who")],
888            },
889            path("$.out"),
890        );
891        let blueprint = bp(flow, vec![agent("echo", "echo")]);
892        let out = svc
893            .launch(launch_input(blueprint, json!({ "who": "swarm" })))
894            .await
895            .expect("launch ok");
896        assert_eq!(out.final_ctx["out"]["echoed"], json!("hello, swarm"));
897    }
898
899    #[tokio::test]
900    async fn launch_call_extern_without_registry_fails_as_flow_eval() {
901        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
902            Ok(WorkerResult {
903                value: json!(inv.prompt),
904                ok: true,
905            })
906        });
907        let svc = build_service(factory); // default NoExterns
908        let flow = step(
909            "echo",
910            Expr::CallExtern {
911                ref_: "fmt.greet".into(),
912                args: vec![],
913            },
914            path("$.out"),
915        );
916        let blueprint = bp(flow, vec![agent("echo", "echo")]);
917        let err = svc
918            .launch(launch_input(blueprint, json!({})))
919            .await
920            .expect_err("expected fail");
921        match err {
922            TaskLaunchError::FlowEval(msg) => {
923                assert!(msg.contains("extern"), "expected extern error, got: {msg}");
924            }
925            other => panic!("expected FlowEval error, got {other:?}"),
926        }
927    }
928
929    // ──────────────────────────────────────────────────────────────────
930    // issue #13 run_id propagation (`TaskLaunchInput.run_ctx`)
931    // ──────────────────────────────────────────────────────────────────
932
933    #[tokio::test]
934    async fn launch_with_run_ctx_appends_one_step_entry_per_dispatched_step() {
935        use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
936        use crate::types::{RunId, TaskId};
937
938        let factory = RustFnInProcessSpawnerFactory::new()
939            .register_fn("upper", |inv| async move {
940                Ok(WorkerResult {
941                    value: json!(inv.prompt.to_uppercase()),
942                    ok: true,
943                })
944            })
945            .register_fn("suffix", |inv| async move {
946                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
947                Ok(WorkerResult {
948                    value: json!(format!("{s}!")),
949                    ok: true,
950                })
951            });
952        let svc = build_service(factory);
953        let flow = FlowNode::Seq {
954            children: vec![
955                step("upper", path("$.in"), path("$.s1")),
956                step("suffix", path("$.s1"), path("$.s2")),
957            ],
958        };
959        let blueprint = bp(
960            flow,
961            vec![agent("upper", "upper"), agent("suffix", "suffix")],
962        );
963
964        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
965        let run_id = RunId::new();
966        run_store
967            .create(RunRecord {
968                id: run_id.clone(),
969                task_id: TaskId::new(),
970                status: RunStatus::Running,
971                step_entries: Vec::new(),
972                operator_sid: None,
973                result_ref: None,
974                created_at: 0,
975                updated_at: 0,
976            })
977            .await
978            .expect("seed RunRecord");
979
980        let mut input = launch_input(blueprint, json!({ "in": "hi" }));
981        input.run_ctx = Some(RunContext {
982            run_id: run_id.clone(),
983            run_store: run_store.clone(),
984        });
985
986        let out = svc.launch(input).await.expect("launch ok");
987        assert_eq!(out.final_ctx["s2"], "HI!");
988
989        let run = run_store.get(&run_id).await.expect("run present");
990        assert_eq!(
991            run.step_entries.len(),
992            2,
993            "expected one step_entry per dispatched step, got {:?}",
994            run.step_entries
995        );
996        assert_eq!(run.step_entries[0].step_ref, Some("upper".to_string()));
997        assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
998        assert_eq!(run.step_entries[1].step_ref, Some("suffix".to_string()));
999        assert_eq!(run.step_entries[1].status, Some("passed".to_string()));
1000    }
1001
1002    #[tokio::test]
1003    async fn launch_without_run_ctx_appends_no_step_entries() {
1004        // `run_ctx: None` (the `automate()` default) must not touch any
1005        // `RunStore` — this is the pre-existing no-tracing behavior, kept
1006        // as a regression guard alongside the `Some` case above.
1007        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1008            Ok(WorkerResult {
1009                value: json!(inv.prompt),
1010                ok: true,
1011            })
1012        });
1013        let svc = build_service(factory);
1014        let blueprint = bp(
1015            step("echo", path("$.input"), path("$.out")),
1016            vec![agent("echo", "echo")],
1017        );
1018        let input = launch_input(blueprint, json!({ "input": "hi" }));
1019        assert!(
1020            input.run_ctx.is_none(),
1021            "automate() defaults run_ctx to None"
1022        );
1023        let out = svc.launch(input).await.expect("launch ok");
1024        assert_eq!(out.final_ctx["out"], "hi");
1025    }
1026
1027    // ──────────────────────────────────────────────────────────────────
1028    // issue #19 ST2: `TaskLaunchInput.task_input` (direct-sibling-read
1029    // replacement for the ST1 `from_init_ctx(&input.init_ctx)` call)
1030    // ──────────────────────────────────────────────────────────────────
1031
1032    #[tokio::test]
1033    async fn launch_with_task_input_leaves_init_ctx_object_seed_unmutated() {
1034        // Issue #19 ST2 invariant: `init_ctx` is a pure flow-ir eval seed —
1035        // `task_input` must not be folded into it. Regression guard for the
1036        // ST1 `resolve_task_level_init_ctx` fold-back this subtask removes:
1037        // if it ever crept back in here, `project_root` / `work_dir` /
1038        // `task_metadata` would leak into `final_ctx` as extra top-level
1039        // keys nobody wrote via a `Step.out`.
1040        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1041            Ok(WorkerResult {
1042                value: json!({ "echoed": inv.prompt }),
1043                ok: true,
1044            })
1045        });
1046        let svc = build_service(factory);
1047        let blueprint = bp(
1048            step("echo", path("$.input"), path("$.out")),
1049            vec![agent("echo", "echo")],
1050        );
1051        let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1052        input.task_input = Some(TaskInputSpec {
1053            project_root: Some("/repo".to_string()),
1054            work_dir: Some("/repo/work".to_string()),
1055            task_metadata: Some(json!({ "issue": 19 })),
1056        });
1057        let out = svc.launch(input).await.expect("launch ok");
1058        assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1059        assert!(
1060            out.final_ctx.get("project_root").is_none(),
1061            "task_input must not be folded into the flow-ir ctx seed, got {:?}",
1062            out.final_ctx
1063        );
1064        assert!(out.final_ctx.get("work_dir").is_none());
1065        assert!(out.final_ctx.get("task_metadata").is_none());
1066    }
1067
1068    #[tokio::test]
1069    async fn launch_with_task_input_none_is_a_no_op() {
1070        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1071            Ok(WorkerResult {
1072                value: json!(inv.prompt),
1073                ok: true,
1074            })
1075        });
1076        let svc = build_service(factory);
1077        let blueprint = bp(
1078            step("echo", path("$.input"), path("$.out")),
1079            vec![agent("echo", "echo")],
1080        );
1081        let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1082        assert!(input.task_input.is_none(), "automate() defaults to None");
1083        input.task_input = None;
1084        let out = svc.launch(input).await.expect("launch ok");
1085        assert_eq!(out.final_ctx["out"], "hi");
1086    }
1087
1088    #[tokio::test]
1089    async fn launch_with_task_input_all_fields_absent_is_a_no_op() {
1090        // `Some(TaskInputSpec::default())` — outer Some, all 3 inner fields
1091        // None — must behave identically to `task_input: None` (mirrors
1092        // `TaskInputMiddleware::new_from_fields`'s own no-op contract).
1093        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1094            Ok(WorkerResult {
1095                value: json!(inv.prompt),
1096                ok: true,
1097            })
1098        });
1099        let svc = build_service(factory);
1100        let blueprint = bp(
1101            step("echo", path("$.input"), path("$.out")),
1102            vec![agent("echo", "echo")],
1103        );
1104        let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1105        input.task_input = Some(TaskInputSpec::default());
1106        let out = svc.launch(input).await.expect("launch ok");
1107        assert_eq!(out.final_ctx["out"], "hi");
1108    }
1109
1110    // ──────────────────────────────────────────────────────────────────
1111    // issue #19 ST3: `merge_init_ctx` (BP default + Task init_ctx)
1112    // ──────────────────────────────────────────────────────────────────
1113
1114    #[test]
1115    fn merge_init_ctx_bp_default_only_passes_through_when_task_is_empty_object() {
1116        let bp_default = json!({ "seeded": "from-bp" });
1117        let task = json!({});
1118        let merged = merge_init_ctx(Some(&bp_default), &task);
1119        assert_eq!(merged, json!({ "seeded": "from-bp" }));
1120    }
1121
1122    #[test]
1123    fn merge_init_ctx_task_only_passes_through_when_bp_default_is_empty_object() {
1124        let bp_default = json!({});
1125        let task = json!({ "seeded": "from-task" });
1126        let merged = merge_init_ctx(Some(&bp_default), &task);
1127        assert_eq!(merged, json!({ "seeded": "from-task" }));
1128    }
1129
1130    #[test]
1131    fn merge_init_ctx_both_objects_task_wins_on_key_collision() {
1132        let bp_default = json!({ "a": "bp", "b": "bp-only" });
1133        let task = json!({ "a": "task", "c": "task-only" });
1134        let merged = merge_init_ctx(Some(&bp_default), &task);
1135        assert_eq!(
1136            merged,
1137            json!({ "a": "task", "b": "bp-only", "c": "task-only" })
1138        );
1139    }
1140
1141    #[test]
1142    fn merge_init_ctx_non_object_task_fully_replaces_bp_default() {
1143        let bp_default = json!({ "seeded": "from-bp" });
1144        let task = json!("plain-string-seed");
1145        let merged = merge_init_ctx(Some(&bp_default), &task);
1146        assert_eq!(merged, json!("plain-string-seed"));
1147    }
1148
1149    #[test]
1150    fn merge_init_ctx_no_bp_default_is_a_no_op() {
1151        let task = json!({ "input": "hi" });
1152        let merged = merge_init_ctx(None, &task);
1153        assert_eq!(merged, task);
1154    }
1155
1156    // ──────────────────────────────────────────────────────────────────
1157    // issue #19 ST4: `merge_init_ctx_3layer` (BP default + Task + Run)
1158    // ──────────────────────────────────────────────────────────────────
1159
1160    #[test]
1161    fn merge_init_ctx_3layer_no_run_override_equals_bp_task_merge_only() {
1162        // `run_override: None` must be a pure pass-through of the BP+Task
1163        // merge — this is the `POST /v1/tasks/:id/runs` no-body rekick
1164        // path, which must preserve pre-#19 behavior byte-for-byte.
1165        let bp_default = json!({ "a": "bp", "b": "bp-only" });
1166        let task = json!({ "a": "task", "c": "task-only" });
1167        let three_layer = merge_init_ctx_3layer(Some(&bp_default), &task, None);
1168        let two_layer = merge_init_ctx(Some(&bp_default), &task);
1169        assert_eq!(three_layer, two_layer);
1170        assert_eq!(
1171            three_layer,
1172            json!({ "a": "task", "b": "bp-only", "c": "task-only" })
1173        );
1174    }
1175
1176    #[test]
1177    fn merge_init_ctx_3layer_run_object_wins_on_key_collision_over_bp_and_task() {
1178        let bp_default = json!({ "a": "bp", "b": "bp-only" });
1179        let task = json!({ "a": "task", "c": "task-only" });
1180        let run_override = json!({ "a": "run", "d": "run-only" });
1181        let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
1182        assert_eq!(
1183            merged,
1184            json!({ "a": "run", "b": "bp-only", "c": "task-only", "d": "run-only" }),
1185            "Run wins on collision (a); BP-only (b) and Task-only (c) keys survive"
1186        );
1187    }
1188
1189    #[test]
1190    fn merge_init_ctx_3layer_run_non_object_fully_replaces_bp_task_merge() {
1191        let bp_default = json!({ "seeded": "from-bp" });
1192        let task = json!({ "seeded": "from-task" });
1193        let run_override = json!("plain-string-run-seed");
1194        let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
1195        assert_eq!(merged, json!("plain-string-run-seed"));
1196    }
1197
1198    #[test]
1199    fn merge_init_ctx_3layer_no_bp_default_and_no_run_override_is_task_passthrough() {
1200        let task = json!({ "input": "hi" });
1201        let merged = merge_init_ctx_3layer(None, &task, None);
1202        assert_eq!(merged, task);
1203    }
1204
1205    #[tokio::test]
1206    async fn launch_merges_bp_default_init_ctx_into_task_init_ctx() {
1207        // End-to-end guard: `Blueprint.default_init_ctx` actually reaches
1208        // `eval_async_externs` — not merely unit-tested in isolation.
1209        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1210            Ok(WorkerResult {
1211                value: json!(inv.prompt),
1212                ok: true,
1213            })
1214        });
1215        let svc = build_service(factory);
1216        let mut blueprint = bp(
1217            step("echo", path("$.greeting"), path("$.out")),
1218            vec![agent("echo", "echo")],
1219        );
1220        blueprint.default_init_ctx = Some(json!({ "greeting": "hello from bp" }));
1221        // Task supplies an empty object — BP default alone seeds `$.greeting`.
1222        let out = svc
1223            .launch(launch_input(blueprint, json!({})))
1224            .await
1225            .expect("launch ok");
1226        assert_eq!(out.final_ctx["out"], "hello from bp");
1227    }
1228
1229    // ──────────────────────────────────────────────────────────────────
1230    // issue #21 Phase 1: `derive_agent_ctx` / `derive_context_policies`
1231    // ──────────────────────────────────────────────────────────────────
1232
1233    fn agent_with_meta(name: &str, fn_id: &str, meta: AgentMeta) -> AgentDef {
1234        AgentDef {
1235            name: name.to_string(),
1236            kind: AgentKind::RustFn,
1237            spec: json!({ "fn_id": fn_id }),
1238            profile: None,
1239            meta: Some(meta),
1240        }
1241    }
1242
1243    #[test]
1244    fn derive_agent_ctx_empty_blueprint_yields_empty_state() {
1245        let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1246        let (global, per_agent) = derive_agent_ctx(&blueprint);
1247        assert_eq!(global, None);
1248        assert!(per_agent.is_empty());
1249    }
1250
1251    #[test]
1252    fn derive_agent_ctx_populated_blueprint_yields_correct_maps() {
1253        let mut blueprint = bp(
1254            step("echo", path("$.in"), path("$.out")),
1255            vec![
1256                agent_with_meta(
1257                    "with-ctx",
1258                    "echo",
1259                    AgentMeta {
1260                        ctx: Some(json!({ "org_conventions": "x" })),
1261                        ..Default::default()
1262                    },
1263                ),
1264                agent("no-ctx", "echo"),
1265            ],
1266        );
1267        blueprint.default_agent_ctx = Some(json!({ "seeded": "from-bp" }));
1268        let (global, per_agent) = derive_agent_ctx(&blueprint);
1269        assert_eq!(global, Some(json!({ "seeded": "from-bp" })));
1270        assert_eq!(
1271            per_agent.len(),
1272            1,
1273            "agents without AgentMeta.ctx are absent, not defaulted to null: {per_agent:?}"
1274        );
1275        assert_eq!(
1276            per_agent.get("with-ctx"),
1277            Some(&json!({ "org_conventions": "x" }))
1278        );
1279        assert!(!per_agent.contains_key("no-ctx"));
1280    }
1281
1282    #[test]
1283    fn derive_context_policies_empty_blueprint_yields_empty_state() {
1284        let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1285        let (default_policy, per_agent) = derive_context_policies(&blueprint);
1286        assert_eq!(default_policy, None);
1287        assert!(per_agent.is_empty());
1288    }
1289
1290    #[test]
1291    fn derive_context_policies_populated_blueprint_yields_correct_maps() {
1292        let mut blueprint = bp(
1293            step("echo", path("$.in"), path("$.out")),
1294            vec![
1295                agent_with_meta(
1296                    "with-policy",
1297                    "echo",
1298                    AgentMeta {
1299                        context_policy: Some(ContextPolicy {
1300                            include: None,
1301                            exclude: vec!["work_dir".to_string()],
1302                            ..Default::default()
1303                        }),
1304                        ..Default::default()
1305                    },
1306                ),
1307                agent("no-policy", "echo"),
1308            ],
1309        );
1310        blueprint.default_context_policy = Some(ContextPolicy {
1311            include: Some(vec!["project_root".to_string()]),
1312            exclude: vec![],
1313            ..Default::default()
1314        });
1315        let (default_policy, per_agent) = derive_context_policies(&blueprint);
1316        assert_eq!(
1317            default_policy,
1318            Some(ContextPolicy {
1319                include: Some(vec!["project_root".to_string()]),
1320                exclude: vec![],
1321                ..Default::default()
1322            })
1323        );
1324        assert_eq!(per_agent.len(), 1);
1325        assert_eq!(
1326            per_agent.get("with-policy"),
1327            Some(&ContextPolicy {
1328                include: None,
1329                exclude: vec!["work_dir".to_string()],
1330                ..Default::default()
1331            })
1332        );
1333        assert!(!per_agent.contains_key("no-policy"));
1334    }
1335
1336    // ──────────────────────────────────────────────────────────────────
1337    // issue #21 Phase 2: `derive_step_metas` / `AgentMeta.meta_ref`
1338    // resolution inside `derive_agent_ctx`
1339    // ──────────────────────────────────────────────────────────────────
1340
1341    #[test]
1342    fn derive_step_metas_empty_blueprint_yields_empty_map() {
1343        let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1344        assert!(derive_step_metas(&blueprint).is_empty());
1345    }
1346
1347    #[test]
1348    fn derive_step_metas_populated_blueprint_yields_name_to_ctx_map() {
1349        let mut blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1350        blueprint.metas = vec![
1351            MetaDef {
1352                name: "heavy-scan".to_string(),
1353                ctx: json!({ "work_dir": "/x" }),
1354            },
1355            MetaDef {
1356                name: "light-scan".to_string(),
1357                ctx: json!({ "work_dir": "/y" }),
1358            },
1359        ];
1360        let metas = derive_step_metas(&blueprint);
1361        assert_eq!(metas.len(), 2);
1362        assert_eq!(metas.get("heavy-scan"), Some(&json!({ "work_dir": "/x" })));
1363        assert_eq!(metas.get("light-scan"), Some(&json!({ "work_dir": "/y" })));
1364    }
1365
1366    #[test]
1367    fn derive_agent_ctx_meta_ref_resolves_as_base_under_inline_ctx() {
1368        let mut blueprint = bp(
1369            step("echo", path("$.in"), path("$.out")),
1370            vec![agent_with_meta(
1371                "with-meta-ref",
1372                "echo",
1373                AgentMeta {
1374                    ctx: Some(json!({ "work_dir": "/inline-wins" })),
1375                    meta_ref: Some("shared".to_string()),
1376                    ..Default::default()
1377                },
1378            )],
1379        );
1380        blueprint.metas = vec![MetaDef {
1381            name: "shared".to_string(),
1382            ctx: json!({ "work_dir": "/base", "extra": "from-pool" }),
1383        }];
1384        let (_, per_agent) = derive_agent_ctx(&blueprint);
1385        assert_eq!(
1386            per_agent.get("with-meta-ref"),
1387            Some(&json!({ "work_dir": "/inline-wins", "extra": "from-pool" })),
1388            "inline ctx must win the collided key while pool-only keys survive the merge"
1389        );
1390    }
1391
1392    #[test]
1393    fn derive_agent_ctx_meta_ref_alone_uses_pool_ctx_verbatim() {
1394        let mut blueprint = bp(
1395            step("echo", path("$.in"), path("$.out")),
1396            vec![agent_with_meta(
1397                "with-meta-ref-only",
1398                "echo",
1399                AgentMeta {
1400                    meta_ref: Some("shared".to_string()),
1401                    ..Default::default()
1402                },
1403            )],
1404        );
1405        blueprint.metas = vec![MetaDef {
1406            name: "shared".to_string(),
1407            ctx: json!({ "work_dir": "/base" }),
1408        }];
1409        let (_, per_agent) = derive_agent_ctx(&blueprint);
1410        assert_eq!(
1411            per_agent.get("with-meta-ref-only"),
1412            Some(&json!({ "work_dir": "/base" }))
1413        );
1414    }
1415
1416    #[test]
1417    fn derive_agent_ctx_unresolved_meta_ref_never_panics_and_falls_back_to_inline() {
1418        let blueprint = bp(
1419            step("echo", path("$.in"), path("$.out")),
1420            vec![agent_with_meta(
1421                "with-unresolved-meta-ref",
1422                "echo",
1423                AgentMeta {
1424                    ctx: Some(json!({ "work_dir": "/inline-only" })),
1425                    meta_ref: Some("missing".to_string()),
1426                    ..Default::default()
1427                },
1428            )],
1429        );
1430        // No `blueprint.metas` entries at all — `meta_ref` unresolved.
1431        let (_, per_agent) = derive_agent_ctx(&blueprint);
1432        assert_eq!(
1433            per_agent.get("with-unresolved-meta-ref"),
1434            Some(&json!({ "work_dir": "/inline-only" })),
1435            "an unresolved meta_ref must never panic; the agent's own inline ctx still applies"
1436        );
1437    }
1438}