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::store::run::RunContext;
33use crate::types::{CapToken, Role};
34use mlua_flow_ir::{Externs, NoExterns};
35use serde_json::Value;
36use std::collections::HashMap;
37use std::sync::Arc;
38use std::time::Duration;
39use thiserror::Error;
40
41fn derive_worker_bindings(blueprint: &Blueprint) -> HashMap<String, WorkerBinding> {
68 blueprint
69 .agents
70 .iter()
71 .filter_map(|ad| {
72 let profile = ad.profile.as_ref()?;
73 let variant = profile.worker_binding.as_ref()?;
74 Some((
75 ad.name.clone(),
76 WorkerBinding {
77 variant: variant.clone(),
78 tools: profile.tools.clone(),
79 },
80 ))
81 })
82 .collect()
83}
84
85fn derive_bp_agent_kinds(blueprint: &Blueprint) -> HashMap<String, OperatorKind> {
86 let mut out = HashMap::new();
87 if blueprint.operators.is_empty() {
88 return out;
89 }
90 for agent in &blueprint.agents {
91 let Some(op_ref) = agent.spec.get("operator_ref").and_then(|v| v.as_str()) else {
92 continue;
93 };
94 let Some(op_def) = blueprint.operators.iter().find(|o| o.name == op_ref) else {
95 continue;
96 };
97 if let Some(kind) = op_def.kind {
98 out.insert(agent.name.clone(), OperatorKind::from(kind));
99 }
100 }
101 out
102}
103
104#[derive(Debug, Error)]
106pub enum TaskLaunchError {
107 #[error("compile: {0}")]
109 Compile(#[from] CompileError),
110 #[error("engine: {0}")]
112 Engine(#[from] EngineError),
113 #[error("flow eval: {0}")]
116 FlowEval(String),
117}
118
119#[derive(Debug, Clone)]
121pub struct TaskLaunchInput {
122 pub blueprint: Blueprint,
124 pub operator_id: String,
126 pub role: Role,
128 pub ttl: Duration,
130 pub operator_kind: Option<OperatorKind>,
139 pub bridge_id: Option<String>,
143 pub hook_id: Option<String>,
146 pub operator_backend_id: Option<String>,
153 pub operator_kind_overrides: HashMap<String, OperatorKind>,
158 pub init_ctx: Value,
162 pub run_ctx: Option<RunContext>,
169}
170
171impl TaskLaunchInput {
172 pub fn automate(
181 blueprint: Blueprint,
182 operator_id: impl Into<String>,
183 role: Role,
184 ttl: Duration,
185 init_ctx: Value,
186 ) -> Self {
187 Self {
188 blueprint,
189 operator_id: operator_id.into(),
190 role,
191 ttl,
192 operator_kind: None,
193 bridge_id: None,
194 hook_id: None,
195 operator_backend_id: None,
196 operator_kind_overrides: HashMap::new(),
197 init_ctx,
198 run_ctx: None,
199 }
200 }
201}
202
203#[derive(Debug, Clone)]
205pub struct TaskLaunchOutput {
206 pub token: CapToken,
208 pub final_ctx: Value,
212}
213
214pub struct TaskLaunchService {
218 engine: Engine,
219 compiler: Compiler,
220 externs: Arc<dyn Externs + Send + Sync>,
225}
226
227impl TaskLaunchService {
228 pub fn new(engine: Engine, compiler: Compiler) -> Self {
230 Self {
231 engine,
232 compiler,
233 externs: Arc::new(NoExterns),
234 }
235 }
236
237 pub fn with_externs(mut self, externs: Arc<dyn Externs + Send + Sync>) -> Self {
241 self.externs = externs;
242 self
243 }
244
245 pub fn engine(&self) -> &Engine {
247 &self.engine
248 }
249
250 pub fn compiler(&self) -> &Compiler {
252 &self.compiler
253 }
254
255 pub async fn launch(
267 &self,
268 input: TaskLaunchInput,
269 ) -> Result<TaskLaunchOutput, TaskLaunchError> {
270 let compiled = self.compiler.compile(&input.blueprint)?;
279 let spawner = linker::link(
280 compiled.router.clone(),
281 &input.blueprint.spawner_hints.layers,
282 &self.engine,
283 );
284 let spawner = if let Some(alias) = input.blueprint.metadata.project_name_alias.as_deref() {
291 SpawnerStack::new(spawner)
292 .layer(ProjectNameAliasMiddleware::new(alias))
293 .build()
294 } else {
295 spawner
296 };
297 let worker_bindings = derive_worker_bindings(&input.blueprint);
301 let spawner = if worker_bindings.is_empty() {
302 spawner
303 } else {
304 SpawnerStack::new(spawner)
305 .layer(WorkerBindingMiddleware::new(worker_bindings))
306 .build()
307 };
308
309 let bp_agent_kinds = derive_bp_agent_kinds(&input.blueprint);
314 let bp_global_kind = input
315 .blueprint
316 .default_operator_kind
317 .map(OperatorKind::from);
318
319 let token = self
320 .engine
321 .attach_with_ids(
322 input.operator_id,
323 input.role,
324 input.ttl,
325 input.operator_kind,
326 input.bridge_id,
327 input.hook_id,
328 input.operator_backend_id,
329 input.operator_kind_overrides,
330 bp_agent_kinds,
331 bp_global_kind,
332 )
333 .await?;
334 let dispatcher =
335 EngineDispatcher::with_spawner(self.engine.clone(), token.clone(), spawner);
336 let dispatcher = match input.run_ctx {
337 Some(run_ctx) => dispatcher.with_run(run_ctx),
338 None => dispatcher,
339 };
340 let final_ctx = mlua_flow_ir::eval_async_externs(
341 &input.blueprint.flow,
342 input.init_ctx,
343 &dispatcher,
344 &*self.externs,
345 )
346 .await
347 .map_err(|e| TaskLaunchError::FlowEval(e.to_string()))?;
348 Ok(TaskLaunchOutput { token, final_ctx })
349 }
350}
351
352#[cfg(test)]
357mod tests {
358 use super::*;
359 use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerRegistry};
360 use crate::blueprint::{
361 current_schema_version, AgentDef, AgentKind, AgentMeta, BlueprintMetadata, CompilerHints,
362 CompilerStrategy,
363 };
364 use crate::core::config::EngineCfg;
365 use crate::worker::adapter::{WorkerError, WorkerResult};
366 use mlua_flow_ir::{Expr, JoinMode, Node as FlowNode};
367 use serde_json::json;
368 use std::sync::Arc;
369
370 fn path(s: &str) -> Expr {
371 Expr::Path { at: s.to_string() }
372 }
373 fn step(ref_: &str, in_: Expr, out: Expr) -> FlowNode {
374 FlowNode::Step {
375 ref_: ref_.to_string(),
376 in_,
377 out,
378 }
379 }
380
381 fn agent(name: &str, fn_id: &str) -> AgentDef {
382 AgentDef {
383 name: name.to_string(),
384 kind: AgentKind::RustFn,
385 spec: json!({ "fn_id": fn_id }),
386 profile: None,
387 meta: Some(AgentMeta::default()),
388 }
389 }
390
391 fn build_service(factory: RustFnInProcessSpawnerFactory) -> TaskLaunchService {
392 let engine = Engine::new(EngineCfg::default());
393 let mut reg = SpawnerRegistry::new();
394 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
395 let compiler = Compiler::new(reg);
396 TaskLaunchService::new(engine, compiler)
397 }
398
399 fn bp(flow: FlowNode, agents: Vec<AgentDef>) -> Blueprint {
400 Blueprint {
401 schema_version: current_schema_version(),
402 id: "ut".into(),
403 flow,
404 agents,
405 operators: vec![],
406 hints: CompilerHints::default(),
407 strategy: CompilerStrategy::default(),
408 metadata: BlueprintMetadata::default(),
409 spawner_hints: Default::default(),
410 default_agent_kind: AgentKind::Operator,
411 default_operator_kind: None,
412 }
413 }
414
415 fn launch_input(blueprint: Blueprint, init_ctx: Value) -> TaskLaunchInput {
416 TaskLaunchInput::automate(
417 blueprint,
418 "ut-op",
419 Role::Operator,
420 Duration::from_secs(30),
421 init_ctx,
422 )
423 }
424
425 #[tokio::test]
426 async fn launch_single_step_writes_out_path() {
427 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
428 Ok(WorkerResult {
429 value: json!({ "echoed": inv.prompt }),
430 ok: true,
431 })
432 });
433 let svc = build_service(factory);
434 let blueprint = bp(
435 step("echo", path("$.input"), path("$.out")),
436 vec![agent("echo", "echo")],
437 );
438 let out = svc
439 .launch(launch_input(blueprint, json!({ "input": "hi" })))
440 .await
441 .expect("launch ok");
442 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
443 }
444
445 #[tokio::test]
446 async fn launch_three_step_seq_threads_ctx_forward() {
447 let factory = RustFnInProcessSpawnerFactory::new()
448 .register_fn("upper", |inv| async move {
449 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
450 Ok(WorkerResult {
451 value: json!(s.to_uppercase()),
452 ok: true,
453 })
454 })
455 .register_fn("suffix", |inv| async move {
456 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
457 Ok(WorkerResult {
458 value: json!(format!("{s}!")),
459 ok: true,
460 })
461 })
462 .register_fn("wrap", |inv| async move {
463 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
464 Ok(WorkerResult {
465 value: json!(format!("[{s}]")),
466 ok: true,
467 })
468 });
469 let svc = build_service(factory);
470 let flow = FlowNode::Seq {
471 children: vec![
472 step("upper", path("$.in"), path("$.s1")),
473 step("suffix", path("$.s1"), path("$.s2")),
474 step("wrap", path("$.s2"), path("$.s3")),
475 ],
476 };
477 let blueprint = bp(
478 flow,
479 vec![
480 agent("upper", "upper"),
481 agent("suffix", "suffix"),
482 agent("wrap", "wrap"),
483 ],
484 );
485 let out = svc
486 .launch(launch_input(blueprint, json!({ "in": "hello" })))
487 .await
488 .expect("launch ok");
489 assert_eq!(out.final_ctx["s1"], "HELLO");
490 assert_eq!(out.final_ctx["s2"], "HELLO!");
491 assert_eq!(out.final_ctx["s3"], "[HELLO!]");
492 }
493
494 #[tokio::test]
495 async fn launch_fanout_join_all_parallel_completes() {
496 use std::sync::atomic::{AtomicU32, Ordering};
497 let counter = Arc::new(AtomicU32::new(0));
498 let max_seen = Arc::new(AtomicU32::new(0));
499 let counter_clone = counter.clone();
500 let max_clone = max_seen.clone();
501
502 let factory = RustFnInProcessSpawnerFactory::new().register_fn("para", move |inv| {
505 let counter = counter_clone.clone();
506 let max_seen = max_clone.clone();
507 async move {
508 let now = counter.fetch_add(1, Ordering::SeqCst) + 1;
509 let mut prev = max_seen.load(Ordering::SeqCst);
510 while now > prev {
511 match max_seen.compare_exchange(prev, now, Ordering::SeqCst, Ordering::SeqCst) {
512 Ok(_) => break,
513 Err(p) => prev = p,
514 }
515 }
516 tokio::time::sleep(Duration::from_millis(50)).await;
517 counter.fetch_sub(1, Ordering::SeqCst);
518 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
519 Ok(WorkerResult {
520 value: json!(format!("did:{s}")),
521 ok: true,
522 })
523 }
524 });
525 let svc = build_service(factory);
526 let flow = FlowNode::Fanout {
527 items: path("$.items"),
528 bind: path("$.item"),
529 body: Box::new(step("para", path("$.item"), path("$.r"))),
530 join: JoinMode::All,
531 out: path("$.results"),
532 };
533 let blueprint = bp(flow, vec![agent("para", "para")]);
534 let out = svc
535 .launch(launch_input(
536 blueprint,
537 json!({ "items": ["a", "b", "c", "d"] }),
538 ))
539 .await
540 .expect("launch ok");
541 let results = out.final_ctx["results"].as_array().expect("array");
542 assert_eq!(results.len(), 4);
543 for (i, expected) in ["a", "b", "c", "d"].iter().enumerate() {
544 assert_eq!(results[i]["r"], json!(format!("did:{expected}")));
545 }
546 let max = max_seen.load(Ordering::SeqCst);
547 assert!(
548 max >= 2,
549 "expected parallel execution (max inflight >= 2), got {max}"
550 );
551 }
552
553 #[tokio::test]
554 async fn launch_propagates_worker_error_as_flow_eval_err() {
555 let factory = RustFnInProcessSpawnerFactory::new()
556 .register_fn("ok", |inv| async move {
557 Ok(WorkerResult {
558 value: json!(inv.prompt),
559 ok: true,
560 })
561 })
562 .register_fn("boom", |_inv| async move {
563 Err(WorkerError::Failed("intentional boom".into()))
564 });
565 let svc = build_service(factory);
566 let flow = FlowNode::Seq {
567 children: vec![
568 step("ok", path("$.input"), path("$.s1")),
569 step("boom", path("$.s1"), path("$.s2")),
570 step("ok", path("$.s2"), path("$.s3")),
571 ],
572 };
573 let blueprint = bp(flow, vec![agent("ok", "ok"), agent("boom", "boom")]);
574 let err = svc
575 .launch(launch_input(blueprint, json!({ "input": "x" })))
576 .await
577 .expect_err("expected fail");
578 match err {
579 TaskLaunchError::FlowEval(msg) => {
580 assert!(
581 msg.contains("boom") || msg.contains("intentional"),
582 "expected error to mention worker failure, got: {msg}"
583 );
584 }
585 other => panic!("expected FlowEval error, got {other:?}"),
586 }
587 }
588
589 #[tokio::test]
590 async fn launch_resolves_call_extern_via_registered_externs() {
591 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
592 Ok(WorkerResult {
593 value: json!({ "echoed": inv.prompt }),
594 ok: true,
595 })
596 });
597 let mut externs = mlua_flow_ir::ExternMap::new();
598 externs.register("fmt.greet", |args: &[Value]| {
599 let name = args[0].as_str().unwrap_or("?");
600 Ok(json!(format!("hello, {name}")))
601 });
602 let svc = build_service(factory).with_externs(Arc::new(externs));
603 let flow = step(
604 "echo",
605 Expr::CallExtern {
606 ref_: "fmt.greet".into(),
607 args: vec![path("$.who")],
608 },
609 path("$.out"),
610 );
611 let blueprint = bp(flow, vec![agent("echo", "echo")]);
612 let out = svc
613 .launch(launch_input(blueprint, json!({ "who": "swarm" })))
614 .await
615 .expect("launch ok");
616 assert_eq!(out.final_ctx["out"]["echoed"], json!("hello, swarm"));
617 }
618
619 #[tokio::test]
620 async fn launch_call_extern_without_registry_fails_as_flow_eval() {
621 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
622 Ok(WorkerResult {
623 value: json!(inv.prompt),
624 ok: true,
625 })
626 });
627 let svc = build_service(factory); let flow = step(
629 "echo",
630 Expr::CallExtern {
631 ref_: "fmt.greet".into(),
632 args: vec![],
633 },
634 path("$.out"),
635 );
636 let blueprint = bp(flow, vec![agent("echo", "echo")]);
637 let err = svc
638 .launch(launch_input(blueprint, json!({})))
639 .await
640 .expect_err("expected fail");
641 match err {
642 TaskLaunchError::FlowEval(msg) => {
643 assert!(msg.contains("extern"), "expected extern error, got: {msg}");
644 }
645 other => panic!("expected FlowEval error, got {other:?}"),
646 }
647 }
648
649 #[tokio::test]
654 async fn launch_with_run_ctx_appends_one_step_entry_per_dispatched_step() {
655 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
656 use crate::types::{RunId, TaskId};
657
658 let factory = RustFnInProcessSpawnerFactory::new()
659 .register_fn("upper", |inv| async move {
660 Ok(WorkerResult {
661 value: json!(inv.prompt.to_uppercase()),
662 ok: true,
663 })
664 })
665 .register_fn("suffix", |inv| async move {
666 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
667 Ok(WorkerResult {
668 value: json!(format!("{s}!")),
669 ok: true,
670 })
671 });
672 let svc = build_service(factory);
673 let flow = FlowNode::Seq {
674 children: vec![
675 step("upper", path("$.in"), path("$.s1")),
676 step("suffix", path("$.s1"), path("$.s2")),
677 ],
678 };
679 let blueprint = bp(
680 flow,
681 vec![agent("upper", "upper"), agent("suffix", "suffix")],
682 );
683
684 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
685 let run_id = RunId::new();
686 run_store
687 .create(RunRecord {
688 id: run_id.clone(),
689 task_id: TaskId::new(),
690 status: RunStatus::Running,
691 step_entries: Vec::new(),
692 operator_sid: None,
693 result_ref: None,
694 created_at: 0,
695 updated_at: 0,
696 })
697 .await
698 .expect("seed RunRecord");
699
700 let mut input = launch_input(blueprint, json!({ "in": "hi" }));
701 input.run_ctx = Some(RunContext {
702 run_id: run_id.clone(),
703 run_store: run_store.clone(),
704 });
705
706 let out = svc.launch(input).await.expect("launch ok");
707 assert_eq!(out.final_ctx["s2"], "HI!");
708
709 let run = run_store.get(&run_id).await.expect("run present");
710 assert_eq!(
711 run.step_entries.len(),
712 2,
713 "expected one step_entry per dispatched step, got {:?}",
714 run.step_entries
715 );
716 assert_eq!(run.step_entries[0].step_ref, Some("upper".to_string()));
717 assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
718 assert_eq!(run.step_entries[1].step_ref, Some("suffix".to_string()));
719 assert_eq!(run.step_entries[1].status, Some("passed".to_string()));
720 }
721
722 #[tokio::test]
723 async fn launch_without_run_ctx_appends_no_step_entries() {
724 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
728 Ok(WorkerResult {
729 value: json!(inv.prompt),
730 ok: true,
731 })
732 });
733 let svc = build_service(factory);
734 let blueprint = bp(
735 step("echo", path("$.input"), path("$.out")),
736 vec![agent("echo", "echo")],
737 );
738 let input = launch_input(blueprint, json!({ "input": "hi" }));
739 assert!(
740 input.run_ctx.is_none(),
741 "automate() defaults run_ctx to None"
742 );
743 let out = svc.launch(input).await.expect("launch ok");
744 assert_eq!(out.final_ctx["out"], "hi");
745 }
746}