use super::journal::{record, slot_from_cache, slot_from_result, Slot};
use crate::sdk::task::{build_result_table, build_task};
use crate::sdk::SdkContext;
use luft_core::contract::backend::AgentTask;
use luft_core::contract::event::AgentEvent;
use luft_core::contract::ids::AgentId;
use luft_core::journal::AgentCacheKey;
use mlua::{Function, Lua, Table, Value};
use std::sync::atomic::Ordering;
pub(super) fn register(lua: &Lua, cx: &SdkContext) -> mlua::Result<()> {
let globals = lua.globals();
let run_id = cx.run_id();
let sched = cx.scheduler.clone();
let journal = cx.journal.clone();
let handle = cx.handle.clone();
let phase_counter = cx.phase_counter.clone();
let agent_seq_counter = cx.agent_seq_counter.clone();
let span_counter = cx.span_counter.clone();
let events = cx.events();
let parallel_fn = lua.create_function(move |lua, (items, map_fn): (Table, Function)| {
let phase_id = phase_counter.load(Ordering::Relaxed);
let span_id = span_counter.fetch_add(1, Ordering::Relaxed);
let count = items.raw_len();
tracing::debug!(count, phase_id, "parallel() fan-out started");
let _ = events.send(AgentEvent::ParallelStarted { run_id, phase_id, span_id, count });
let t0 = std::time::Instant::now();
struct Pending {
idx: usize,
cache_key: AgentCacheKey,
agent_id: AgentId,
task: AgentTask,
backend: Option<String>,
}
let outcome: mlua::Result<(Table, usize, usize, serde_json::Value)> = (|| {
let mut slots: Vec<Option<Slot>> = Vec::new();
let mut pending: Vec<Pending> = Vec::new();
for item in items.sequence_values::<Value>() {
let idx = slots.len();
slots.push(None);
let item = item?;
let opts: Table = match map_fn.call(item)? {
Value::Table(t) => t,
_ => {
return Err(mlua::Error::RuntimeError(
"parallel: map function must return an options table".into(),
))
}
};
let (task, cache_key, backend) = build_task(&opts, phase_id, &agent_seq_counter)?;
if let Some(ref j) = journal {
if let Some(c) = j.get_cached(&cache_key) {
slots[idx] = Some(slot_from_cache(c));
continue;
}
}
pending.push(Pending { idx, cache_key, agent_id: task.agent_id, task, backend });
}
if !pending.is_empty() {
let n_pending = pending.len();
let n_cached = count - n_pending;
tracing::debug!(n_pending, n_cached, "parallel() dispatching to scheduler");
let tasks: Vec<(AgentTask, Option<String>)> =
pending.iter().map(|p| (p.task.clone(), p.backend.clone())).collect();
let results = handle.block_on(sched.run_parallel(run_id, tasks));
for (p, res) in pending.iter().zip(results) {
let slot = match res {
Ok(r) => {
tracing::debug!(agent_id = %p.agent_id, "parallel() agent completed");
record(&journal, &p.cache_key, p.agent_id, phase_id, &r);
slot_from_result(r)
}
Err(e) => {
tracing::warn!(agent_id = %p.agent_id, error = %e, "parallel() agent failed");
("error".to_string(), serde_json::json!({ "error": e.to_string() }), 0, vec![])
}
};
slots[p.idx] = Some(slot);
}
}
let default: Slot = ("error".into(), serde_json::Value::Null, 0, vec![]);
let results_json: Vec<serde_json::Value> = slots
.iter()
.map(|s| {
let (status, output, tokens, findings) = s.as_ref().unwrap_or(&default);
serde_json::json!({
"status": status,
"output": output,
"tokens": tokens,
"findings": findings,
})
})
.collect();
let ok = slots.iter().filter(|s| s.as_ref().map(|x| x.0 == "ok").unwrap_or(false)).count();
let failed = slots.len() - ok;
let arr = lua.create_table()?;
for (i, slot) in slots.into_iter().enumerate() {
let (status, output, tokens, findings) =
slot.unwrap_or_else(|| ("error".into(), serde_json::Value::Null, 0, vec![]));
arr.set(i + 1, build_result_table(lua, &status, output, tokens, &findings)?)?;
}
Ok((arr, ok, failed, serde_json::Value::Array(results_json)))
})();
let elapsed_ms = t0.elapsed().as_millis() as u64;
let done = match &outcome {
Ok((_, ok, failed, results)) => AgentEvent::ParallelDone {
run_id, phase_id, span_id, ok: *ok, failed: *failed, results: results.clone(), elapsed_ms,
},
Err(e) => AgentEvent::ParallelDone {
run_id, phase_id, span_id, ok: 0, failed: count,
results: serde_json::json!({ "error": e.to_string() }), elapsed_ms,
},
};
let _ = events.send(done);
outcome.map(|(arr, ..)| arr)
})?;
globals.set("parallel", parallel_fn)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::register;
use crate::sdk::task::build_task;
use crate::sdk::ReportSink;
use crate::sdk::SdkContext;
use luft_core::contract::backend::{AgentStatus, RunContext};
use luft_core::contract::ids::TokenUsage;
use luft_core::journal::JournalStore;
use luft_core::scheduler::{BackendRegistry, SchedulerConfig};
use luft_core::Scheduler;
use luft_core::{FailKind, MockBackend, MockBehavior};
use mlua::Lua;
use std::sync::atomic::AtomicU32;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::broadcast;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
fn make_cx(behaviors: Vec<MockBehavior>) -> (Lua, SdkContext, tokio::runtime::Runtime) {
let rt = tokio::runtime::Runtime::new().unwrap();
let lua = Lua::new();
let run_id = Uuid::now_v7();
let (tx, _rx) = broadcast::channel(64);
let run_ctx = RunContext {
run_id,
cancel: CancellationToken::new(),
events: tx,
};
let report_sink: ReportSink = Arc::new(Mutex::new(None));
let handle = rt.handle().clone();
let backend = Arc::new(MockBackend::new("mock", behaviors));
let scheduler: Arc<Scheduler> = Scheduler::new(
SchedulerConfig::default(),
BackendRegistry::new()
.with(backend as Arc<dyn luft_core::contract::backend::AgentBackend>),
None,
);
scheduler.init_run_with(run_id, run_ctx.events.clone());
let cx = SdkContext::new(run_ctx, scheduler, report_sink, None, handle);
(lua, cx, rt)
}
fn make_cx_with_journal(
behaviors: Vec<MockBehavior>,
) -> (
Lua,
SdkContext,
tokio::runtime::Runtime,
Arc<JournalStore>,
tempfile::TempDir,
) {
let rt = tokio::runtime::Runtime::new().unwrap();
let lua = Lua::new();
let run_id = Uuid::now_v7();
let (tx, _rx) = broadcast::channel(64);
let run_ctx = RunContext {
run_id,
cancel: CancellationToken::new(),
events: tx,
};
let report_sink: ReportSink = Arc::new(Mutex::new(None));
let handle = rt.handle().clone();
let backend = Arc::new(MockBackend::new("mock", behaviors));
let scheduler: Arc<Scheduler> = Scheduler::new(
SchedulerConfig::default(),
BackendRegistry::new()
.with(backend as Arc<dyn luft_core::contract::backend::AgentBackend>),
None,
);
scheduler.init_run_with(run_id, run_ctx.events.clone());
let dir = tempfile::TempDir::new().unwrap();
let journal = Arc::new(JournalStore::new(dir.path()).unwrap());
journal.init_run(run_id, "test").unwrap();
let cx = SdkContext::new(
run_ctx,
scheduler,
report_sink,
Some(journal.clone()),
handle,
);
(lua, cx, rt, journal, dir)
}
fn make_cx_with_journal_and_backend(
behaviors: Vec<MockBehavior>,
) -> (
Lua,
SdkContext,
tokio::runtime::Runtime,
Arc<JournalStore>,
tempfile::TempDir,
Arc<MockBackend>,
) {
let rt = tokio::runtime::Runtime::new().unwrap();
let lua = Lua::new();
let run_id = Uuid::now_v7();
let (tx, _rx) = broadcast::channel(64);
let run_ctx = RunContext {
run_id,
cancel: CancellationToken::new(),
events: tx,
};
let report_sink: ReportSink = Arc::new(Mutex::new(None));
let handle = rt.handle().clone();
let backend = Arc::new(MockBackend::new("mock", behaviors));
let scheduler: Arc<Scheduler> = Scheduler::new(
SchedulerConfig::default(),
BackendRegistry::new()
.with(backend.clone() as Arc<dyn luft_core::contract::backend::AgentBackend>),
None,
);
scheduler.init_run_with(run_id, run_ctx.events.clone());
let dir = tempfile::TempDir::new().unwrap();
let journal = Arc::new(JournalStore::new(dir.path()).unwrap());
journal.init_run(run_id, "test").unwrap();
let cx = SdkContext::new(
run_ctx,
scheduler,
report_sink,
Some(journal.clone()),
handle,
);
(lua, cx, rt, journal, dir, backend)
}
#[test]
fn empty_items() {
let (lua, cx, _rt) = make_cx(vec![MockBehavior::Success {
output: serde_json::json!({}),
tokens: TokenUsage::default(),
delay: Duration::ZERO,
}]);
register(&lua, &cx).unwrap();
let results: mlua::Table = lua
.load(r#"parallel({}, function() return { prompt = "x" } end)"#)
.eval()
.unwrap();
assert_eq!(results.raw_len(), 0);
}
#[test]
fn map_fn_lua_error() {
let (lua, cx, _rt) = make_cx(vec![MockBehavior::Success {
output: serde_json::json!({}),
tokens: TokenUsage::default(),
delay: Duration::ZERO,
}]);
register(&lua, &cx).unwrap();
let err = lua
.load(r#"parallel({1, 2}, function() error('boom') end)"#)
.eval::<mlua::Value>()
.unwrap_err();
assert!(
err.to_string().contains("boom"),
"unexpected error: {}",
err
);
}
#[test]
fn build_task_missing_prompt() {
let (lua, cx, _rt) = make_cx(vec![MockBehavior::Success {
output: serde_json::json!({}),
tokens: TokenUsage::default(),
delay: Duration::ZERO,
}]);
register(&lua, &cx).unwrap();
let err = lua
.load(r#"parallel({1}, function() return { model = "x" } end)"#)
.eval::<mlua::Value>()
.unwrap_err();
assert!(
err.to_string().contains("missing required 'prompt'"),
"unexpected error: {}",
err,
);
}
#[test]
fn all_items_fail() {
let (lua, cx, _rt) = make_cx(vec![MockBehavior::Fail {
kind: FailKind::Protocol,
delay: Duration::ZERO,
}]);
register(&lua, &cx).unwrap();
let script = r#"
return parallel(
{ "a", "b", "c" },
function(item)
return { prompt = "task_" .. item }
end
)
"#;
let results: mlua::Table = lua.load(script).eval().unwrap();
assert_eq!(results.raw_len(), 3);
let mut ok = 0usize;
let mut err = 0usize;
for i in 1..=3 {
let r: mlua::Table = results.get(i).unwrap();
if r.get::<bool>("ok").unwrap() {
ok += 1;
} else {
err += 1;
}
}
assert_eq!(ok, 0, "expected 0 ok results");
assert_eq!(err, 3, "expected 3 error results");
}
#[test]
fn partial_failure_detail() {
let (lua, cx, _rt) = make_cx(vec![
MockBehavior::Success {
output: serde_json::json!({}),
tokens: TokenUsage::default(),
delay: Duration::ZERO,
},
MockBehavior::Fail {
kind: FailKind::Protocol,
delay: Duration::ZERO,
},
MockBehavior::Success {
output: serde_json::json!({}),
tokens: TokenUsage::default(),
delay: Duration::ZERO,
},
]);
register(&lua, &cx).unwrap();
let script = r#"
return parallel(
{ "x", "y", "z" },
function(item)
return { prompt = "task_" .. item }
end
)
"#;
let results: mlua::Table = lua.load(script).eval().unwrap();
assert_eq!(results.raw_len(), 3);
for i in 1..=3 {
let r: mlua::Table = results.get(i).unwrap();
if !r.get::<bool>("ok").unwrap() {
assert_eq!(r.get::<String>("status").unwrap(), "error");
let output: mlua::Table = r.get("output").unwrap();
let err_msg: String = output.get("error").unwrap();
assert!(
err_msg.contains("mock protocol"),
"expected 'mock protocol' in error detail, got: {}",
err_msg,
);
}
}
}
#[test]
fn successful_output_and_tokens() {
let (lua, cx, _rt) = make_cx(vec![
MockBehavior::Success {
output: serde_json::json!({ "result": "alpha" }),
tokens: TokenUsage {
input: 10,
output: 20,
cache_read: 0,
cache_write: 0,
},
delay: Duration::ZERO,
},
MockBehavior::Success {
output: serde_json::json!({ "result": "beta" }),
tokens: TokenUsage {
input: 5,
output: 15,
cache_read: 0,
cache_write: 0,
},
delay: Duration::ZERO,
},
]);
register(&lua, &cx).unwrap();
let script = r#"
return parallel(
{ "a", "b" },
function(item)
return { prompt = "task_" .. item }
end
)
"#;
let results: mlua::Table = lua.load(script).eval().unwrap();
assert_eq!(results.raw_len(), 2);
let r1: mlua::Table = results.get(1).unwrap();
assert_eq!(r1.get::<String>("status").unwrap(), "ok");
assert!(r1.get::<bool>("ok").unwrap());
let out1: mlua::Table = r1.get("output").unwrap();
assert_eq!(out1.get::<String>("result").unwrap(), "alpha");
assert_eq!(r1.get::<i64>("tokens").unwrap(), 30);
let r2: mlua::Table = results.get(2).unwrap();
assert_eq!(r2.get::<String>("status").unwrap(), "ok");
assert!(r2.get::<bool>("ok").unwrap());
let out2: mlua::Table = r2.get("output").unwrap();
assert_eq!(out2.get::<String>("result").unwrap(), "beta");
assert_eq!(r2.get::<i64>("tokens").unwrap(), 20);
}
#[test]
fn single_item() {
let (lua, cx, _rt) = make_cx(vec![MockBehavior::Success {
output: serde_json::json!({ "x": 42 }),
tokens: TokenUsage {
input: 1,
output: 1,
cache_read: 0,
cache_write: 0,
},
delay: Duration::ZERO,
}]);
register(&lua, &cx).unwrap();
let script = r#"
return parallel(
{ "only" },
function(item)
return { prompt = "single" }
end
)
"#;
let results: mlua::Table = lua.load(script).eval().unwrap();
assert_eq!(results.raw_len(), 1);
let r: mlua::Table = results.get(1).unwrap();
assert_eq!(r.get::<String>("status").unwrap(), "ok");
assert!(r.get::<bool>("ok").unwrap());
assert_eq!(r.get::<i64>("tokens").unwrap(), 2);
let out: mlua::Table = r.get("output").unwrap();
assert_eq!(out.get::<i64>("x").unwrap(), 42);
}
#[test]
fn map_fn_returns_non_table_error() {
let (lua, cx, _rt) = make_cx(vec![MockBehavior::Success {
output: serde_json::json!({}),
tokens: TokenUsage::default(),
delay: Duration::ZERO,
}]);
register(&lua, &cx).unwrap();
let err = lua
.load(r#"parallel({1}, function() return 42 end)"#)
.eval::<mlua::Value>()
.unwrap_err();
assert!(
err.to_string()
.contains("map function must return an options table"),
"unexpected error: {}",
err,
);
}
#[test]
fn successful_parallel() {
let (lua, cx, _rt) = make_cx(vec![
MockBehavior::Success {
output: serde_json::json!({}),
tokens: TokenUsage {
input: 1,
output: 1,
cache_read: 0,
cache_write: 0,
},
delay: Duration::from_millis(1),
},
MockBehavior::Success {
output: serde_json::json!({ "x": 1 }),
tokens: TokenUsage {
input: 2,
output: 3,
cache_read: 0,
cache_write: 0,
},
delay: Duration::from_millis(1),
},
]);
register(&lua, &cx).unwrap();
let script = r#"
return parallel(
{ "a", "b" },
function(item)
if item == "a" then
return { prompt = "hello", model = "claude-x" }
else
return { prompt = "world", model = "claude-x" }
end
end
)
"#;
let results: mlua::Table = lua.load(script).eval().unwrap();
assert_eq!(results.raw_len(), 2);
for i in 1..=2 {
let r: mlua::Table = results.get(i).unwrap();
assert_eq!(r.get::<String>("status").unwrap(), "ok");
assert!(r.get::<bool>("ok").unwrap());
}
}
#[test]
fn partial_failure() {
let (lua, cx, _rt) = make_cx(vec![
MockBehavior::Success {
output: serde_json::json!({}),
tokens: TokenUsage {
input: 1,
output: 1,
cache_read: 0,
cache_write: 0,
},
delay: Duration::ZERO,
},
MockBehavior::Fail {
kind: FailKind::Protocol,
delay: Duration::ZERO,
},
MockBehavior::Success {
output: serde_json::json!({ "z": 9 }),
tokens: TokenUsage {
input: 3,
output: 4,
cache_read: 0,
cache_write: 0,
},
delay: Duration::ZERO,
},
]);
register(&lua, &cx).unwrap();
let script = r#"
return parallel(
{ "x", "y", "z" },
function(item)
return { prompt = "task_" .. item }
end
)
"#;
let results: mlua::Table = lua.load(script).eval().unwrap();
assert_eq!(results.raw_len(), 3);
let mut ok = 0usize;
let mut err = 0usize;
for i in 1..=3 {
let r: mlua::Table = results.get(i).unwrap();
if r.get::<bool>("ok").unwrap() {
ok += 1;
} else {
err += 1;
}
}
assert_eq!(ok, 2, "expected 2 ok results");
assert_eq!(err, 1, "expected 1 error result");
}
#[test]
fn all_items_cached() {
let (lua, cx, _rt, journal, _dir) = make_cx_with_journal(vec![MockBehavior::Success {
output: serde_json::json!({}),
tokens: TokenUsage::default(),
delay: Duration::ZERO,
}]);
register(&lua, &cx).unwrap();
let opts = lua.create_table().unwrap();
opts.set("prompt", "cached_prompt").unwrap();
let (_, cache_key, _) = build_task(&opts, 0, &Arc::new(AtomicU32::new(0))).unwrap();
journal
.cache_agent(
&cache_key,
Uuid::now_v7(),
0,
AgentStatus::Ok,
serde_json::json!({ "from_cache": true }),
vec![],
TokenUsage {
input: 1,
output: 1,
cache_read: 0,
cache_write: 0,
},
)
.unwrap();
let script = r#"
return parallel(
{ 1, 2, 3 },
function()
return { prompt = "cached_prompt" }
end
)
"#;
let results: mlua::Table = lua.load(script).eval().unwrap();
assert_eq!(results.raw_len(), 3);
for i in 1..=3 {
let r: mlua::Table = results.get(i).unwrap();
assert_eq!(r.get::<String>("status").unwrap(), "ok");
assert!(r.get::<bool>("ok").unwrap());
}
}
#[test]
fn partial_cache_mixed() {
let (lua, cx, _rt, journal, _dir, backend) = make_cx_with_journal_and_backend(vec![
MockBehavior::Success {
output: serde_json::json!({ "fresh": true }),
tokens: TokenUsage {
input: 5,
output: 5,
cache_read: 0,
cache_write: 0,
},
delay: Duration::ZERO,
},
]);
register(&lua, &cx).unwrap();
let opts1 = lua.create_table().unwrap();
opts1.set("prompt", "aaa").unwrap();
let (_, ck1, _) = build_task(&opts1, 0, &Arc::new(AtomicU32::new(0))).unwrap();
journal
.cache_agent(
&ck1,
Uuid::now_v7(),
0,
AgentStatus::Ok,
serde_json::json!({ "source": "cache" }),
vec![],
TokenUsage::default(),
)
.unwrap();
let script = r#"
return parallel(
{ "first", "second", "third" },
function(item)
if item == "second" then
return { prompt = "bbb" }
else
return { prompt = "aaa" }
end
end
)
"#;
let results: mlua::Table = lua.load(script).eval().unwrap();
assert_eq!(results.raw_len(), 3);
assert_eq!(backend.call_count(), 1, "expected exactly 1 backend call");
for i in 1..=3 {
let r: mlua::Table = results.get(i).unwrap();
assert_eq!(r.get::<String>("status").unwrap(), "ok");
assert!(r.get::<bool>("ok").unwrap());
let output: mlua::Table = r.get("output").unwrap();
if i == 2 {
assert!(
output.get::<bool>("fresh").unwrap_or(false),
"item {} should come from fresh run",
i,
);
} else {
assert_eq!(
output.get::<String>("source").unwrap(),
"cache",
"item {} should come from cache",
i,
);
}
}
}
}