1use 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
40fn 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#[derive(Debug, Error)]
105pub enum TaskLaunchError {
106 #[error("compile: {0}")]
108 Compile(#[from] CompileError),
109 #[error("engine: {0}")]
111 Engine(#[from] EngineError),
112 #[error("flow eval: {0}")]
115 FlowEval(String),
116}
117
118#[derive(Debug, Clone)]
120pub struct TaskLaunchInput {
121 pub blueprint: Blueprint,
123 pub operator_id: String,
125 pub role: Role,
127 pub ttl: Duration,
129 pub operator_kind: Option<OperatorKind>,
138 pub bridge_id: Option<String>,
142 pub hook_id: Option<String>,
145 pub operator_backend_id: Option<String>,
152 pub operator_kind_overrides: HashMap<String, OperatorKind>,
157 pub init_ctx: Value,
161}
162
163impl TaskLaunchInput {
164 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#[derive(Debug, Clone)]
195pub struct TaskLaunchOutput {
196 pub token: CapToken,
198 pub final_ctx: Value,
202}
203
204pub struct TaskLaunchService {
208 engine: Engine,
209 compiler: Compiler,
210 externs: Arc<dyn Externs + Send + Sync>,
215}
216
217impl TaskLaunchService {
218 pub fn new(engine: Engine, compiler: Compiler) -> Self {
220 Self {
221 engine,
222 compiler,
223 externs: Arc::new(NoExterns),
224 }
225 }
226
227 pub fn with_externs(mut self, externs: Arc<dyn Externs + Send + Sync>) -> Self {
231 self.externs = externs;
232 self
233 }
234
235 pub fn engine(&self) -> &Engine {
237 &self.engine
238 }
239
240 pub fn compiler(&self) -> &Compiler {
242 &self.compiler
243 }
244
245 pub async fn launch(
257 &self,
258 input: TaskLaunchInput,
259 ) -> Result<TaskLaunchOutput, TaskLaunchError> {
260 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 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 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 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#[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 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); 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}