BasicFlow tutorial
13. Parallel children and tools: runSteps()
runSteps() fans out independent registered Steps through an isolated fork/join barrier. A parallel child may call tools and return typed JSON to its parent with directResult().
runStep() calls one nested child. runSteps() calls independent children concurrently. The
caller’s class-based syntax is unchanged: use the registered Step classes directly. The runtime
creates a fresh worker for every branch; there is no decorator, factory hook, or other parallel
registration to add to the child class.
Fan-out from onEnter()
InContextStep starts two follow-up Steps at the same time:
protected override async onEnter() {
await super.onEnter();
const batch = await this.runSteps([
{ step: ConcurStep1, userMessage: "Run the 1st concurrent follow-up task." },
{ step: ConcurStep2, userMessage: "Run the 2nd concurrent follow-up task." },
]);
if (batch.rejected.length > 0) {
throw new Error(batch.rejected.map(({ error }) => error.message).join("; "));
}
this.saveState({
concurStep1: batch.fulfilled[0]?.output ?? null,
concurStep2: batch.fulfilled[1]?.output ?? null,
});
}
The result is a ParallelBatchResult, not an array of message content. Its branches,
fulfilled, and rejected collections are in request order. The default policy,
retain-successes, preserves successful branches even if a sibling rejects. Pass
{ failurePolicy: "atomic" } when any rejected branch should prevent all state publication.
What each branch can do
At the fork, each worker gets an immutable snapshot of the session document, context, and other
Step states, plus a mutable private copy of its own state and memory. A worker may call
saveState() or removeState() only for itself. It cannot update another Step, move the
cursor, complete the session, write the session document, alter shared Flow memory, or save the
session; those attempts raise ParallelMutationError.
Successful child state is merged into the caller’s authoritative session document before
runSteps() resolves. The caller can therefore immediately inspect it with getStepState() or
getSessionDoc(). The normal outer turn persists it. Use { checkpoint: "root-join" } only
when a root-level join must request an immediate durable save.
Raw child memory is deliberately not merged. It is private while the branch runs and discarded at branch end. A child communicates through its returned output and its published Step state, not by leaking an in-progress transcript into its siblings.
Tools are allowed in parallel children
Parallel execution does not make a Step tool-free. A child may expose a normal Zod-backed
tool with defineTool() and handle it with @Tool, just as NameStep or DOBStep does. The
tool call is made by that child’s model and the handler may validate input, call a service, and
save state owned by that child.
The difference is the tool’s result: a parallel child cannot change the shared cursor. Use
directResult(json) to return a JSON value to the runSteps() caller. Do not return go(),
stay(), or direct() from that handler; those are routing or user-response builders and throw
inside a parallel child.
A directResult() tool call from ConcurStep1
ConcurStep1 first starts ConcurStep3 as a nested child from onEnter(). Once that inner
barrier completes, its model makes one tool call; the handler saves child-owned state and
returns a JSON result directly to InContextStep:
protected override async onEnter() {
await super.onEnter();
this.saveState({ concurStep1: "Starting nested ConcurStep3." });
const batch = await this.runSteps([
{ step: ConcurStep3, userMessage: "Run the ConcurStep3." },
]);
if (batch.rejected.length > 0) {
throw new Error(batch.rejected[0]!.error.message);
}
}
public override getPrompt(): string {
return `
You are ConcurStep1.
Immediately call 'complete_concurrent_step1' with no arguments.
Do not return prose.
`;
}
@Tool
protected async complete_concurrent_step1(): Promise<ToolResponseType> {
const result = { completed: true };
this.saveState({ concurStep1: result });
return directResult(result);
}
The marker is part of ConcurStep3’s fork snapshot. The completion tool later replaces it with
{ completed: true }, which is the value published to the outer caller.
directResult(result) finishes only this branch. It does not move the durable cursor and it
does not make the usual follow-up model call. The caller receives the same object at
batch.fulfilled[0].output; InContextStep saves it as its own concurStep1 state. This is
the child-only alternative to direct(), go(), and stay(), which are still invalid inside a
parallel branch because they attempt a cursor transition.
Every directResult(...) call has four rules:
- Return it from an
@Toolor@Toolshandler in an activerunSteps()child. - Pass only JSON-compatible data: string, number, boolean,
null, array, or object. - Save any child-owned state before returning it; the successful branch publishes that state at the join.
- Return at most one
directResult(...)for a single model tool turn.
The direct-result path is deliberately terminal for the child:
ConcurStep1 model call
-> complete_concurrent_step1 tool call
-> handler saves ConcurStep1 state
-> directResult({ completed: true })
-> batch.fulfilled[0].output in InContextStep
There is no second model call, no onResponse() call for ConcurStep1, and no cursor change.
Nested fan-out
BasicFlow also retains a nested fan-out example:
ConcurStep1startsConcurStep3fromonEnter(), before its own model call.ConcurStep2startsConcurStep4fromonEnter(), before its own model call.
Use onEnter() for prerequisite work needed by the parent prompt. The nested call checks its
structured batch result before continuing.
If B changes its private state and then calls B.runSteps([D, E]), D and E both see B’s
materialized pre-fork state. They do not see each other’s in-flight changes. Successful D/E
state becomes visible to B at the inner join, and reaches the root session document only if B
itself succeeds at the outer join.
Repeating one Step class
The same Step class may occur more than once in one batch. Give every repeated invocation a non-empty, batch-unique key:
await this.runSteps([
{ step: QuoteStep, key: "basic", params: { plan: "basic" } },
{ step: QuoteStep, key: "premium", params: { plan: "premium" } },
]);
Each request receives a separate QuoteStep state copy. If two copies replace the same state
field, the barrier raises ParallelStateConflictError; the framework will not choose a winner
based on timing. When combining copies is intentional, declare a reducer channel and contribute
to it explicitly:
protected override parallelStateChannels() {
return { quoteByPlan: StepChannels.keyedByBranch() };
}
public override async run() {
const quote = await this.quote(this.getParallelInvocation().params);
this.contributeState("quoteByPlan", quote);
return quote;
}
Reducers run in request order, not completion order.
The resulting tree
NameStep.user_name (top level)
runStep(InContextStep)
InContextStep.onEnter
runSteps([ConcurStep1, ConcurStep2]) (parallel barrier)
ConcurStep1.onEnter -> runSteps([ConcurStep3])
then -> complete_concurrent_step1 -> directResult({ completed: true })
ConcurStep2 -> onEnter -> runSteps([ConcurStep4])
InContextStep model call
go(DOBStep) (the owner moves the cursor)
ConcurStep1 and ConcurStep2 run concurrently. Their nested branches can overlap, but no
branch may change the durable cursor. Only the owning top-level Step decides a transition.
Common mistakes
- Treating the result as an array. Inspect
batch.rejectedand usebatch.fulfilledorbatch.branches. - Depending on a sibling’s write. Siblings see snapshots, not each other’s live work.
Sequence dependent work with
runStep(). - Writing another Step’s state. Publish only the worker’s own state; use a reducer for deliberate fan-in from repeated copies.
- Returning
go(),stay(), ordirect()from a parallel tool. Tools are allowed; only their routing-style results are not. UsedirectResult(json)when the parent needs a branch result. - Using raw memory as the result channel. Branch histories are private and discarded.
- Assuming a successful branch is immediately durable. It is immediately visible to the caller, then persists with the outer turn unless a root-join checkpoint is requested.
Next
InContextStep reads a value with getTransientState().
14. Transient state and context explains where
that came from and what survives persistence.