Skip to content

Commit 6a6d041

Browse files
committed
feat(orchestration): Node pipeline with callback stages (Phase 5b)
Session.pipeline(items, stages, timeoutMs?) exposes execute_pipeline to JS. Each stage is (ctx: { previous, item }) => spec | null; chains run with no inter-stage barrier and stop on a null stage or a failed step. FFI design (honors the #32 contract): - Synchronous napi method returning a Promise via env.create_deferred(): JS stage functions (not Send) are converted to ThreadsafeFunctions on the JS thread, then the chains run on the worker runtime and resolve the Promise — the event loop is never blocked and no non-Send value crosses the async boundary (an async fn taking Vec<JsFunction> won't compile, by design). - Each stage call uses call_with_return_value + a fail-closed timeout (default 30s) and lenient JsUnknown parsing: a null/unreadable return or a hang stops that chain (None) rather than fabricating a step or blocking. Mirrors the proven setBudgetGuard bridge; stage callbacks MUST NOT throw (documented) — same napi return-conversion-abort constraint. Limitation: per-stage outputSchema isn't supported on pipeline stages yet (the lenient parse can't read an arbitrary schema property); use parallel for schema-validated steps. Conversion test green; fmt + clippy --lib clean.
1 parent ab4fba2 commit 6a6d041

1 file changed

Lines changed: 162 additions & 1 deletion

File tree

sdk/node/src/lib.rs

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ use a3s_code_core::hooks::{
5757
};
5858
use a3s_code_core::llm::{ContentBlock as RustContentBlock, Message as RustMessage};
5959
use a3s_code_core::orchestration::{
60-
execute_steps_parallel, execute_steps_parallel_resumable, AgentStepSpec as RustAgentStepSpec,
60+
execute_pipeline, execute_steps_parallel, execute_steps_parallel_resumable,
61+
AgentStepSpec as RustAgentStepSpec, PipelineStage as RustPipelineStage,
6162
StepOutcome as RustStepOutcome,
6263
};
6364
use a3s_code_core::permissions::{
@@ -85,6 +86,7 @@ use a3s_code_core::{
8586
SessionOptions as RustSessionOptions,
8687
};
8788
use napi::Either;
89+
use napi::Env;
8890

8991
// AHP Type Bindings
9092
mod ahp_types;
@@ -3189,6 +3191,77 @@ impl Session {
31893191
Ok(outcomes.into_iter().map(StepOutcomeObject::from).collect())
31903192
}
31913193

3194+
/// Run each item through a chain of `stages`, with no barrier between
3195+
/// stages — item A can be in stage 3 while item B is still in stage 1.
3196+
///
3197+
/// Each stage is a function `(ctx) => spec | null` where `ctx` is
3198+
/// `{ previous: StepOutcomeObject | null, item: any }`. Return an
3199+
/// `AgentStepSpecObject` (camelCase keys) to run that step, or `null` to
3200+
/// stop the item's chain. A chain also stops when a step fails.
3201+
///
3202+
/// IMPORTANT: a stage callback MUST NOT throw — in this napi version a JS
3203+
/// throw at return-conversion aborts the process (same constraint as
3204+
/// `setBudgetGuard`). Wrap your logic in try/catch and return `null` on
3205+
/// error. A stage that hangs past `timeoutMs` (default 30s) fails closed
3206+
/// (treated as `null`, stopping that chain) rather than blocking forever.
3207+
///
3208+
/// This is a *synchronous* napi method that returns a Promise via a
3209+
/// deferred: the JS stage functions (which are not `Send`) are converted
3210+
/// to thread-safe functions on the JS thread here, then the chains run on
3211+
/// the worker runtime and resolve the Promise — so the event loop is never
3212+
/// blocked and no non-`Send` value crosses the async boundary.
3213+
#[napi(
3214+
ts_args_type = "items: Array<any>, stages: Array<(ctx: { previous: StepOutcomeObject | null, item: any }) => AgentStepSpecObject | null>, timeoutMs?: number",
3215+
ts_return_type = "Promise<Array<StepOutcomeObject | null>>"
3216+
)]
3217+
pub fn pipeline(
3218+
&self,
3219+
env: Env,
3220+
items: Vec<serde_json::Value>,
3221+
stages: Vec<napi::JsFunction>,
3222+
timeout_ms: Option<u32>,
3223+
) -> napi::Result<napi::JsObject> {
3224+
use napi::threadsafe_function::{ThreadSafeCallContext, ThreadsafeFunction};
3225+
// Single-object arg so the JS stage signature is `(ctx) => ...`.
3226+
let single_obj = |ctx: ThreadSafeCallContext<serde_json::Value>| {
3227+
Ok(vec![ctx.env.to_js_value(&ctx.value)?])
3228+
};
3229+
let timeout = timeout_ms.map(|t| t as u64).unwrap_or(30_000);
3230+
3231+
// Build the thread-safe functions on the JS thread (JsFunction is not
3232+
// Send), then wrap each as a synchronous PipelineStage the combinator
3233+
// can call from the worker runtime.
3234+
let rust_stages: Vec<RustPipelineStage<serde_json::Value>> = stages
3235+
.into_iter()
3236+
.map(|f| {
3237+
let tsfn: ThreadsafeFunction<
3238+
serde_json::Value,
3239+
napi::threadsafe_function::ErrorStrategy::Fatal,
3240+
> = f.create_threadsafe_function(0, single_obj)?;
3241+
let stage = Arc::new(NodePipelineStage {
3242+
tsfn,
3243+
timeout_ms: timeout,
3244+
});
3245+
let pipeline_stage: RustPipelineStage<serde_json::Value> =
3246+
Arc::new(move |prev, item| stage.invoke(prev, item));
3247+
Ok::<_, napi::Error>(pipeline_stage)
3248+
})
3249+
.collect::<napi::Result<Vec<_>>>()?;
3250+
3251+
let session = self.inner.clone();
3252+
let (deferred, promise) = env.create_deferred::<Vec<Option<StepOutcomeObject>>, _>()?;
3253+
get_runtime().spawn(async move {
3254+
let executor = session.agent_executor();
3255+
let outcomes = execute_pipeline(executor, items, rust_stages, None).await;
3256+
let mapped: Vec<Option<StepOutcomeObject>> = outcomes
3257+
.into_iter()
3258+
.map(|o| o.map(StepOutcomeObject::from))
3259+
.collect();
3260+
deferred.resolve(move |_env| Ok(mapped));
3261+
});
3262+
Ok(promise)
3263+
}
3264+
31923265
/// Send a prompt or request and get a streaming event iterator.
31933266
///
31943267
/// Returns an `EventStream`. Use `for await (const event of stream)` or call `.next()` manually.
@@ -4821,6 +4894,94 @@ struct NodeBudgetGuard {
48214894
unsafe impl Send for NodeBudgetGuard {}
48224895
unsafe impl Sync for NodeBudgetGuard {}
48234896

4897+
/// Bridges a JS pipeline-stage function to a synchronous `PipelineStage`.
4898+
struct NodePipelineStage {
4899+
tsfn: napi::threadsafe_function::ThreadsafeFunction<
4900+
serde_json::Value,
4901+
napi::threadsafe_function::ErrorStrategy::Fatal,
4902+
>,
4903+
timeout_ms: u64,
4904+
}
4905+
4906+
// SAFETY: ThreadsafeFunction is designed to be sent across threads.
4907+
unsafe impl Send for NodePipelineStage {}
4908+
unsafe impl Sync for NodePipelineStage {}
4909+
4910+
impl NodePipelineStage {
4911+
fn invoke(
4912+
&self,
4913+
prev: Option<&RustStepOutcome>,
4914+
item: &serde_json::Value,
4915+
) -> Option<RustAgentStepSpec> {
4916+
let previous = prev
4917+
.map(|o| serde_json::to_value(o).unwrap_or(serde_json::Value::Null))
4918+
.unwrap_or(serde_json::Value::Null);
4919+
let payload = serde_json::json!({ "previous": previous, "item": item });
4920+
4921+
let (tx, rx) = std::sync::mpsc::sync_channel::<Option<RustAgentStepSpec>>(1);
4922+
self.tsfn.call_with_return_value(
4923+
payload,
4924+
napi::threadsafe_function::ThreadsafeFunctionCallMode::NonBlocking,
4925+
move |ret: napi::JsUnknown| {
4926+
// Fail-closed: a null/unreadable return stops this chain rather
4927+
// than fabricating a step.
4928+
let _ = tx.send(parse_js_step_spec(ret));
4929+
Ok(())
4930+
},
4931+
);
4932+
// Fail-closed on timeout/throw: under Fatal strategy a JS throw means
4933+
// the return closure never fires, so the channel stays empty — treat
4934+
// as None (stop this chain) instead of blocking forever.
4935+
tokio::task::block_in_place(|| {
4936+
rx.recv_timeout(std::time::Duration::from_millis(self.timeout_ms))
4937+
.unwrap_or(None)
4938+
})
4939+
}
4940+
}
4941+
4942+
/// Parse a JS pipeline-stage return value into an `AgentStepSpec`, or `None`
4943+
/// for `null`/`undefined`/unreadable input (which stops the chain). Accepts
4944+
/// camelCase (the SDK convention) and snake_case keys.
4945+
fn parse_js_step_spec(val: napi::JsUnknown) -> Option<RustAgentStepSpec> {
4946+
use napi::{JsObject, ValueType};
4947+
if !matches!(val.get_type().ok()?, ValueType::Object) {
4948+
return None;
4949+
}
4950+
let obj = unsafe { val.cast::<JsObject>() };
4951+
let get_str = |keys: &[&str]| -> Option<String> {
4952+
for k in keys {
4953+
if let Ok(s) = obj.get_named_property::<napi::JsString>(k) {
4954+
if let Some(v) = s.into_utf8().ok().and_then(|s| s.into_owned().ok()) {
4955+
return Some(v);
4956+
}
4957+
}
4958+
}
4959+
None
4960+
};
4961+
let task_id = get_str(&["taskId", "task_id"])?;
4962+
let agent = get_str(&["agent"])?;
4963+
let prompt = get_str(&["prompt"])?;
4964+
let description = get_str(&["description"]).unwrap_or_default();
4965+
let max_steps = ["maxSteps", "max_steps"]
4966+
.iter()
4967+
.find_map(|k| obj.get_named_property::<napi::JsNumber>(k).ok())
4968+
.and_then(|n| n.get_uint32().ok())
4969+
.map(|n| n as usize);
4970+
let parent_session_id = get_str(&["parentSessionId", "parent_session_id"]);
4971+
Some(RustAgentStepSpec {
4972+
task_id,
4973+
agent,
4974+
description,
4975+
prompt,
4976+
max_steps,
4977+
parent_session_id,
4978+
// Per-stage `outputSchema` is not yet supported on pipeline stages
4979+
// (the lenient JsUnknown parse here can't read an arbitrary JSON-schema
4980+
// property safely). Use `parallel` for schema-validated steps.
4981+
output_schema: None,
4982+
})
4983+
}
4984+
48244985
impl NodeBudgetGuard {
48254986
fn call_decision(
48264987
&self,

0 commit comments

Comments
 (0)