diff --git a/docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md b/docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md index 0dd497f441..c8cd96622a 100644 --- a/docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md +++ b/docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md @@ -101,6 +101,8 @@ Snapshot coverage pins the transcript-facing ACP behavior for a multi-call step: Parallel execution can expose latent shared-state bugs in tools that declare themselves safe too broadly. The default is exclusive, the shipped declarations are conservative, and input-sensitive tools such as bash stay exclusive until their owning package proves a narrower classifier. +Tool registration changes are a scheduling boundary. A call classified against one tool definition can become unsafe if an earlier exclusive tool replaces that definition before dispatch, so registry-mutating tools stay exclusive and scheduler changes that cross such barriers must either reclassify against the live registry view or bind dispatch to the classified definition. + An around-dispatch plugin can also violate the contract even when the tool itself is safe. The scheduler limits that risk to `tools/execute`; shipped wrappers are per-call, and third-party wrappers with shared mutable state must serialize internally. Parallel groups change abort timing: a sibling call may have started in a case where the serial loop would not have reached it yet. The pool makes this explicit by logging only started calls, stopping replenishment on abort, draining those calls to results, and preventing later calls from starting. diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 2c9c38fb1f..fbc1722391 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -19,7 +19,7 @@ import type { TransmissionLog } from './request-log.ts' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' -import { executeToolCalls } from './tool-calls.ts' +import { executeToolCalls, resolveMaxParallelToolCalls } from './tool-calls.ts' import type { ReactLoopAgent } from './agent.ts' import type { Inbox } from './inbox.ts' @@ -858,6 +858,11 @@ async function runStep( // // sourceEventSeqs records the assistant/chunk provenance, but is omitted when // no chunks streamed (the surface invariant rejects an empty sourceEventSeqs). + const toolCalls = message.content.filter(block => block.type === 'tool-call') + const scheduling = toolCalls.length > 0 + ? { maxParallel: resolveMaxParallelToolCalls(agent.options.maxParallelToolCalls) } + : undefined + if (message.content.length > 0 || assembler.usage) { session.append( 'assistant/message', @@ -874,13 +879,14 @@ async function runStep( // ordered the same way. Tool failures (including aborts) become isError // results; the scheduler re-checks the shared signal around calls and throws // the abort so this step's caller ends the turn. - const toolCalls = message.content.filter(block => block.type === 'tool-call') // Per-step buffer of `additionalContext` attached by tools/post-execute // listeners. Appended as context/message(s) only AFTER every tool/result for // the step, so a multi-call step keeps tool-call/result adjacency // (interleaving context between a call's result and the next call's would // break the pairing the next model request relies on). - const pendingContext = await executeToolCalls(ctx, agent, turn, step, toolCalls, signal) + const pendingContext = scheduling !== undefined + ? await executeToolCalls(ctx, agent, turn, step, toolCalls, signal, scheduling.maxParallel) + : [] // Append buffered post-execute context AFTER every tool/result, preserving // tool-call/result adjacency across the whole batch. inject() appends into the diff --git a/packages/core/agent-loop/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts index ae6f44d4dc..6ee1dc5906 100644 --- a/packages/core/agent-loop/src/tool-calls.ts +++ b/packages/core/agent-loop/src/tool-calls.ts @@ -60,6 +60,7 @@ interface Slot { * @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 `additionalContext` buffer in model call order. */ export async function executeToolCalls( @@ -69,9 +70,9 @@ export async function executeToolCalls( step: number, toolCalls: ToolCallBlock[], signal: AbortSignal, + maxParallel: number, ): Promise { - const { session, options } = agent - const maxParallel = options.maxParallelToolCalls ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS + 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 @@ -108,6 +109,20 @@ export async function executeToolCalls( return pendingContext } +/** + * Resolve and validate the per-step parallel dispatch cap before the assistant + * tool-call message is logged, so invalid mutable options fail without leaving + * dangling model-visible tool calls in the session transcript. + * + * @param maxParallelToolCalls - the live agent option value. + * @returns the positive integer cap to use for this step. + */ +export function resolveMaxParallelToolCalls(maxParallelToolCalls: number | undefined): number { + const maxParallel = maxParallelToolCalls ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS + assertMaxParallelToolCalls(maxParallel) + return maxParallel +} + /** Parse a model-produced raw arguments string, falling back to the raw string on invalid JSON (empty ⇒ `{}`). */ function parseArguments(raw: string): unknown { try { diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index cd4e202b9b..987b358720 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -218,6 +218,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => expect(gated.started).toEqual([]) expect(adapter.requests).toHaveLength(1) + expect(events(agent).some(e => e.type === 'assistant/message')).toBe(false) expect(events(agent).filter(e => e.type === 'tool/call' || e.type === 'tool/result')).toEqual([]) const turnEnd = events(agent).findLast(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('error')