Skip to content

Commit fce312b

Browse files
committed
feat(orchestration): expose executor + Node parallel/resumable (Phase 5a)
The SDK linchpin + the first SDK grammar. core: AgentSession::agent_executor() returns an AgentExecutor backed by the session (its registry, LLM client, workspace, MCP tools, subagent tracker, parallelism cap) — what the orchestration combinators run against; a host can substitute its own for cross-node placement. AgentSession::session_store() exposes the configured store for the resumable combinator. sdk/node: AgentStepSpecObject / StepOutcomeObject (#[napi(object)], with Option<Value> for output_schema/structured) + From conversions, and Session.parallel(specs) / Session.parallelResumable(specs, workflowId) async methods over the executor. parallelResumable rejects when no sessionStore is configured. Pipeline (callback stages across FFI) and the Python SDK land in follow-up sub-PRs. Conversion unit test green; Node SDK fmt + clippy clean; core + combinators unchanged.
1 parent 53425d1 commit fce312b

2 files changed

Lines changed: 181 additions & 0 deletions

File tree

core/src/agent_api.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -920,6 +920,35 @@ impl AgentSession {
920920
SessionView::from_session(self).id()
921921
}
922922

923+
/// An [`AgentExecutor`](crate::orchestration::AgentExecutor) backed by this
924+
/// session — runs each orchestrated step as a child agent on this node,
925+
/// inheriting the session's agent registry, LLM client, workspace, MCP
926+
/// tools, and subagent tracker.
927+
///
928+
/// This is what the orchestration combinators
929+
/// ([`execute_steps_parallel`](crate::orchestration::execute_steps_parallel),
930+
/// [`execute_pipeline`](crate::orchestration::execute_pipeline),
931+
/// [`execute_steps_parallel_resumable`](crate::orchestration::execute_steps_parallel_resumable))
932+
/// run against; a host can instead supply its own executor to place steps
933+
/// across a cluster.
934+
pub fn agent_executor(&self) -> Arc<dyn crate::orchestration::AgentExecutor> {
935+
let executor = crate::tools::TaskExecutor::with_mcp(
936+
Arc::clone(&self.agent_registry),
937+
Arc::clone(&self.llm_client),
938+
self.workspace.display().to_string(),
939+
Arc::clone(&self.mcp_manager),
940+
)
941+
.with_subagent_tracker(Arc::clone(&self.subagent_tasks))
942+
.with_max_parallel_tasks(self.config.max_parallel_tasks);
943+
Arc::new(executor)
944+
}
945+
946+
/// The session's persistence store, if one is configured — needed by the
947+
/// resumable orchestration combinator to journal workflow progress.
948+
pub fn session_store(&self) -> Option<Arc<dyn crate::store::SessionStore>> {
949+
self.session_store.clone()
950+
}
951+
923952
/// Return the definitions of all tools currently registered in this session.
924953
///
925954
/// The list reflects the live state of the tool executor — tools added via

sdk/node/src/lib.rs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ use a3s_code_core::hooks::{
5656
HookMatcher as RustHookMatcher, HookResponse as RustHookResponse,
5757
};
5858
use a3s_code_core::llm::{ContentBlock as RustContentBlock, Message as RustMessage};
59+
use a3s_code_core::orchestration::{
60+
execute_steps_parallel, execute_steps_parallel_resumable, AgentStepSpec as RustAgentStepSpec,
61+
StepOutcome as RustStepOutcome,
62+
};
5963
use a3s_code_core::permissions::{
6064
PermissionDecision as RustPermissionDecision, PermissionPolicy as RustPermissionPolicy,
6165
PermissionRule as RustPermissionRule,
@@ -3012,6 +3016,67 @@ pub struct McpServerStatusEntry {
30123016
// Session
30133017
// ============================================================================
30143018

3019+
/// One unit of orchestrated agent work — what to run, independent of where.
3020+
#[napi(object)]
3021+
#[derive(Clone)]
3022+
pub struct AgentStepSpecObject {
3023+
/// Stable id for this step (assigned by the caller).
3024+
pub task_id: String,
3025+
/// Registry key of the agent to run (e.g. "explore", "review").
3026+
pub agent: String,
3027+
/// Short label for display/tracking.
3028+
pub description: String,
3029+
/// Instruction handed to the child agent.
3030+
pub prompt: String,
3031+
/// Optional per-step tool-round cap.
3032+
pub max_steps: Option<u32>,
3033+
/// Optional parent session id for event correlation.
3034+
pub parent_session_id: Option<String>,
3035+
/// When set, the step must return JSON conforming to this schema; the
3036+
/// validated object lands in `StepOutcomeObject.structured`.
3037+
pub output_schema: Option<serde_json::Value>,
3038+
}
3039+
3040+
impl From<AgentStepSpecObject> for RustAgentStepSpec {
3041+
fn from(o: AgentStepSpecObject) -> Self {
3042+
RustAgentStepSpec {
3043+
task_id: o.task_id,
3044+
agent: o.agent,
3045+
description: o.description,
3046+
prompt: o.prompt,
3047+
max_steps: o.max_steps.map(|n| n as usize),
3048+
parent_session_id: o.parent_session_id,
3049+
output_schema: o.output_schema,
3050+
}
3051+
}
3052+
}
3053+
3054+
/// The result of running one orchestrated step.
3055+
#[napi(object)]
3056+
#[derive(Clone)]
3057+
pub struct StepOutcomeObject {
3058+
pub task_id: String,
3059+
pub session_id: String,
3060+
pub agent: String,
3061+
pub output: String,
3062+
pub success: bool,
3063+
/// Schema-validated structured output, when the step requested one.
3064+
pub structured: Option<serde_json::Value>,
3065+
}
3066+
3067+
impl From<RustStepOutcome> for StepOutcomeObject {
3068+
fn from(o: RustStepOutcome) -> Self {
3069+
StepOutcomeObject {
3070+
task_id: o.task_id,
3071+
session_id: o.session_id,
3072+
agent: o.agent,
3073+
output: o.output,
3074+
success: o.success,
3075+
structured: o.structured,
3076+
}
3077+
}
3078+
}
3079+
30153080
/// Workspace-bound session. All LLM and tool operations happen here.
30163081
#[napi]
30173082
pub struct Session {
@@ -3070,6 +3135,60 @@ impl Session {
30703135
Ok(AgentResult::from(result))
30713136
}
30723137

3138+
/// Run `specs` as a fan-out of agent steps, bounded by the session's
3139+
/// configured parallelism, and resolve with each step's outcome in input
3140+
/// order. A failed step surfaces as `success: false` without failing the
3141+
/// batch.
3142+
#[napi]
3143+
pub async fn parallel(
3144+
&self,
3145+
specs: Vec<AgentStepSpecObject>,
3146+
) -> napi::Result<Vec<StepOutcomeObject>> {
3147+
let session = self.inner.clone();
3148+
let rust_specs: Vec<RustAgentStepSpec> = specs.into_iter().map(Into::into).collect();
3149+
let outcomes = get_runtime()
3150+
.spawn(async move {
3151+
let executor = session.agent_executor();
3152+
execute_steps_parallel(executor, rust_specs, None).await
3153+
})
3154+
.await
3155+
.map_err(|e| napi::Error::from_reason(format!("Task join error: {e}")))?;
3156+
Ok(outcomes.into_iter().map(StepOutcomeObject::from).collect())
3157+
}
3158+
3159+
/// Like `parallel`, but resumable: progress is journaled under
3160+
/// `workflowId` via the session's `sessionStore`, so an interrupted run
3161+
/// skips already-completed steps. Rejects when no `sessionStore` is
3162+
/// configured.
3163+
#[napi]
3164+
pub async fn parallel_resumable(
3165+
&self,
3166+
specs: Vec<AgentStepSpecObject>,
3167+
workflow_id: String,
3168+
) -> napi::Result<Vec<StepOutcomeObject>> {
3169+
let session = self.inner.clone();
3170+
let rust_specs: Vec<RustAgentStepSpec> = specs.into_iter().map(Into::into).collect();
3171+
let outcomes = get_runtime()
3172+
.spawn(async move {
3173+
let Some(store) = session.session_store() else {
3174+
return Err("parallelResumable requires a sessionStore on the session");
3175+
};
3176+
let executor = session.agent_executor();
3177+
Ok(execute_steps_parallel_resumable(
3178+
executor,
3179+
rust_specs,
3180+
&workflow_id,
3181+
store,
3182+
None,
3183+
)
3184+
.await)
3185+
})
3186+
.await
3187+
.map_err(|e| napi::Error::from_reason(format!("Task join error: {e}")))?
3188+
.map_err(napi::Error::from_reason)?;
3189+
Ok(outcomes.into_iter().map(StepOutcomeObject::from).collect())
3190+
}
3191+
30733192
/// Send a prompt or request and get a streaming event iterator.
30743193
///
30753194
/// Returns an `EventStream`. Use `for await (const event of stream)` or call `.next()` manually.
@@ -5374,6 +5493,39 @@ impl From<SearchConfig> for RustSearchConfig {
53745493
mod tests {
53755494
use super::*;
53765495

5496+
#[test]
5497+
fn orchestration_object_conversions_round_trip_fields() {
5498+
let schema = serde_json::json!({ "type": "object" });
5499+
let spec = AgentStepSpecObject {
5500+
task_id: "t1".into(),
5501+
agent: "explore".into(),
5502+
description: "d".into(),
5503+
prompt: "p".into(),
5504+
max_steps: Some(5),
5505+
parent_session_id: Some("parent".into()),
5506+
output_schema: Some(schema.clone()),
5507+
};
5508+
let rust: RustAgentStepSpec = spec.into();
5509+
assert_eq!(rust.task_id, "t1");
5510+
assert_eq!(rust.agent, "explore");
5511+
assert_eq!(rust.max_steps, Some(5));
5512+
assert_eq!(rust.parent_session_id.as_deref(), Some("parent"));
5513+
assert_eq!(rust.output_schema, Some(schema));
5514+
5515+
let outcome = RustStepOutcome {
5516+
task_id: "t1".into(),
5517+
session_id: "task-run-t1".into(),
5518+
agent: "explore".into(),
5519+
output: "out".into(),
5520+
success: true,
5521+
structured: Some(serde_json::json!({ "k": 1 })),
5522+
};
5523+
let obj = StepOutcomeObject::from(outcome);
5524+
assert_eq!(obj.task_id, "t1");
5525+
assert!(obj.success);
5526+
assert_eq!(obj.structured, Some(serde_json::json!({ "k": 1 })));
5527+
}
5528+
53775529
fn sdk_test_config() -> a3s_code_core::CodeConfig {
53785530
a3s_code_core::CodeConfig {
53795531
default_model: Some("openai/gpt-4o".to_string()),

0 commit comments

Comments
 (0)