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