@@ -57,7 +57,8 @@ use a3s_code_core::hooks::{
5757} ;
5858use a3s_code_core:: llm:: { ContentBlock as RustContentBlock , Message as RustMessage } ;
5959use 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} ;
6364use a3s_code_core:: permissions:: {
@@ -85,6 +86,7 @@ use a3s_code_core::{
8586 SessionOptions as RustSessionOptions ,
8687} ;
8788use napi:: Either ;
89+ use napi:: Env ;
8890
8991// AHP Type Bindings
9092mod 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 {
48214894unsafe impl Send for NodeBudgetGuard { }
48224895unsafe 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+
48244985impl NodeBudgetGuard {
48254986 fn call_decision (
48264987 & self ,
0 commit comments