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::ctx::OperatorKind;
25use crate::core::engine::Engine;
26use crate::core::errors::EngineError;
27use crate::middleware::project_name_alias::ProjectNameAliasMiddleware;
28use crate::middleware::worker_binding::WorkerBindingMiddleware;
29use crate::middleware::SpawnerStack;
30use crate::operator::WorkerBinding;
31use crate::service::linker;
32use crate::types::{CapToken, Role};
33use mlua_flow_ir::{Externs, NoExterns};
34use serde_json::Value;
35use std::collections::HashMap;
36use std::sync::Arc;
37use std::time::Duration;
38use thiserror::Error;
39
40/// Derive the "BP Agent-level" tier of the `OperatorKind` cascade from a
41/// Blueprint: for every `AgentDef` whose `spec.operator_ref` resolves to an
42/// `OperatorDef` with a `Some` `kind`, map `AgentDef.name -> OperatorKind`.
43///
44/// Deliberately **not** filtered by `AgentDef.kind == AgentKind::Operator`:
45/// the `OperatorKind` cascade is a middleware-level cross-cutting concern
46/// (spawn_hook / senior_bridge / operator-delegate gating via `Ctx.operator`),
47/// orthogonal to the Worker IMPL axis that `AgentKind` expresses (see the
48/// crate root doc, "Operator is delivered as a cross-cutting overlay through
49/// `Ctx` plus middleware"). A `RustFn` / `Lua` / `Subprocess` agent can
50/// equally declare `spec.operator_ref` to opt into a BP-declared
51/// `OperatorKind` without changing its Worker IMPL. Agents without an
52/// `operator_ref`, an unresolved `operator_ref`, or an `OperatorDef.kind =
53/// None` are simply absent from the map (= that tier falls through for
54/// them). This is a separate, independent consumer of `Blueprint.operators`
55/// from the design-time `operator_ref` validation in
56/// `blueprint::compiler::Compiler::compile` (issue: `OperatorDef`
57/// first-class treatment), which only checks the reference resolves for
58/// `AgentKind::Operator` agents and is unaffected by this function.
59/// Build the `agent name → WorkerBinding` map from
60/// `Blueprint.agents[].profile.worker_binding` — the launch-time sibling of
61/// the compile-time resolution in `OperatorSpawnerFactory::build`. Consumed
62/// by `WorkerBindingMiddleware` so the delegate axis
63/// (`OperatorDelegateMiddleware`) can resolve the binding via `ctx.agent`
64/// like every other agent-keyed table (`CompiledAgentTable.routes` idiom).
65/// Agents without a declared binding are simply absent (no silent default).
66fn derive_worker_bindings(blueprint: &Blueprint) -> HashMap<String, WorkerBinding> {
67    blueprint
68        .agents
69        .iter()
70        .filter_map(|ad| {
71            let profile = ad.profile.as_ref()?;
72            let variant = profile.worker_binding.as_ref()?;
73            Some((
74                ad.name.clone(),
75                WorkerBinding {
76                    variant: variant.clone(),
77                    tools: profile.tools.clone(),
78                },
79            ))
80        })
81        .collect()
82}
83
84fn derive_bp_agent_kinds(blueprint: &Blueprint) -> HashMap<String, OperatorKind> {
85    let mut out = HashMap::new();
86    if blueprint.operators.is_empty() {
87        return out;
88    }
89    for agent in &blueprint.agents {
90        let Some(op_ref) = agent.spec.get("operator_ref").and_then(|v| v.as_str()) else {
91            continue;
92        };
93        let Some(op_def) = blueprint.operators.iter().find(|o| o.name == op_ref) else {
94            continue;
95        };
96        if let Some(kind) = op_def.kind {
97            out.insert(agent.name.clone(), OperatorKind::from(kind));
98        }
99    }
100    out
101}
102
103/// Failure modes of [`TaskLaunchService::launch`].
104#[derive(Debug, Error)]
105pub enum TaskLaunchError {
106    /// `Compiler::compile` rejected the Blueprint.
107    #[error("compile: {0}")]
108    Compile(#[from] CompileError),
109    /// `Engine::attach_with_ids` failed.
110    #[error("engine: {0}")]
111    Engine(#[from] EngineError),
112    /// A `Step` inside `flow.ir`'s `eval_async` produced a dispatcher
113    /// error, or a sub-flow raised.
114    #[error("flow eval: {0}")]
115    FlowEval(String),
116}
117
118/// Input to [`TaskLaunchService::launch`].
119#[derive(Debug, Clone)]
120pub struct TaskLaunchInput {
121    /// The Blueprint to compile, link, and run.
122    pub blueprint: Blueprint,
123    /// Caller-supplied id for the Operator that owns this run.
124    pub operator_id: String,
125    /// The Operator's role for this run.
126    pub role: Role,
127    /// How long the attached session is allowed to live.
128    pub ttl: Duration,
129    /// "Runtime Global" tier of the `OperatorKind` cascade. `Some(_)` is
130    /// always an explicit request — including `Some(OperatorKind::Automate)`
131    /// — that outranks the BP-level tiers (`OperatorDef.kind` /
132    /// `Blueprint.default_operator_kind`); `None` leaves it unspecified so
133    /// those tiers / the final default decide. Under `MainAi` or
134    /// `Composite`, `MainAIMiddleware`'s `spawn_hook` before/after
135    /// callbacks become effective. See
136    /// `crate::core::ctx::collapse_operator_kind`.
137    pub operator_kind: Option<OperatorKind>,
138    /// `SeniorBridge` registry ID. `None` — no bridge; `Some(id)` —
139    /// attach a bridge previously registered via
140    /// `engine.register_senior_bridge`.
141    pub bridge_id: Option<String>,
142    /// `SpawnHook` registry ID. Same shape as above, via
143    /// `engine.register_spawn_hook`.
144    pub hook_id: Option<String>,
145    /// Operator registry ID — used on the path that hands the whole
146    /// spawn off to an external Operator. Name previously registered
147    /// with `engine.register_operator`; resolved by
148    /// `OperatorDelegateMiddleware`, which — for `kind = MainAi` or
149    /// `Composite` — bypasses `inner.spawn` and calls
150    /// `operator.execute`.
151    pub operator_backend_id: Option<String>,
152    /// "Runtime Agent-level" tier (highest priority) of the `OperatorKind`
153    /// cascade — per-agent override, keyed by `AgentDef.name`. Empty by
154    /// default (no override for any agent). See
155    /// `crate::core::ctx::collapse_operator_kind` for the full tier list.
156    pub operator_kind_overrides: HashMap<String, OperatorKind>,
157    /// The initial `ctx` (JSON `Value`) that flow.ir's `eval_async`
158    /// starts from. Every `Step.in` `$.<path>` reference reads from
159    /// here.
160    pub init_ctx: Value,
161}
162
163impl TaskLaunchInput {
164    /// Helper for existing callers on the default path — no hooks and no
165    /// per-agent `OperatorKind` overrides. Leaves the "Runtime Global" tier
166    /// unspecified (`None`), so the BP-level tiers / final default
167    /// (`OperatorKind::Automate`) decide — this preserves today's
168    /// behaviour for every existing caller without silently forcing
169    /// `Automate` as an explicit override that would outrank a BP-declared
170    /// `MainAi`/`Composite` kind.
171    pub fn automate(
172        blueprint: Blueprint,
173        operator_id: impl Into<String>,
174        role: Role,
175        ttl: Duration,
176        init_ctx: Value,
177    ) -> Self {
178        Self {
179            blueprint,
180            operator_id: operator_id.into(),
181            role,
182            ttl,
183            operator_kind: None,
184            bridge_id: None,
185            hook_id: None,
186            operator_backend_id: None,
187            operator_kind_overrides: HashMap::new(),
188            init_ctx,
189        }
190    }
191}
192
193/// Result of a successful [`TaskLaunchService::launch`] call.
194#[derive(Debug, Clone)]
195pub struct TaskLaunchOutput {
196    /// The capability token for the attached session.
197    pub token: CapToken,
198    /// The final `ctx` after the flow ran — every `Step.out` has
199    /// been written. Application-layer callers pull the outcome out
200    /// of this `Value` and fold it into a domain status.
201    pub final_ctx: Value,
202}
203
204/// Domain service that compiles, links, and runs a Blueprint's flow to
205/// completion through the [`Engine`]. See the module doc for the full
206/// responsibility list.
207pub struct TaskLaunchService {
208    engine: Engine,
209    compiler: Compiler,
210    /// `call_extern` registry threaded into flow eval. Defaults to
211    /// [`NoExterns`] (= every `call_extern` in a Blueprint raises
212    /// `ExternError`); hosts opt in via [`Self::with_externs`] with an
213    /// `ExternMap` of pure value-shape functions.
214    externs: Arc<dyn Externs + Send + Sync>,
215}
216
217impl TaskLaunchService {
218    /// Build a service bound to one `Engine` and one `Compiler`.
219    pub fn new(engine: Engine, compiler: Compiler) -> Self {
220        Self {
221            engine,
222            compiler,
223            externs: Arc::new(NoExterns),
224        }
225    }
226
227    /// Replace the `call_extern` registry (builder style). Entries MUST be
228    /// pure functions — no side effects, no flow control; effectful work
229    /// belongs to `Step` / agents, not externs (flow-ir canonical contract).
230    pub fn with_externs(mut self, externs: Arc<dyn Externs + Send + Sync>) -> Self {
231        self.externs = externs;
232        self
233    }
234
235    /// The bound `Engine`.
236    pub fn engine(&self) -> &Engine {
237        &self.engine
238    }
239
240    /// The bound `Compiler`.
241    pub fn compiler(&self) -> &Compiler {
242        &self.compiler
243    }
244
245    /// Run the Blueprint's flow to completion and return the final
246    /// `ctx`.
247    ///
248    /// Failure paths:
249    ///
250    /// - `compiler.compile` failure → `TaskLaunchError::Compile`.
251    /// - `engine.attach` failure → `TaskLaunchError::Engine`.
252    /// - A `Step` inside `flow eval` producing a dispatcher error, or
253    ///   a sub-flow raising, → `TaskLaunchError::FlowEval`. There is
254    ///   no silent partial-success completion; failures always
255    ///   propagate.
256    pub async fn launch(
257        &self,
258        input: TaskLaunchInput,
259    ) -> Result<TaskLaunchOutput, TaskLaunchError> {
260        // After the stateless-executor refactor, the
261        // caller (Service) does compile + link +
262        // `EngineDispatcher::with_spawner` itself; the engine no longer
263        // holds any global spawner state to touch. The link path (base
264        // `SpawnerAdapter` +
265        // `LayerRegistry` resolution + `SpawnerStack` wrapping) is
266        // concentrated inside `service::linker::link` — Service
267        // scatter is intentionally prevented.
268        let compiled = self.compiler.compile(&input.blueprint)?;
269        let spawner = linker::link(
270            compiled.router.clone(),
271            &input.blueprint.spawner_hints.layers,
272            &self.engine,
273        );
274        // When `Blueprint.metadata.project_name_alias` is Some, layer a
275        // `ProjectNameAliasMiddleware` on top of the stack that injects the
276        // alias into `Ctx.meta.runtime.project_name_alias` just before spawn.
277        // Downstream operators (for example, the server crate's
278        // `Operator.execute`) read `ctx.meta.runtime.get("project_name_alias")`
279        // and expand it into the Spawn directive prompt body.
280        let spawner = if let Some(alias) = input.blueprint.metadata.project_name_alias.as_deref() {
281            SpawnerStack::new(spawner)
282                .layer(ProjectNameAliasMiddleware::new(alias))
283                .build()
284        } else {
285            spawner
286        };
287        // Layer the Blueprint-baked worker bindings (same ctx.meta.runtime
288        // inject shape as the alias layer above) so the delegate axis can
289        // resolve per-agent variants — see `derive_worker_bindings`.
290        let worker_bindings = derive_worker_bindings(&input.blueprint);
291        let spawner = if worker_bindings.is_empty() {
292            spawner
293        } else {
294            SpawnerStack::new(spawner)
295                .layer(WorkerBindingMiddleware::new(worker_bindings))
296                .build()
297        };
298
299        // "BP Agent-level" (`OperatorDef.kind` via `operator_ref`) + "BP
300        // Global" (`Blueprint.default_operator_kind`) tiers of the
301        // `OperatorKind` cascade, baked here (the only point that has both
302        // the resolved Blueprint and the launch-time overrides in scope).
303        let bp_agent_kinds = derive_bp_agent_kinds(&input.blueprint);
304        let bp_global_kind = input
305            .blueprint
306            .default_operator_kind
307            .map(OperatorKind::from);
308
309        let token = self
310            .engine
311            .attach_with_ids(
312                input.operator_id,
313                input.role,
314                input.ttl,
315                input.operator_kind,
316                input.bridge_id,
317                input.hook_id,
318                input.operator_backend_id,
319                input.operator_kind_overrides,
320                bp_agent_kinds,
321                bp_global_kind,
322            )
323            .await?;
324        let dispatcher =
325            EngineDispatcher::with_spawner(self.engine.clone(), token.clone(), spawner);
326        let final_ctx = mlua_flow_ir::eval_async_externs(
327            &input.blueprint.flow,
328            input.init_ctx,
329            &dispatcher,
330            &*self.externs,
331        )
332        .await
333        .map_err(|e| TaskLaunchError::FlowEval(e.to_string()))?;
334        Ok(TaskLaunchOutput { token, final_ctx })
335    }
336}
337
338// ──────────────────────────────────────────────────────────────────────────
339// UT
340// ──────────────────────────────────────────────────────────────────────────
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerRegistry};
346    use crate::blueprint::{
347        current_schema_version, AgentDef, AgentKind, AgentMeta, BlueprintMetadata, CompilerHints,
348        CompilerStrategy,
349    };
350    use crate::core::config::EngineCfg;
351    use crate::worker::adapter::{WorkerError, WorkerResult};
352    use mlua_flow_ir::{Expr, JoinMode, Node as FlowNode};
353    use serde_json::json;
354    use std::sync::Arc;
355
356    fn path(s: &str) -> Expr {
357        Expr::Path { at: s.to_string() }
358    }
359    fn step(ref_: &str, in_: Expr, out: Expr) -> FlowNode {
360        FlowNode::Step {
361            ref_: ref_.to_string(),
362            in_,
363            out,
364        }
365    }
366
367    fn agent(name: &str, fn_id: &str) -> AgentDef {
368        AgentDef {
369            name: name.to_string(),
370            kind: AgentKind::RustFn,
371            spec: json!({ "fn_id": fn_id }),
372            profile: None,
373            meta: Some(AgentMeta::default()),
374        }
375    }
376
377    fn build_service(factory: RustFnInProcessSpawnerFactory) -> TaskLaunchService {
378        let engine = Engine::new(EngineCfg::default());
379        let mut reg = SpawnerRegistry::new();
380        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
381        let compiler = Compiler::new(reg);
382        TaskLaunchService::new(engine, compiler)
383    }
384
385    fn bp(flow: FlowNode, agents: Vec<AgentDef>) -> Blueprint {
386        Blueprint {
387            schema_version: current_schema_version(),
388            id: "ut".into(),
389            flow,
390            agents,
391            operators: vec![],
392            hints: CompilerHints::default(),
393            strategy: CompilerStrategy::default(),
394            metadata: BlueprintMetadata::default(),
395            spawner_hints: Default::default(),
396            default_agent_kind: AgentKind::Operator,
397            default_operator_kind: None,
398        }
399    }
400
401    fn launch_input(blueprint: Blueprint, init_ctx: Value) -> TaskLaunchInput {
402        TaskLaunchInput::automate(
403            blueprint,
404            "ut-op",
405            Role::Operator,
406            Duration::from_secs(30),
407            init_ctx,
408        )
409    }
410
411    #[tokio::test]
412    async fn launch_single_step_writes_out_path() {
413        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
414            Ok(WorkerResult {
415                value: json!({ "echoed": inv.prompt }),
416                ok: true,
417            })
418        });
419        let svc = build_service(factory);
420        let blueprint = bp(
421            step("echo", path("$.input"), path("$.out")),
422            vec![agent("echo", "echo")],
423        );
424        let out = svc
425            .launch(launch_input(blueprint, json!({ "input": "hi" })))
426            .await
427            .expect("launch ok");
428        assert_eq!(out.final_ctx["out"]["echoed"], "hi");
429    }
430
431    #[tokio::test]
432    async fn launch_three_step_seq_threads_ctx_forward() {
433        let factory = RustFnInProcessSpawnerFactory::new()
434            .register_fn("upper", |inv| async move {
435                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
436                Ok(WorkerResult {
437                    value: json!(s.to_uppercase()),
438                    ok: true,
439                })
440            })
441            .register_fn("suffix", |inv| async move {
442                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
443                Ok(WorkerResult {
444                    value: json!(format!("{s}!")),
445                    ok: true,
446                })
447            })
448            .register_fn("wrap", |inv| async move {
449                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
450                Ok(WorkerResult {
451                    value: json!(format!("[{s}]")),
452                    ok: true,
453                })
454            });
455        let svc = build_service(factory);
456        let flow = FlowNode::Seq {
457            children: vec![
458                step("upper", path("$.in"), path("$.s1")),
459                step("suffix", path("$.s1"), path("$.s2")),
460                step("wrap", path("$.s2"), path("$.s3")),
461            ],
462        };
463        let blueprint = bp(
464            flow,
465            vec![
466                agent("upper", "upper"),
467                agent("suffix", "suffix"),
468                agent("wrap", "wrap"),
469            ],
470        );
471        let out = svc
472            .launch(launch_input(blueprint, json!({ "in": "hello" })))
473            .await
474            .expect("launch ok");
475        assert_eq!(out.final_ctx["s1"], "HELLO");
476        assert_eq!(out.final_ctx["s2"], "HELLO!");
477        assert_eq!(out.final_ctx["s3"], "[HELLO!]");
478    }
479
480    #[tokio::test]
481    async fn launch_fanout_join_all_parallel_completes() {
482        use std::sync::atomic::{AtomicU32, Ordering};
483        let counter = Arc::new(AtomicU32::new(0));
484        let max_seen = Arc::new(AtomicU32::new(0));
485        let counter_clone = counter.clone();
486        let max_clone = max_seen.clone();
487
488        // Each worker bumps the inflight counter up, sleeps 50ms, then bumps it down.
489        // When parallel execution is working, max inflight exceeds 1.
490        let factory = RustFnInProcessSpawnerFactory::new().register_fn("para", move |inv| {
491            let counter = counter_clone.clone();
492            let max_seen = max_clone.clone();
493            async move {
494                let now = counter.fetch_add(1, Ordering::SeqCst) + 1;
495                let mut prev = max_seen.load(Ordering::SeqCst);
496                while now > prev {
497                    match max_seen.compare_exchange(prev, now, Ordering::SeqCst, Ordering::SeqCst) {
498                        Ok(_) => break,
499                        Err(p) => prev = p,
500                    }
501                }
502                tokio::time::sleep(Duration::from_millis(50)).await;
503                counter.fetch_sub(1, Ordering::SeqCst);
504                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
505                Ok(WorkerResult {
506                    value: json!(format!("did:{s}")),
507                    ok: true,
508                })
509            }
510        });
511        let svc = build_service(factory);
512        let flow = FlowNode::Fanout {
513            items: path("$.items"),
514            bind: path("$.item"),
515            body: Box::new(step("para", path("$.item"), path("$.r"))),
516            join: JoinMode::All,
517            out: path("$.results"),
518        };
519        let blueprint = bp(flow, vec![agent("para", "para")]);
520        let out = svc
521            .launch(launch_input(
522                blueprint,
523                json!({ "items": ["a", "b", "c", "d"] }),
524            ))
525            .await
526            .expect("launch ok");
527        let results = out.final_ctx["results"].as_array().expect("array");
528        assert_eq!(results.len(), 4);
529        for (i, expected) in ["a", "b", "c", "d"].iter().enumerate() {
530            assert_eq!(results[i]["r"], json!(format!("did:{expected}")));
531        }
532        let max = max_seen.load(Ordering::SeqCst);
533        assert!(
534            max >= 2,
535            "expected parallel execution (max inflight >= 2), got {max}"
536        );
537    }
538
539    #[tokio::test]
540    async fn launch_propagates_worker_error_as_flow_eval_err() {
541        let factory = RustFnInProcessSpawnerFactory::new()
542            .register_fn("ok", |inv| async move {
543                Ok(WorkerResult {
544                    value: json!(inv.prompt),
545                    ok: true,
546                })
547            })
548            .register_fn("boom", |_inv| async move {
549                Err(WorkerError::Failed("intentional boom".into()))
550            });
551        let svc = build_service(factory);
552        let flow = FlowNode::Seq {
553            children: vec![
554                step("ok", path("$.input"), path("$.s1")),
555                step("boom", path("$.s1"), path("$.s2")),
556                step("ok", path("$.s2"), path("$.s3")),
557            ],
558        };
559        let blueprint = bp(flow, vec![agent("ok", "ok"), agent("boom", "boom")]);
560        let err = svc
561            .launch(launch_input(blueprint, json!({ "input": "x" })))
562            .await
563            .expect_err("expected fail");
564        match err {
565            TaskLaunchError::FlowEval(msg) => {
566                assert!(
567                    msg.contains("boom") || msg.contains("intentional"),
568                    "expected error to mention worker failure, got: {msg}"
569                );
570            }
571            other => panic!("expected FlowEval error, got {other:?}"),
572        }
573    }
574
575    #[tokio::test]
576    async fn launch_resolves_call_extern_via_registered_externs() {
577        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
578            Ok(WorkerResult {
579                value: json!({ "echoed": inv.prompt }),
580                ok: true,
581            })
582        });
583        let mut externs = mlua_flow_ir::ExternMap::new();
584        externs.register("fmt.greet", |args: &[Value]| {
585            let name = args[0].as_str().unwrap_or("?");
586            Ok(json!(format!("hello, {name}")))
587        });
588        let svc = build_service(factory).with_externs(Arc::new(externs));
589        let flow = step(
590            "echo",
591            Expr::CallExtern {
592                ref_: "fmt.greet".into(),
593                args: vec![path("$.who")],
594            },
595            path("$.out"),
596        );
597        let blueprint = bp(flow, vec![agent("echo", "echo")]);
598        let out = svc
599            .launch(launch_input(blueprint, json!({ "who": "swarm" })))
600            .await
601            .expect("launch ok");
602        assert_eq!(out.final_ctx["out"]["echoed"], json!("hello, swarm"));
603    }
604
605    #[tokio::test]
606    async fn launch_call_extern_without_registry_fails_as_flow_eval() {
607        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
608            Ok(WorkerResult {
609                value: json!(inv.prompt),
610                ok: true,
611            })
612        });
613        let svc = build_service(factory); // default NoExterns
614        let flow = step(
615            "echo",
616            Expr::CallExtern {
617                ref_: "fmt.greet".into(),
618                args: vec![],
619            },
620            path("$.out"),
621        );
622        let blueprint = bp(flow, vec![agent("echo", "echo")]);
623        let err = svc
624            .launch(launch_input(blueprint, json!({})))
625            .await
626            .expect_err("expected fail");
627        match err {
628            TaskLaunchError::FlowEval(msg) => {
629                assert!(msg.contains("extern"), "expected extern error, got: {msg}");
630            }
631            other => panic!("expected FlowEval error, got {other:?}"),
632        }
633    }
634}