feat(agent-loop): run safe tool calls in parallel

This commit is contained in:
Dudu-0223
2026-07-13 11:02:21 +08:00
parent 4cda9dd03d
commit 7ea1bf119f
48 changed files with 1542 additions and 141 deletions

View File

@@ -34,10 +34,14 @@ sequenceDiagram
Session-->>SDK: <code>session/event</code> <code>assistant/chunk</code>*
Driver->>Hooks: <code>agent/step-result</code> waterfall
Driver->>Session: <code>assistant/message</code>
Driver->>Session: <code>tool/call</code>
Driver->>Tools: execute through pre and post waterfalls
Tools-->>Session: tool-owned events when applicable
Driver->>Session: <code>tool/result</code> and <code>step/end</code>
Driver->>Tools: group calls by executionMode
loop started tool calls (bounded pool)
Driver->>Session: <code>tool/call</code> pending audit
Driver->>Tools: ordered pre / pooled dispatch / ordered post
Tools-->>Session: tool-owned events when applicable
end
Driver->>Session: <code>tool/result</code> in model order
Driver->>Session: <code>step/end</code>
Driver->>Hooks: <code>agent/turn-continuation</code> waterfall
Driver->>Session: <code>turn/end</code>
Driver->>Persistence: <code>session/flush</code> parallel checkpoint

View File

@@ -81,11 +81,13 @@ forever:
'assistant/chunk'
agent/step-result
'assistant/message'
each tool call:
'tool/call'
tools/pre-execute -> tools/execute -> tools/post-execute
'tool/result'
append post-tool context and steering
schedule tool calls by ctx.tools.executionMode (exclusive = barrier;
consecutive parallel-safe = one rolling-pool group, <= maxParallelToolCalls in flight):
each started call:
'tool/call'
tools/pre-execute -> tools/execute -> tools/post-execute
'tool/result' committed in model order (slot-buffered)
append post-tool context (model order) and steering
'step/end'
agent/turn-continuation
stop unless tools or continuation policy ask for another step
@@ -97,6 +99,8 @@ Prompt assembly is single-path: `renderPrompt(assemble({ agent }))` IS the syste
Post-tool context lands after all tool results so tool-call/result adjacency stays stable. Steering drains between steps; leftover steering after a turn is re-queued as ordinary input.
Tool-call scheduling groups exclusive barriers and bounded parallel-safe runs while preserving ordered results ([the parallel tool-call RFC](rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md)).
### Failure Boundaries
The turn is the containment boundary. A throwing listener, adapter error finish, or failed step ends the current turn with an error reason and reports live diagnostics through `agent/error`; it does not kill the driver loop. `cancel()` clears queued and steering work, aborts the active model/tool boundary when possible, and records the appropriate turn end. Disposal stops the loop, awaits quiescence, unregisters the agent, and lets service disposers drain.

View File

