use crate::blueprint::compiler::{CompileError, Compiler};
use crate::blueprint::{Blueprint, EngineDispatcher};
use crate::core::ctx::OperatorKind;
use crate::core::engine::Engine;
use crate::core::errors::EngineError;
use crate::middleware::project_name_alias::ProjectNameAliasMiddleware;
use crate::middleware::worker_binding::WorkerBindingMiddleware;
use crate::middleware::SpawnerStack;
use crate::operator::WorkerBinding;
use crate::service::linker;
use crate::types::{CapToken, Role};
use mlua_flow_ir::{Externs, NoExterns};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use thiserror::Error;
fn derive_worker_bindings(blueprint: &Blueprint) -> HashMap<String, WorkerBinding> {
blueprint
.agents
.iter()
.filter_map(|ad| {
let profile = ad.profile.as_ref()?;
let variant = profile.worker_binding.as_ref()?;
Some((
ad.name.clone(),
WorkerBinding {
variant: variant.clone(),
tools: profile.tools.clone(),
},
))
})
.collect()
}
fn derive_bp_agent_kinds(blueprint: &Blueprint) -> HashMap<String, OperatorKind> {
let mut out = HashMap::new();
if blueprint.operators.is_empty() {
return out;
}
for agent in &blueprint.agents {
let Some(op_ref) = agent.spec.get("operator_ref").and_then(|v| v.as_str()) else {
continue;
};
let Some(op_def) = blueprint.operators.iter().find(|o| o.name == op_ref) else {
continue;
};
if let Some(kind) = op_def.kind {
out.insert(agent.name.clone(), OperatorKind::from(kind));
}
}
out
}
#[derive(Debug, Error)]
pub enum TaskLaunchError {
#[error("compile: {0}")]
Compile(#[from] CompileError),
#[error("engine: {0}")]
Engine(#[from] EngineError),
#[error("flow eval: {0}")]
FlowEval(String),
}
#[derive(Debug, Clone)]
pub struct TaskLaunchInput {
pub blueprint: Blueprint,
pub operator_id: String,
pub role: Role,
pub ttl: Duration,
pub operator_kind: Option<OperatorKind>,
pub bridge_id: Option<String>,
pub hook_id: Option<String>,
pub operator_backend_id: Option<String>,
pub operator_kind_overrides: HashMap<String, OperatorKind>,
pub init_ctx: Value,
}
impl TaskLaunchInput {
pub fn automate(
blueprint: Blueprint,
operator_id: impl Into<String>,
role: Role,
ttl: Duration,
init_ctx: Value,
) -> Self {
Self {
blueprint,
operator_id: operator_id.into(),
role,
ttl,
operator_kind: None,
bridge_id: None,
hook_id: None,
operator_backend_id: None,
operator_kind_overrides: HashMap::new(),
init_ctx,
}
}
}
#[derive(Debug, Clone)]
pub struct TaskLaunchOutput {
pub token: CapToken,
pub final_ctx: Value,
}
pub struct TaskLaunchService {
engine: Engine,
compiler: Compiler,
externs: Arc<dyn Externs + Send + Sync>,
}
impl TaskLaunchService {
pub fn new(engine: Engine, compiler: Compiler) -> Self {
Self {
engine,
compiler,
externs: Arc::new(NoExterns),
}
}
pub fn with_externs(mut self, externs: Arc<dyn Externs + Send + Sync>) -> Self {
self.externs = externs;
self
}
pub fn engine(&self) -> &Engine {
&self.engine
}
pub fn compiler(&self) -> &Compiler {
&self.compiler
}
pub async fn launch(
&self,
input: TaskLaunchInput,
) -> Result<TaskLaunchOutput, TaskLaunchError> {
let compiled = self.compiler.compile(&input.blueprint)?;
let spawner = linker::link(
compiled.router.clone(),
&input.blueprint.spawner_hints.layers,
&self.engine,
);
let spawner = if let Some(alias) = input.blueprint.metadata.project_name_alias.as_deref() {
SpawnerStack::new(spawner)
.layer(ProjectNameAliasMiddleware::new(alias))
.build()
} else {
spawner
};
let worker_bindings = derive_worker_bindings(&input.blueprint);
let spawner = if worker_bindings.is_empty() {
spawner
} else {
SpawnerStack::new(spawner)
.layer(WorkerBindingMiddleware::new(worker_bindings))
.build()
};
let bp_agent_kinds = derive_bp_agent_kinds(&input.blueprint);
let bp_global_kind = input
.blueprint
.default_operator_kind
.map(OperatorKind::from);
let token = self
.engine
.attach_with_ids(
input.operator_id,
input.role,
input.ttl,
input.operator_kind,
input.bridge_id,
input.hook_id,
input.operator_backend_id,
input.operator_kind_overrides,
bp_agent_kinds,
bp_global_kind,
)
.await?;
let dispatcher =
EngineDispatcher::with_spawner(self.engine.clone(), token.clone(), spawner);
let final_ctx = mlua_flow_ir::eval_async_externs(
&input.blueprint.flow,
input.init_ctx,
&dispatcher,
&*self.externs,
)
.await
.map_err(|e| TaskLaunchError::FlowEval(e.to_string()))?;
Ok(TaskLaunchOutput { token, final_ctx })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerRegistry};
use crate::blueprint::{
current_schema_version, AgentDef, AgentKind, AgentMeta, BlueprintMetadata, CompilerHints,
CompilerStrategy,
};
use crate::core::config::EngineCfg;
use crate::worker::adapter::{WorkerError, WorkerResult};
use mlua_flow_ir::{Expr, JoinMode, Node as FlowNode};
use serde_json::json;
use std::sync::Arc;
fn path(s: &str) -> Expr {
Expr::Path { at: s.to_string() }
}
fn step(ref_: &str, in_: Expr, out: Expr) -> FlowNode {
FlowNode::Step {
ref_: ref_.to_string(),
in_,
out,
}
}
fn agent(name: &str, fn_id: &str) -> AgentDef {
AgentDef {
name: name.to_string(),
kind: AgentKind::RustFn,
spec: json!({ "fn_id": fn_id }),
profile: None,
meta: Some(AgentMeta::default()),
}
}
fn build_service(factory: RustFnInProcessSpawnerFactory) -> TaskLaunchService {
let engine = Engine::new(EngineCfg::default());
let mut reg = SpawnerRegistry::new();
reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
let compiler = Compiler::new(reg);
TaskLaunchService::new(engine, compiler)
}
fn bp(flow: FlowNode, agents: Vec<AgentDef>) -> Blueprint {
Blueprint {
schema_version: current_schema_version(),
id: "ut".into(),
flow,
agents,
operators: vec![],
hints: CompilerHints::default(),
strategy: CompilerStrategy::default(),
metadata: BlueprintMetadata::default(),
spawner_hints: Default::default(),
default_agent_kind: AgentKind::Operator,
default_operator_kind: None,
}
}
fn launch_input(blueprint: Blueprint, init_ctx: Value) -> TaskLaunchInput {
TaskLaunchInput::automate(
blueprint,
"ut-op",
Role::Operator,
Duration::from_secs(30),
init_ctx,
)
}
#[tokio::test]
async fn launch_single_step_writes_out_path() {
let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
Ok(WorkerResult {
value: json!({ "echoed": inv.prompt }),
ok: true,
})
});
let svc = build_service(factory);
let blueprint = bp(
step("echo", path("$.input"), path("$.out")),
vec![agent("echo", "echo")],
);
let out = svc
.launch(launch_input(blueprint, json!({ "input": "hi" })))
.await
.expect("launch ok");
assert_eq!(out.final_ctx["out"]["echoed"], "hi");
}
#[tokio::test]
async fn launch_three_step_seq_threads_ctx_forward() {
let factory = RustFnInProcessSpawnerFactory::new()
.register_fn("upper", |inv| async move {
let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
Ok(WorkerResult {
value: json!(s.to_uppercase()),
ok: true,
})
})
.register_fn("suffix", |inv| async move {
let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
Ok(WorkerResult {
value: json!(format!("{s}!")),
ok: true,
})
})
.register_fn("wrap", |inv| async move {
let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
Ok(WorkerResult {
value: json!(format!("[{s}]")),
ok: true,
})
});
let svc = build_service(factory);
let flow = FlowNode::Seq {
children: vec![
step("upper", path("$.in"), path("$.s1")),
step("suffix", path("$.s1"), path("$.s2")),
step("wrap", path("$.s2"), path("$.s3")),
],
};
let blueprint = bp(
flow,
vec![
agent("upper", "upper"),
agent("suffix", "suffix"),
agent("wrap", "wrap"),
],
);
let out = svc
.launch(launch_input(blueprint, json!({ "in": "hello" })))
.await
.expect("launch ok");
assert_eq!(out.final_ctx["s1"], "HELLO");
assert_eq!(out.final_ctx["s2"], "HELLO!");
assert_eq!(out.final_ctx["s3"], "[HELLO!]");
}
#[tokio::test]
async fn launch_fanout_join_all_parallel_completes() {
use std::sync::atomic::{AtomicU32, Ordering};
let counter = Arc::new(AtomicU32::new(0));
let max_seen = Arc::new(AtomicU32::new(0));
let counter_clone = counter.clone();
let max_clone = max_seen.clone();
let factory = RustFnInProcessSpawnerFactory::new().register_fn("para", move |inv| {
let counter = counter_clone.clone();
let max_seen = max_clone.clone();
async move {
let now = counter.fetch_add(1, Ordering::SeqCst) + 1;
let mut prev = max_seen.load(Ordering::SeqCst);
while now > prev {
match max_seen.compare_exchange(prev, now, Ordering::SeqCst, Ordering::SeqCst) {
Ok(_) => break,
Err(p) => prev = p,
}
}
tokio::time::sleep(Duration::from_millis(50)).await;
counter.fetch_sub(1, Ordering::SeqCst);
let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
Ok(WorkerResult {
value: json!(format!("did:{s}")),
ok: true,
})
}
});
let svc = build_service(factory);
let flow = FlowNode::Fanout {
items: path("$.items"),
bind: path("$.item"),
body: Box::new(step("para", path("$.item"), path("$.r"))),
join: JoinMode::All,
out: path("$.results"),
};
let blueprint = bp(flow, vec![agent("para", "para")]);
let out = svc
.launch(launch_input(
blueprint,
json!({ "items": ["a", "b", "c", "d"] }),
))
.await
.expect("launch ok");
let results = out.final_ctx["results"].as_array().expect("array");
assert_eq!(results.len(), 4);
for (i, expected) in ["a", "b", "c", "d"].iter().enumerate() {
assert_eq!(results[i]["r"], json!(format!("did:{expected}")));
}
let max = max_seen.load(Ordering::SeqCst);
assert!(
max >= 2,
"expected parallel execution (max inflight >= 2), got {max}"
);
}
#[tokio::test]
async fn launch_propagates_worker_error_as_flow_eval_err() {
let factory = RustFnInProcessSpawnerFactory::new()
.register_fn("ok", |inv| async move {
Ok(WorkerResult {
value: json!(inv.prompt),
ok: true,
})
})
.register_fn("boom", |_inv| async move {
Err(WorkerError::Failed("intentional boom".into()))
});
let svc = build_service(factory);
let flow = FlowNode::Seq {
children: vec![
step("ok", path("$.input"), path("$.s1")),
step("boom", path("$.s1"), path("$.s2")),
step("ok", path("$.s2"), path("$.s3")),
],
};
let blueprint = bp(flow, vec![agent("ok", "ok"), agent("boom", "boom")]);
let err = svc
.launch(launch_input(blueprint, json!({ "input": "x" })))
.await
.expect_err("expected fail");
match err {
TaskLaunchError::FlowEval(msg) => {
assert!(
msg.contains("boom") || msg.contains("intentional"),
"expected error to mention worker failure, got: {msg}"
);
}
other => panic!("expected FlowEval error, got {other:?}"),
}
}
#[tokio::test]
async fn launch_resolves_call_extern_via_registered_externs() {
let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
Ok(WorkerResult {
value: json!({ "echoed": inv.prompt }),
ok: true,
})
});
let mut externs = mlua_flow_ir::ExternMap::new();
externs.register("fmt.greet", |args: &[Value]| {
let name = args[0].as_str().unwrap_or("?");
Ok(json!(format!("hello, {name}")))
});
let svc = build_service(factory).with_externs(Arc::new(externs));
let flow = step(
"echo",
Expr::CallExtern {
ref_: "fmt.greet".into(),
args: vec![path("$.who")],
},
path("$.out"),
);
let blueprint = bp(flow, vec![agent("echo", "echo")]);
let out = svc
.launch(launch_input(blueprint, json!({ "who": "swarm" })))
.await
.expect("launch ok");
assert_eq!(out.final_ctx["out"]["echoed"], json!("hello, swarm"));
}
#[tokio::test]
async fn launch_call_extern_without_registry_fails_as_flow_eval() {
let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
Ok(WorkerResult {
value: json!(inv.prompt),
ok: true,
})
});
let svc = build_service(factory); let flow = step(
"echo",
Expr::CallExtern {
ref_: "fmt.greet".into(),
args: vec![],
},
path("$.out"),
);
let blueprint = bp(flow, vec![agent("echo", "echo")]);
let err = svc
.launch(launch_input(blueprint, json!({})))
.await
.expect_err("expected fail");
match err {
TaskLaunchError::FlowEval(msg) => {
assert!(msg.contains("extern"), "expected extern error, got: {msg}");
}
other => panic!("expected FlowEval error, got {other:?}"),
}
}
}