docs: tighten parallel tool-call prose

This commit is contained in:
Tianyi Cui
2026-07-18 14:59:26 +08:00
parent 3e07f9b270
commit 21ec178841
30 changed files with 161 additions and 320 deletions

View File

@@ -29,7 +29,7 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo
```ts
interface Config {
maxParallelToolCalls?: number // shared by every agent; default 10; 1 is serial
maxParallelToolCalls?: number // default 10; 1 is serial
agents: Array<{
id: string // required
provider?: string
@@ -54,7 +54,7 @@ The driver owns one agent for its lifetime. It records turn, step, request, stre
Plugin failure ends the current turn, not the loop. Cancellation clears pending work and aborts the current step without leaking to the next prompt. Terminal continuation stops remain authoritative through turn close and durability flush.
Within a step, consecutive parallel-safe calls form a rolling-pool group; exclusive calls are ordering barriers. The scheduler reclassifies pending calls after each barrier and before replenishing the pool, so a live tool-registry change applies before the next call starts. Only dispatch/body overlaps. Pre/post policy, durable results, and additional context remain in model order. Abort stops replenishment, drains started calls, drops their buffered context, and ends the turn through the normal abort path.
Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and context remain model-ordered. Abort stops new calls, drains started results, discards their context, and follows the normal abort path.
### What belongs to plugins
@@ -82,7 +82,7 @@ Everything that goes beyond "call the model, run the tools, repeat" belongs to p
## Known Limitations and Deferred Work
- **Concurrency is explicit and conservative** — only tools whose per-call classifier returns `true` join the rolling pool; undeclared, invalid, or throwing classifications remain exclusive.
- **Classification is unary** — calls whose safety depends on comparing siblings or resources must remain exclusive ([rationale](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md)).
- **No resume-or-create policy on the config path** — config-driven `create()` starts a fresh `${id}-session-<uuid>` every run (`TODO(demo)`), and a config `resumeSessionId` whose resume fails logs a warning and creates no agent.
- **Config agents have no per-agent persona field or setup hook** — they use the deployment persona; scoped persona/tool composition is available only through the programmatic `ctx.agents.create()` / `resume()` factory options.
- **No built-in turn budget** — the default continuation is `continue` whenever a step had tool calls or steering; bounding a runaway turn requires an `agent/turn-continuation` force-stop plugin.

View File

@@ -54,7 +54,7 @@ export interface PreparedReactLoopAgent {
* @param id - the concrete agent identity.
* @param options - loop options for the agent.
* @param session - the prepared session the agent will own.
* @param maxParallelToolCalls - resolved scheduler cap shared by this factory's agents.
* @param maxParallelToolCalls - resolved in-flight cap for this agent.
* @returns the agent and closures bound only to that exact instance.
*/
export function prepareReactLoopAgent(
@@ -144,7 +144,7 @@ export class ReactLoopAgent implements Agent {
* the `disposed` transition fires and leave the promise hanging.
*/
private idleWaiters: (() => void)[] = []
/** Immutable scheduler cap resolved by the owning AgentLoop factory. */
/** Maximum parallel-safe calls allowed in one step. */
private readonly maxParallelToolCalls: number
/**
* Durability checkpoints started by idle {@link inject} calls. `inject()` is

View File

@@ -1,15 +1,6 @@
/**
* Loop-level tunable defaults shared between the plugin entry (`index.ts`) and
* the tool-call scheduler (`tool-calls.ts`). Kept in a leaf module so importing
* a default never pulls in the service class or the scheduler.
*
/** Shared agent-loop scheduler defaults.
* @module dsh-agent-loop/constants
*/
/**
* Default cap on simultaneously in-flight tool calls within one assistant step
* when the agent-loop config omits one. Matches the rolling-pool size Claude
* Code uses; a larger group is not truncated — the cap limits concurrency, not
* the group.
*/
/** Default maximum in-flight parallel-safe calls per agent step. */
export const DEFAULT_MAX_PARALLEL_TOOL_CALLS = 10

View File

@@ -333,9 +333,8 @@ export { DEFAULT_MAX_PARALLEL_TOOL_CALLS }
/** Agent-loop plugin configuration. */
export interface Config {
/**
* Concurrent parallel-safe tool-call cap shared by every agent this factory
* creates. A positive integer; `1` preserves fully serial execution and an
* omitted value defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}.
* Maximum parallel-safe calls in flight per agent step. `1` is serial;
* omission defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}.
*/
maxParallelToolCalls?: number
/** Agents created or resumed at plugin startup. */
@@ -355,7 +354,6 @@ export class AgentLoop extends Service implements AgentFactory {
/** Runtime schema for declarative agents. */
static Config = z.object({
// The deployment-wide cap is defaulted and validated at plugin load.
maxParallelToolCalls: z.number().step(1).min(1).default(DEFAULT_MAX_PARALLEL_TOOL_CALLS),
agents: z.array(z.object({
id: z.string().required(),
@@ -367,7 +365,7 @@ export class AgentLoop extends Service implements AgentFactory {
}) as unknown as z<Config>
private readonly ownership: FactoryOwnership
/** Resolved immutable scheduler cap shared by every driver from this factory. */
/** Resolved concurrency cap for every driver created by this factory. */
private readonly maxParallelToolCalls: number
/** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */
private readonly runtime: { ctx: Context }

View File

@@ -74,7 +74,7 @@ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
export interface LoopHandle {
/** Native-private agent inbox handed to the driver only at internal startup. */
readonly inbox: Inbox
/** Immutable concurrent tool-call cap resolved by the owning factory. */
/** Maximum parallel-safe calls allowed in one step. */
readonly maxParallelToolCalls: number
setStatus(status: 'idle' | 'running'): void
setAbort(controller: AbortController | undefined): void
@@ -558,13 +558,12 @@ async function runStep(
// Empty messages exist only to carry usage; the helper also omits empty chunk provenance.
recordAssistantMessage(session, turn, step, header.config, assembledContent, message, assembler, chunkSeqs)
// The scheduler overlaps only dispatch/body for parallel-safe calls; policy,
// results, and additional context remain in model order.
// Dispatch may overlap; policy, results, and context remain model-ordered.
const pendingContext = toolCalls.length > 0
? await executeToolCalls(ctx, agent, turn, step, toolCalls, signal, maxParallelToolCalls)
: []
// Append context after the complete result batch to preserve call/result adjacency.
// Context follows the complete result batch to preserve call/result adjacency.
for (const context of pendingContext) {
agent.inject(context.content, {
source: context.source,

View File

@@ -1,22 +1,11 @@
/**
* The agent loop's per-step tool-call scheduler. `runStep` (loop.ts) hands it
* the assistant message's `tool-call` blocks; this module parses each call's
* arguments once, classifies pending calls via `ctx.tools.executionMode`, and
* runs ordered groups through a rolling pool bounded by the agent-loop's
* `maxParallelToolCalls` config. Exclusive calls are singleton barriers. A
* parallel group reclassifies each later call before it starts, so registry
* changes during an earlier barrier or ordered result commit take effect before
* the pool replenishes.
*
* The session log stays the source of truth and is reconstructable regardless
* of dispatch timing: each STARTED call appends its own `tool/call` before its
* body runs, `tool/result` events are appended in MODEL order (slot-buffered
* behind a commit cursor), and buffered `additionalContexts` are injected in model
* call order after every result. A `tool/call`'s log position may interleave
* with a sibling's `tool/result` as the pool replenishes; that is safe because
* `tool/call` is log-only and derived history pairs the assistant message's
* `tool-call` blocks with the ordered `tool/result`s by `callId`.
* Schedules one assistant step's tool calls. Exclusive calls form barriers;
* parallel calls use a bounded rolling pool and are reclassified before start.
* Dispatch may overlap, while policy, results, and context remain model-ordered.
* Abort stops replenishment and drains started calls.
*
* Each started call records `tool/call`; `tool/result` commits in model order,
* preserving derived history when audit events interleave with earlier results.
* @module dsh-agent-loop/tool-calls
*/
@@ -29,40 +18,30 @@ import type { ReactLoopAgent } from './agent.ts'
/** One tool call after argument parsing, ready to schedule. */
interface PlannedCall {
/** The model-transcript call (authoritative `id`/`name`/raw `arguments`). */
block: ToolCallBlock
/** The distinct per-call execution input handed to the tool pipeline. */
exec: ToolExecutionInput
}
/** A settled call's slot, filled in model order before ordered finalization. */
/** Settled dispatch awaiting model-order finalization. */
interface Slot {
/** The registry-minted execution object, carrying this call's token. */
exec: ToolRunContext
/** The raw dispatch/pre result. */
result: ToolExecutionResult
/** Whether the result still needs ordered `tools/post-execute` finalization. */
needsPost: boolean
}
/**
* Execute one assistant step's tool calls, honoring per-call concurrency safety.
* Schedule one assistant step's tool calls by their live concurrency mode.
* Started calls receive ordered results; abort drains them, discards their
* buffered context, and rethrows so the turn owns final error handling.
*
* Appends `tool/call` (per started call) and `tool/result` (in model order) to
* the session, and returns the ordered `additionalContexts` buffer for the loop
* to inject after the batch. On abort it drains only already-started calls to
* results, drops buffered context, and throws the abort error so `runTurn` owns
* the turn-end reason.
*
* @param ctx - the loop context (reaches `ctx.tools`).
* @param agent - the agent being driven (owns the session, options, and is
* passed to each `ToolExecution`).
* @param turn - the current turn number (for the session events).
* @param step - the current step number (for the session events).
* @param toolCalls - the assistant message's `tool-call` blocks, in model order.
* @param signal - the step's abort signal (shared by every call).
* @param maxParallel - the already-validated cap snapshot for parallel groups.
* @returns the per-step `additionalContexts` buffer in model call order.
* @param ctx - loop context that owns the tool registry.
* @param agent - agent and session receiving the call lifecycle.
* @param turn - current turn number.
* @param step - current step number.
* @param toolCalls - assistant calls in model order.
* @param signal - abort signal shared by the step.
* @param maxParallel - validated in-flight cap.
* @returns buffered contexts in model call order.
*/
export async function executeToolCalls(
ctx: Context,
@@ -75,10 +54,7 @@ export async function executeToolCalls(
): Promise<HookContext[]> {
const { session } = agent
// Plan: parse each call's raw JSON arguments exactly once, and build one
// distinct ToolExecution per call so a `tools/execute` wrapper that mutates
// `exec` in place (e.g. replacing exec.signal with a per-call deadline) cannot
// race through a shared payload.
// Inputs are distinct because tools/execute wrappers may replace `exec.signal`.
const planned: PlannedCall[] = toolCalls.map(block => ({
block,
exec: {
@@ -93,9 +69,7 @@ export async function executeToolCalls(
const pendingContext: HookContext[] = []
let next = 0
while (next < planned.length) {
// Classify the next group only after the previous one has fully committed.
// A registry mutation in an exclusive call or result observer therefore
// changes how every not-yet-started call is scheduled.
// Commit before classifying again so registry changes affect unstarted calls.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
const first = planned[next]!
const mode = ctx.tools.executionMode(first.exec).kind
@@ -105,7 +79,7 @@ export async function executeToolCalls(
return pendingContext
}
/** Parse a model-produced raw arguments string, falling back to the raw string on invalid JSON (empty ⇒ `{}`). */
/** Parse model arguments, preserving invalid JSON as text and mapping empty input to `{}`. */
function parseArguments(raw: string): unknown {
try {
return raw ? JSON.parse(raw) : {}
@@ -115,18 +89,11 @@ function parseArguments(raw: string): unknown {
}
/**
* The rolling-pool path for one ordered group. A singleton exclusive group runs
* as a pool of one (a barrier). A parallel-safe run starts calls in model order
* up to `maxParallel`; before each later call starts, the scheduler reclassifies
* it against the live registry. An exclusive result stops replenishment, drains
* the current run, and remains for the caller's next singleton group. Settled
* dispatches land in model-order slots; a commit cursor appends `tool/result`
* (and collects `additionalContexts`) only while the next slot is ready, so the
* log stays model-ordered regardless of completion order.
*
* Abort: an already-aborted signal starts nothing and throws before any
* `tool/call`. An abort mid-group stops replenishment, awaits only the started
* calls, commits their results in order, drops buffered context, and throws.
* Run one exclusive barrier or parallel pool. Later calls are reclassified
* before start; an exclusive reclassification waits for the current pool to
* drain and remains for the caller's next barrier. Results and contexts commit
* in model order. Abort stops starts, drains and commits started calls, discards
* their contexts, and throws.
*/
async function runGroup(
ctx: Context,
@@ -142,17 +109,14 @@ async function runGroup(
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
const slots: (Slot | undefined)[] = group.map(() => undefined)
// callSeqs[i] is the `tool/call` event seq for started slot i (its provenance
// for the matching tool/result). A slot is only committed after it is started,
// so its callSeq is always set by then.
// Started slots retain their tool/call seq for result provenance.
const callSeqs: number[] = group.map(() => -1)
let nextToStart = 0
let committed = 0
let started = 0
let aborted: boolean = signal.aborted
// Advance the commit cursor over contiguous settled slots: run post-execute in
// model order, append each tool/result, and collect its additionalContexts.
// `committed` advances only across contiguous model-order slots.
const commitReady = async (): Promise<void> => {
while (committed < group.length) {
const slot = slots[committed]
@@ -161,7 +125,6 @@ async function runGroup(
const result = slot.needsPost
? await ctx.tools[TOOL_REGISTRY_SCHEDULER].finalize(slot.exec, slot.result)
: ctx.tools[TOOL_REGISTRY_SCHEDULER].finish(slot.exec, slot.result)
// committed < group.length, so call and its callSeq (set at start) exist.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index
appendToolResult(session, turn, step, call!.block, result, callSeqs[committed]!)
pendingContext.push(...result.additionalContexts ?? [])
@@ -172,7 +135,6 @@ async function runGroup(
const inFlight = new Map<number, Promise<number>>()
const startCall = async (index: number): Promise<void> => {
// index is always < group.length (bounded by every caller).
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index
const call = group[index]!
callSeqs[index] = appendToolCall(session, turn, step, call.block)
@@ -201,9 +163,7 @@ async function runGroup(
const fillPool = async (): Promise<void> => {
while (!aborted && nextToStart < group.length && inFlight.size < maxParallel) {
// The caller classified the first item immediately before entering this
// group. Re-read every later item after ordered commits so a live registry
// change can turn it into the next barrier.
// Re-read later modes after ordered commits so registry changes can create a barrier.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
const nextCall = group[nextToStart]!
if (nextToStart > 0 && mode === 'parallel'
@@ -211,49 +171,40 @@ async function runGroup(
await startCall(nextToStart)
nextToStart++
await commitReady()
// The signal CAN flip while an ordered pre-execute listener is running.
// Abort may arrive while pre-execute awaits.
if (signal.aborted) aborted = true
}
}
// Prime the pool up to the cap. Ordered pre-execute listeners may be async;
// dispatch/body is the only stage that overlaps across in-flight calls.
// Ordered pre-execute may await; only dispatch/body overlaps.
await fillPool()
while (inFlight.size > 0) {
const settledIndex = await Promise.race(inFlight.values())
inFlight.delete(settledIndex)
// Commit every contiguous settled slot now available.
await commitReady()
// The signal CAN flip during the await above (abort() inside a tool); the
// analyzer can't see through the await boundary. An abort stops the pool
// from starting any further calls, but already-started calls still drain.
// Abort may arrive while a tool or ordered commit awaits.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (signal.aborted) aborted = true
await fillPool()
}
if (aborted) {
// Every started call has settled and committed in order; buffered context
// from this aborted step is dropped (not injected). Raise the abort so the
// existing runTurn catch owns turn/end reason selection. Unstarted calls
// beyond the cap never appended a tool/call.
// Started calls are committed; their context is discarded with the aborted step.
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
throw new Error(String(signal.reason ?? 'aborted'))
}
// A defensive check that every started call committed before this group
// returns; a reclassified barrier may leave the rest of `group` unstarted.
/* v8 ignore next -- unreachable: a non-aborted group commits every started call */
if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls')
return started
}
/** Append the `tool/call` audit event for one started call; returns its seq (the tool/result's provenance). */
/** Append a started call and return its provenance sequence. */
function appendToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): number {
const event = session.append('tool/call', { turn, step, callId: block.id, name: block.name, arguments: block.arguments })
return event.seq
}
/** Append one call's `tool/result`, keyed by the authoritative model-transcript call id and provenanced to its `tool/call`. */
/** Append a model-ordered result linked to its call event. */
function appendToolResult(
session: Session,
turn: number,

View File

@@ -1,13 +1,6 @@
/**
* The per-step tool-call scheduler (`tool-calls.ts`): live classification by
* `ctx.tools.executionMode`, the rolling pool for parallel groups, model-order
* `tool/result` commit despite out-of-order settlement, registry-change
* reclassification, interleaved `tool/call` audit records, ordered
* `tools/pre-execute`/`tools/post-execute`, model-ordered `additionalContexts`,
* and abort behavior.
*
* Tools are mocked and deterministic — no real API, no snapshot here (the
* transcript-facing live-order behavior is pinned by the ACP snapshot goldens).
* Exercises scheduler ordering and cancellation with deterministic gated tools.
* ACP goldens own transcript-facing coverage.
*/
import { describe, expect, it } from 'vitest'
@@ -48,7 +41,7 @@ function events(agent: ReactLoopAgent): SessionEvent[] {
return [...agent.session.events]
}
/** An assistant message with N tool-call blocks named `name` (ids c1..cN, arg = index). */
/** Build one assistant response containing the supplied tool calls. */
function multiCall(calls: { id: string; name: string; args: object }[]): StreamChunk[] {
const chunks: StreamChunk[] = []
calls.forEach((call, index) => {
@@ -82,18 +75,15 @@ function gatedTool(name: string, parallel: boolean) {
return {
tool,
started,
/** Release one in-flight call by its arg id (its `execute` resolves). */
release(id: string) { gates.get(id)?.(); gates.delete(id) },
pending() { return [...gates.keys()] },
}
}
/** A parallel-safe gated tool. */
function gatedParallelTool(name: string) {
return gatedTool(name, true)
}
/** An exclusive gated tool. */
function gatedExclusiveTool(name: string) {
return gatedTool(name, false)
}
@@ -116,7 +106,6 @@ describe('tool-call scheduler: grouping and barriers', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
// All three start before any is released — proof of concurrency.
await until(() => gated.started.length === 3)
expect(gated.started).toEqual(['1', '2', '3'])
gated.release('1'); gated.release('2'); gated.release('3')
@@ -124,9 +113,6 @@ describe('tool-call scheduler: grouping and barriers', () => {
})
it('an exclusive call between two parallel-safe calls forms a barrier (3 groups)', async () => {
// read A (safe), write A (exclusive), read A (safe) → the write must not
// overlap either read. The exclusive tool records whether a read was still
// in flight when it ran.
const order: string[] = []
const adapter = new MockAdapter([
multiCall([
@@ -150,7 +136,6 @@ describe('tool-call scheduler: grouping and barriers', () => {
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
// The write ran strictly between the two reads (barrier ordering).
expect(order).toEqual(['r-start-A1', 'r-end-A1', 'w-A2', 'r-start-A3', 'r-end-A3'])
})
@@ -243,8 +228,6 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme
agent.send([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 2)
// Release the SECOND call first; its result must NOT be committed until the
// first commits (the commit cursor holds it in a slot).
gated.release('2')
await new Promise(r => setTimeout(r, 5))
const beforeFirst = events(agent).filter(e => e.type === 'tool/result')
@@ -270,8 +253,6 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme
gated.release('2'); gated.release('1')
await waitForIdle(ctx, agent)
// deriveMessages pairs the assistant tool-call blocks with tool-result
// blocks by callId — model order, independent of log interleaving.
const messages = agent.session.deriveMessages()
const toolResults = messages.flatMap(m => m.content.filter(b => b.type === 'tool-result'))
expect(toolResults.map(b => b.toolCallId)).toEqual([CallId('c1'), CallId('c2')])
@@ -314,11 +295,9 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
// Only 2 start initially (the cap).
await until(() => gated.started.length === 2)
await new Promise(r => setTimeout(r, 5))
expect(gated.started).toEqual(['1', '2'])
// Releasing one starts the next in model order.
gated.release('1')
await until(() => gated.started.length === 3)
expect(gated.started).toEqual(['1', '2', '3'])
@@ -354,7 +333,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
await waitForIdle(ctx, agent)
})
it('applies the global Config cap to every agent created by the factory', async () => {
it('applies the configured cap to every factory-created agent', async () => {
const adapter = new MockAdapter([
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]),
textResponse('done'),
@@ -365,7 +344,6 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
// The global cap of 1 must serialize every agent from this factory.
await ctx.plugin(AgentLoop, { agents: [], maxParallelToolCalls: 1 })
ctx.llm.registerAdapter(['mock'], adapter)
const gated = gatedParallelTool('p')
@@ -400,8 +378,6 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
agent.send([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 3)
// Settle in reverse; post-execute (ordered by the commit cursor) still fires
// in model order because post runs on the commit path, not on dispatch.
gated.release('3'); gated.release('2'); gated.release('1')
await waitForIdle(ctx, agent)
@@ -427,7 +403,6 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
await waitForIdle(ctx, agent)
const log = events(agent)
// Both tool/results precede both context/messages, and context is model-ordered.
const contextTexts = log.filter(e => e.type === 'context/message')
.map(e => (e.data.content[0] as { text: string }).text)
expect(contextTexts).toEqual(['ctx-c1', 'ctx-c2'])
@@ -436,7 +411,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
expect(lastResult).toBeLessThan(firstContext)
})
it('keeps pre-produced deny/error results ordered without dispatching those calls', async () => {
it('orders pre-execute denials and errors without dispatching them', async () => {
const adapter = new MockAdapter([
multiCall([
{ id: 'c1', name: 'p', args: { id: '1' } },