@@ -124,6 +124,11 @@ export interface Config {
id: AgentId
/** Optional workspace cwd for the config-created fresh session. */
cwd?: string
/**
* Maximum parallel-safe tool calls to run concurrently within one assistant
* step. Must be a positive integer; `1` preserves serial execution.
*/
maxParallelToolCalls?: number
/**
* If set, the config agent RESUMES this persisted session id instead of
* starting a fresh `${id}-session-<uuid>`. Sourced from an env var in
@@ -144,7 +149,7 @@ export interface Config {
Depends on: [`AgentId`](../packages/core/agent/src/index.ts) · [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts)
Source: [`packages/core/agent-loop/src/index.ts:36`](../packages/core/agent-loop/src/index.ts)
Source: [`packages/core/agent-loop/src/index.ts:52`](../packages/core/agent-loop/src/index.ts)
## `@deepseek-ai/dsh-bash-local`
@@ -956,7 +961,7 @@ export interface Config {
export type ToolPresentationMode = 'native' | 'code' | 'both'
```
Source: [`packages/core/tools/src/index.ts:323`](../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:389`](../packages/core/tools/src/index.ts)
## `@deepseek-ai/dsh-user-approval`

View File

@@ -21,7 +21,7 @@ createAgent(options: CreateAgentOptions): AgentHandle
async resume(options: ResumeAgentOptions): Promise<AgentHandle>
```
Source: [`packages/core/agent-loop/src/index.ts:70`](../../packages/core/agent-loop/src/index.ts)
Source: [`packages/core/agent-loop/src/index.ts:91`](../../packages/core/agent-loop/src/index.ts)
## `ctx.agents` — `AgentRegistry`
@@ -266,12 +266,13 @@ Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop e
register(definition: ToolDefinition): () => void
get(name: string): ToolDefinition | undefined
schemas(): ToolSchema[]
executionMode(exec: ToolExecution): ToolExecutionMode
async execute(exec: ToolExecution): Promise<ToolExecutionResult>
```
Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:349`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:415`](../../packages/core/tools/src/index.ts)
## `ctx.userInteraction` — `UserInteractionService`

View File

@@ -6,7 +6,7 @@ Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index
## `ToolDefinition` — a registered tool
A `ToolSchema` (the model-facing fields) plus the `execute` function and optional UI presenters. The registry holds these; the loop dispatches calls through them. The registry's `schemas()` builds the model-facing `ToolSchema[]` by an explicit allowlist — `execute`/`presentCall`/`presentResult` must never leak into a model request.
A `ToolSchema` (the model-facing fields) plus the `execute` function, host-only scheduler metadata, and optional UI presenters. The registry holds these; the loop dispatches calls through them. The registry's `schemas()` builds the model-facing `ToolSchema[]` by an explicit allowlist — `execute`/`timeoutMs`/`isConcurrencySafe`/`presentCall`/`presentResult` must never leak into a model request.
```ts type-equiv
interface ToolDefinition extends ToolSchema {
@@ -19,6 +19,31 @@ interface ToolDefinition extends ToolSchema {
* cooperative implementation that can reach quiescence when the signal aborts.
*/
timeoutMs?: number
/**
* Optional synchronous, pure classification: may this call run concurrently
* with other tool calls in the same assistant step? The agent-loop scheduler
* calls it (via {@link ToolRegistry.executionMode}) to decide whether the call
* joins a parallel group or forms an exclusive barrier; a missing declaration,
* a thrown check, or any non-`true` return is treated as exclusive. Like
* `timeoutMs` it is host-only scheduler metadata — NEVER sent to the model,
* since `schemas()` whitelists only name/description/parameters.
*
* It may inspect the parsed `args` (`unknown` — a hand-rolled definition
* receives the raw parsed value; `defineTool` schema-validates first and
* returns `false` on invalid args, so an eventual `ToolArgsError` is produced
* only if the tool actually executes). The check performs no I/O and receives
* no live `Agent` or mutable `ToolExecution`.
*
* Declaring `true` is a contract: the tool body must NOT mutate the parent
* agent's session or other parent-owned async state during `execute` (no
* `exec.agent.session.append(...)`, no `agent.inject(...)`). Its only parent-
* step outputs are the returned content, `meta`, structured error, and
* `additionalContext` carried through the loop's ordered post-execute path.
* The narrow exception is a synchronous, side-effect-only recorder whose
* updates are commutative for concurrent calls by the same session (the
* `fs/observed` version recorder is the worked example).
*/
isConcurrencySafe?(args: unknown): boolean
/**
* Optional: how to present the PENDING state of one call in a UI, derived from
* the call's `args` (parsed arguments, `unknown` — the tool validates/narrows
@@ -97,6 +122,14 @@ interface ToolExecution {
}
```
The agent loop asks the registry for each pending call's execution mode and uses it to partition a step into exclusive barriers and rolling-pool parallel runs:
```ts type-equiv
type ToolExecutionMode =
| { kind: 'parallel' }
| { kind: 'exclusive' }
```
```ts type-equiv
interface ToolExecutionResult {
callId: CallId

View File

@@ -70,6 +70,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
| [The session prefix — request-only messages in front of the derived history](implemented/feature/2026-07-07-session-prefix.md) | 2026-07-07 |
| [Repeat-tool-call guard plugin](implemented/feature/2026-07-08-repeat-tool-guard.md) | 2026-07-08 |
| [The self-referential cordis toolset](implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 2026-07-08 |
| [Parallel tool-call execution by per-call safety](implemented/feature/2026-07-10-parallel-tool-call-execution.md) | 2026-07-10 |
### Simplification

View File

@@ -0,0 +1,110 @@
# RFC: Parallel tool-call execution by per-call safety
Status: implemented
## Problem
The loop accepts an assistant message containing multiple `tool-call` blocks. Serial execution makes independent reads, web requests, and subagent delegations pay the sum of their wall-clock latency even though the model and adapters already represent sibling tool calls in one response.
Concurrency cannot live in the model-facing JSON schema. `ctx.tools.schemas()` exposes only `name`, `description`, and `parameters`; scheduling is a host contract. The loop needs an internal per-call safety decision and must use it without hardcoding tool names.
The hard constraint is replay. The session log remains the source of truth: the assistant message contains the model's calls in order, each started call has a `tool/call` audit event before its body runs, each model-facing result is a `tool/result`, and derived history sees results in the original call order. Live ACP and stdio surfaces may show several pending calls before the first result; that progress interleaving is not part of the model-history guarantee.
## Decision
`ToolDefinition` carries an optional host-only classifier:
```text
export interface ToolDefinition extends ToolSchema {
execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn>
isConcurrencySafe?(args: unknown): boolean
}
```
`isConcurrencySafe` is synchronous, pure classification metadata. It may inspect parsed call arguments; `defineTool()` schema-validates those arguments before the typed callback runs, while hand-rolled definitions receive the raw parsed value. The callback performs no I/O and receives no live `Agent` or mutable `ToolExecution`. `defineTool()` validates arguments softly for `isConcurrencySafe`, matching the display-only `presentCall`/`presentResult` pattern: invalid args return `false`, and the ordinary `ToolArgsError` is produced only if the tool executes.
The registry exposes the scheduling decision as a plain method:
```text
export type ToolExecutionMode =
| { kind: 'parallel' }
| { kind: 'exclusive' }
```
```text
class ToolRegistry {
executionMode(exec: ToolExecution): ToolExecutionMode
}
```
`ctx.tools.executionMode(exec)` looks up the registered tool and calls `tool.isConcurrencySafe?.(exec.arguments)`. Unknown tools, missing declarations, malformed typed args, and thrown safety checks all resolve to `{ kind: 'exclusive' }`. The method is not a Cordis waterfall; it is the future insertion point if hook, MCP, or provider policy needs to downgrade a tool's baseline decision. The object-tagged union leaves room for future resource grouping, for example `{ kind: 'exclusive', group: 'session:...' }`.
A parallel-safe declaration is a contract. The tool body must not mutate the parent agent's session or other parent-owned async state during `execute`; parent-session writes such as `exec.agent.session.append(...)`, `agent.inject(...)`, or other tool-owned parent events belong to exclusive tools unless the mutation moves behind the loop's ordered result path. The only parent-step outputs a parallel-safe call may produce are its returned content, `meta`, structured error, and `additionalContext` carried through the ordered post-execute path. The narrow exception is a synchronous, side-effect-only recorder whose updates are commutative for concurrent calls by the same session. `fs/observed` is the worked example: `read` emits it synchronously after a successful read, `dsh-fs-policy` records `WeakMap<session, target, version>` state synchronously, same-target reads converge to an observed version, and write/edit remain exclusive barriers that re-check versions before mutating.
## Scheduling
The loop waits for the model stream to finish and logs one authoritative `assistant/message` before scheduling tools. Streaming tool execution is out of scope.
For each assistant step, `packages/core/agent-loop/src/tool-calls.ts` parses each call's raw JSON arguments exactly once, creates one distinct `ToolExecution` object per call, asks `ctx.tools.executionMode(exec)`, and partitions calls into ordered groups. A group is either one exclusive call or a run of consecutive parallel calls. `loop.ts` calls the helper so the turn/step lifecycle remains readable.
Parallelism is per agent. `AgentOptions.maxParallelToolCalls` is a positive integer, defaults to `DEFAULT_MAX_PARALLEL_TOOL_CALLS` (`10`), and is accepted through the config-created agent path. Setting it to `1` preserves serial execution for that agent. Both the TypeScript `AgentOptions` vocabulary and the `AgentLoop.Config` schemastery object validate the cap, so invalid `cordis.yml` values fail during config validation.
Within a parallel group, execution uses a rolling pool: start calls in model order up to `maxParallelToolCalls`, and whenever one call settles, start the next unstarted call until the group is exhausted. A group larger than the cap is not truncated; the cap limits simultaneous in-flight calls only.
Only the dispatch/body stage runs concurrently. Generic middleware that can shape ordering-sensitive state remains ordered: `tools/pre-execute` and `tools/post-execute` run in model call order. `@deepseek-ai/dsh-tools` exposes the symbol-keyed internal `TOOL_REGISTRY_SCHEDULER` view so `dsh-agent-loop` can split prepare, dispatch, and finalize without adding named staged service methods to `ctx.tools`; ordinary callers still use the one-call `execute(exec)` API. `tools/execute` around-dispatch listeners run with the dispatch they wrap, so wrappers must be reentrant across distinct `ToolExecution` objects. The shipped timeout policy is per-call: every call owns its mutable `exec` and deadline.
Each started call appends its own `tool/call` immediately before its pre-execute gate and body can run. `tool/call` events remain in model order relative to started calls, but their log positions may interleave with sibling results: a later call's `tool/call` can appear before or after an earlier call's `tool/result` as the rolling pool replenishes. That is safe because `tool/call` is log-only; derived model history reads the assistant's `tool-call` blocks and the ordered `tool/result` events, pairing by `callId`. Settled dispatches are stored in model-order slots, and a commit cursor appends `tool/result` only while the next slot is ready. `additionalContext` is collected from those same slots and injected in model call order after normal completion of every started tool result in the step.
If the parent signal is already aborted before a group starts, the group is not started and no `tool/call` audit records are appended for it. If the signal aborts while a parallel group is running, the pool stops replenishing, waits for only the already-started calls to settle, records their results in order, drops buffered `additionalContext`, and then raises the abort error so the existing `runTurn` catch path owns `turn/end` reason selection. This keeps every started call paired while avoiding audit records for calls that never began.
Code Mode remains outside native scheduling. In `mode: 'code'`, the wire exposes only `run_code`, so the model emits one native tool call and the loop-level scheduler has nothing to parallelize. `run_code` stays exclusive, and its in-program dispatch queue remains serialized. In `mode: 'both'`, native sibling tool calls can form parallel groups normally, while calls made inside one `run_code` execution still follow Code Mode's own queue.
## Tool declarations
The shipped declarations are conservative:
- `web_search`, `web_fetch`, filesystem `read`, and `subagent` return `true`.
- Filesystem `write`, filesystem `edit`, `todo_write`, `bash`, `bash_output`, `bash_kill`, `workflow`, `ask_user_question`, and Cordis mutation tools stay exclusive by omitting `isConcurrencySafe`.
- Bash stays exclusive until a bash-owned read-only classifier exists; the loop never infers shell safety.
Subagent providers do not get an extra opt-in field. `SubagentProvider.start()` is part of the provider contract and must be safe to call concurrently for independent runs. A provider backed by a limited resource may queue internally, apply its own capacity limit, or return a typed failure for the affected run, but it must not require the parent agent loop to serialize every `subagent` tool call. Built-in spawn, fork, and ACP runs own a child session or process; fork seeds only the parent's completed-turn prefix, so concurrent forks inside the parent's open step all see the same stable prefix.
Exclusive tools naturally form ordering barriers. A step such as `[read A, write A, read A]` becomes three ordered groups because `write` is exclusive, so the scheduler does not introduce a read/write race inside one assistant step.
The subagent tool remains synchronous. Multiple subagent tool calls in one assistant message can run concurrently, but each tool result is still the child final answer. Background spawning plus later collection would be a separate tool vocabulary.
## Testing
Unit tests cover the classifier (`ToolDefinition.isConcurrencySafe`, `defineTool()` soft validation, `ToolRegistry.executionMode`, and schema projection), the loop scheduler (grouping, exclusive barriers, rolling-pool replenishment, `maxParallelToolCalls: 1`, distinct `ToolExecution` objects, ordered pre/post middleware, ordered `tool/result`, concrete `tool/call`/`tool/result` interleaving, ordered `additionalContext`, and abort/drop-context cases), and first-party safe declarations for filesystem read, web tools, and subagent.
Snapshot coverage pins the transcript-facing ACP behavior for a multi-call step: several pending tool-call updates may precede model-ordered result updates. Code Mode tests and docs pin that `run_code` remains exclusive and that in-program dispatch stays serialized. No real-API e2e is required for this decision because scheduling is deterministic loop behavior with mocked tools and replayable snapshots, not provider-specific behavior.
## Alternatives considered
**Keep serial execution.** This keeps the loop simple and avoids new abort ordering cases, but it leaves obvious latency on the table for independent reads, web calls, and subagent delegations. The model and adapters already represent multiple tool calls in one assistant message, so serial execution is a host limitation rather than a protocol limitation.
**Codex-style tool-level `supportsParallelToolCalls`.** A tool-level boolean is smaller, but it cannot express that the same tool is safe for some inputs and unsafe for others. Bash is the key example: a read-only command classifier can make `pwd` or `ls` parallel-safe without making `rm` or a long-lived background-task operation parallel-safe.
**Parallelize the complete `ctx.tools.execute()` pipeline.** This preserves the existing one-call API in the loop, but it also runs `tools/pre-execute` and `tools/post-execute` concurrently. The shipped repeat-tool guard and hook bridges can carry ordering-sensitive state, so the shipped design keeps pre/post ordered and overlaps only dispatch/body work.
**Expose a public staged API such as `prepare` / `dispatch` / `finalize`.** That names too much implementation surface before another consumer exists. The loop needs staged behavior, but `ToolRegistry` factors it through a symbol-keyed internal view while keeping `execute(exec)` as the public one-call API for ordinary callers.
**Add a `tools/execution-mode` waterfall.** A Cordis seam would let hook bridges, provider policies, or MCP server metadata downgrade a tool's declaration. It is not needed for the conservative declaration set: raw and undeclared tools default exclusive, pre/post middleware stays ordered, and a non-reentrant around-dispatch wrapper can serialize internally. The `executionMode(exec)` method remains the insertion point if a real deployment needs policy-driven downgrades.
**Start tools while the model is still streaming.** Claude Code has a streaming executor path, but this repo's log reconstruction and surface-pairing contracts make that a larger design. This decision waits for the assistant message to be assembled, so the log records one authoritative assistant message before scheduling tools.
**Use fixed windows inside one parallel group.** Fixed windows would start `maxParallelToolCalls` calls, wait for all of them to settle, then start the next window. The rolling pool wins because slot-based result storage and a model-order commit cursor preserve the transcript contract without sacrificing avoidable latency.
**Expose concurrency in the model-facing schema.** The model does not need a scheduler flag to request multiple calls; it already can emit multiple `tool-call` blocks. Sending host-only concurrency metadata would bloat requests and mix execution policy into the schema whose job is only argument shape and tool-choice guidance.
## Consequences
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.
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.
Concurrent subagents can compete for model quota, filesystem state, or external process resources. The provider contract requires concurrent `start()` safety, not unlimited capacity, and tool guidance still tells the model to parallelize only independent tasks with non-overlapping write scopes.
The result-order rule can delay a fast result behind a slow sibling in the same group. That preserves the model transcript and replay contract. ACP and stdio still expose immediate pending-call progress, but completion updates stay model-ordered.

View File

@@ -398,7 +398,7 @@ Source: [`packages/skill/tool-skill/src/index.ts`](../packages/skill/tool-skill/
### `subagent`
Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.
Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. You may issue several subagent calls in one message to run independent tasks concurrently when their work scopes do not overlap.
```json
{