From 7ea1bf119f76455bdb084a380a9c5738877244a8 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 13 Jul 2026 11:02:21 +0800 Subject: [PATCH 01/14] feat(agent-loop): run safe tool calls in parallel --- docs/agent-lifecycle.md | 12 +- docs/architecture.md | 14 +- docs/config-catalog.md | 9 +- docs/cordis-catalog/services.md | 7 +- docs/core-data-structures/tools.md | 35 +- docs/rfc/INDEX.md | 1 + ...2026-07-10-parallel-tool-call-execution.md | 110 +++++ docs/tool-catalog.md | 2 +- examples/acp-agent/tests/acp.snapshot.ts | 1 + .../snapshots/both-mode-turn/session.jsonl | 2 +- .../both-mode-turn/system-prompt.golden.md | 4 +- .../code-mode-turn/system-prompt.golden.md | 4 +- .../snapshots/parallel-tool-calls/input.json | 7 + .../parallel-tool-calls/session.jsonl | 28 ++ .../parallel-tool-calls/stdout.golden.jsonl | 8 + .../parallel-tool-calls/workspace/a.txt | 1 + .../parallel-tool-calls/workspace/b.txt | 1 + .../tests/snapshots/skill-load/session.jsonl | 2 +- .../tests/snapshots/text-turn/session.jsonl | 2 +- .../escalation-approved/session.jsonl | 4 +- .../escalation-rejected/session.jsonl | 4 +- packages/bash/tool-bash/src/index.ts | 6 + .../cordis/tool-cordis/src/api-catalog.ts | 7 +- packages/core/agent-loop/README.md | 19 +- packages/core/agent-loop/src/constants.ts | 15 + packages/core/agent-loop/src/index.ts | 36 ++ packages/core/agent-loop/src/loop.ts | 78 +-- packages/core/agent-loop/src/tool-calls.ts | 326 ++++++++++++ .../core/agent-loop/tests/tool-calls.spec.ts | 462 ++++++++++++++++++ packages/core/tools/README.md | 16 +- packages/core/tools/src/index.ts | 216 ++++++-- packages/core/tools/src/schema.ts | 28 +- .../core/tools/tests/execution-mode.spec.ts | 133 +++++ packages/fs/tool-fs/README.md | 2 + packages/fs/tool-fs/src/read.ts | 5 + packages/fs/tool-fs/tests/tools.spec.ts | 10 + packages/subagent/README.md | 2 + packages/subagent/subagent/src/types.ts | 13 + packages/subagent/tool-subagent/README.md | 4 + packages/subagent/tool-subagent/src/index.ts | 12 +- .../tool-subagent/tests/tool-subagent.spec.ts | 9 + packages/web/tool-web/README.md | 2 + packages/web/tool-web/src/fetch.ts | 3 + packages/web/tool-web/src/search.ts | 3 + packages/web/tool-web/tests/tool-web.spec.ts | 4 + scripts/gen-cordis-catalog.ts | 1 + scripts/gen-doc-graphs.ts | 12 +- scripts/type-equiv.manifest.json | 1 + 48 files changed, 1542 insertions(+), 141 deletions(-) create mode 100644 docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md create mode 100644 examples/acp-agent/tests/snapshots/parallel-tool-calls/input.json create mode 100644 examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl create mode 100644 examples/acp-agent/tests/snapshots/parallel-tool-calls/workspace/a.txt create mode 100644 examples/acp-agent/tests/snapshots/parallel-tool-calls/workspace/b.txt create mode 100644 packages/core/agent-loop/src/constants.ts create mode 100644 packages/core/agent-loop/src/tool-calls.ts create mode 100644 packages/core/agent-loop/tests/tool-calls.spec.ts create mode 100644 packages/core/tools/tests/execution-mode.spec.ts diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index d6d9ab1ba7..05e8aea2d0 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -34,10 +34,14 @@ sequenceDiagram Session-->>SDK: session/event assistant/chunk* Driver->>Hooks: agent/step-result waterfall Driver->>Session: assistant/message - Driver->>Session: tool/call - Driver->>Tools: execute through pre and post waterfalls - Tools-->>Session: tool-owned events when applicable - Driver->>Session: tool/result and step/end + Driver->>Tools: group calls by executionMode + loop started tool calls (bounded pool) + Driver->>Session: tool/call pending audit + Driver->>Tools: ordered pre / pooled dispatch / ordered post + Tools-->>Session: tool-owned events when applicable + end + Driver->>Session: tool/result in model order + Driver->>Session: step/end Driver->>Hooks: agent/turn-continuation waterfall Driver->>Session: turn/end Driver->>Persistence: session/flush parallel checkpoint diff --git a/docs/architecture.md b/docs/architecture.md index c1755dc815..2752f35fec 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 75ca4a67d1..ebc4efde33 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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-`. 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` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index bd01d39792..75fe128826 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -21,7 +21,7 @@ createAgent(options: CreateAgentOptions): AgentHandle async resume(options: ResumeAgentOptions): Promise ``` -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 ``` -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` diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 96d3e79bdc..324af67289 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -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 diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 8dc3b93b47..4b2c63b1c4 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -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 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 new file mode 100644 index 0000000000..e75dcd0a81 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md @@ -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 + 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` 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. diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 7713c1f278..e76a808b18 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -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 { diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index eaa4e0381a..ed01a613ae 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -49,6 +49,7 @@ const SCENARIOS: Scenario[] = [ // Its system-prompt.golden.md and JSONL tool list pin the composed header. { name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true }, { name: 'tool-call-turn', hasModelTurn: true, recorded: true }, + { name: 'parallel-tool-calls', hasModelTurn: true, recorded: false }, { name: 'fs-terminal-card', hasModelTurn: true, recorded: true }, { name: 'todo-plan', hasModelTurn: true, recorded: true }, { name: 'skill-load', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'skill' }, diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl index b49097188e..39d381f16a 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783611774323,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783611774323,"data":{"content":[{"type":"text","text":"Call the run_code tool (NOT the native bash tool directly) with a program that runs exactly `echo BOTH_OK` via tools.bash and returns its output. Then reply with that output only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783611774324,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783611774325,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"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.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783611774325,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"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.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. You may issue several subagent calls in one message to run independent tasks concurrently when their work scopes do not overlap.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783611774792,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":5,"time":1783611774792,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} {"type":"assistant/chunk","seq":6,"time":1783611774879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md index 5dd8547aa8..47910b3214 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md @@ -76,14 +76,14 @@ declare const tools: { /** The exact skill name from the available skills list. */ name: string; }): Promise; - /** 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. */ subagent(args: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; }): Promise; - /** Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */ + /** Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. You may issue several subagent calls in one message to run independent tasks concurrently when their work scopes do not overlap. */ subagent_fork(args: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md index 5dd8547aa8..47910b3214 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md @@ -76,14 +76,14 @@ declare const tools: { /** The exact skill name from the available skills list. */ name: string; }): Promise; - /** 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. */ subagent(args: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; }): Promise; - /** Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */ + /** Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. You may issue several subagent calls in one message to run independent tasks concurrently when their work scopes do not overlap. */ subagent_fork(args: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/input.json b/examples/acp-agent/tests/snapshots/parallel-tool-calls/input.json new file mode 100644 index 0000000000..e5356e4af5 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl b/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl new file mode 100644 index 0000000000..7d7e79974e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl @@ -0,0 +1,28 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_read_a","name":"read","argumentsDelta":"{\"file_path\":\"a.txt\"}"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_read_b","name":"read","argumentsDelta":"{\"file_path\":\"b.txt\"}"}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":12,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"},{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}],"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8,9,10,11],"surfaceOp":"append"} +{"type":"tool/call","seq":13,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}} +{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}} +{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_a","content":[{"type":"text","text":"{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_b","content":[{"type":"text","text":"{{cwd}}/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":24,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"usage":{"inputTokens":10,"outputTokens":1}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":26,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl new file mode 100644 index 0000000000..91ca67746f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl @@ -0,0 +1,8 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_a","title":"Read a.txt","kind":"read","status":"in_progress","locations":[{"path":"a.txt","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_b","title":"Read b.txt","kind":"read","status":"in_progress","locations":[{"path":"b.txt","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_read_a","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_read_b","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/workspace/a.txt b/examples/acp-agent/tests/snapshots/parallel-tool-calls/workspace/a.txt new file mode 100644 index 0000000000..4a58007052 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/workspace/a.txt @@ -0,0 +1 @@ +alpha diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/workspace/b.txt b/examples/acp-agent/tests/snapshots/parallel-tool-calls/workspace/b.txt new file mode 100644 index 0000000000..65b2df87f7 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/workspace/b.txt @@ -0,0 +1 @@ +beta diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index 645671709e..fde4a1f941 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783654655602,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783654655603,"data":{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783654655608,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783654655608,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"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.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}],"messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}]}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783654655608,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"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.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. You may issue several subagent calls in one message to run independent tasks concurrently when their work scopes do not overlap.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}],"messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}]}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":5,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Load the requested skill."}}} {"type":"assistant/chunk","seq":6,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl index 324ef8d4eb..86240f9fe0 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783600629542,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783600629542,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"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.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783600629542,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"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.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. You may issue several subagent calls in one message to run independent tasks concurrently when their work scopes do not overlap.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783600630819,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":5,"time":1783600630820,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} {"type":"assistant/chunk","seq":6,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} diff --git a/examples/sandbox-acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/sandbox-acp-agent/tests/snapshots/escalation-approved/session.jsonl index 2ac9d27044..fef0ca3597 100644 --- a/examples/sandbox-acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/sandbox-acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -114,8 +114,8 @@ {"type":"assistant/chunk","seq":112,"time":1783486771232,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":113,"time":1783486771236,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to retry the command with sandbox_permissions set to workspace-write. They explicitly said they will approve the permission prompt. Let me proceed."},{"type":"tool-call","id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > escalated.txt && cat escalated.txt\", \"description\": \"Write escalated.txt and cat its content\", \"sandbox_permissions\": \"workspace-write\", \"justification\": \"the user asked to write escalated.txt in the workspace\"}"}],"usage":{"inputTokens":1255,"outputTokens":160,"cacheReadTokens":0,"reasoningTokens":34}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112],"surfaceOp":"append"} {"type":"tool/call","seq":114,"time":1783486771236,"data":{"turn":1,"step":1,"callId":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > escalated.txt && cat escalated.txt\", \"description\": \"Write escalated.txt and cat its content\", \"sandbox_permissions\": \"workspace-write\", \"justification\": \"the user asked to write escalated.txt in the workspace\"}"}} -{"type":"approval/asked","seq":115,"time":1783486771238,"data":{"id":"3ec45405-5add-4929-a755-e8c077ec7a7e","toolName":"bash","callId":"call_00_ZSEIrZNdgQhL2QJgVHww8689","reason":"escalate sandbox to workspace-write: the user asked to write escalated.txt in the workspace"}} -{"type":"approval/decided","seq":116,"time":1783486771243,"data":{"id":"3ec45405-5add-4929-a755-e8c077ec7a7e","outcome":"allowed-once"}} +{"type":"approval/asked","seq":115,"time":1783486771238,"data":{"id":"6c836429-f953-4505-8ead-2c70db64f4f8","toolName":"bash","callId":"call_00_ZSEIrZNdgQhL2QJgVHww8689","reason":"escalate sandbox to workspace-write: the user asked to write escalated.txt in the workspace"}} +{"type":"approval/decided","seq":116,"time":1783486771243,"data":{"id":"6c836429-f953-4505-8ead-2c70db64f4f8","outcome":"allowed-once"}} {"type":"tool/result","seq":117,"time":1783486771442,"data":{"turn":1,"step":1,"callId":"call_00_ZSEIrZNdgQhL2QJgVHww8689","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[114],"surfaceOp":"append"} {"type":"step/end","seq":118,"time":1783486771443,"data":{"turn":1,"step":1}} {"type":"step/start","seq":119,"time":1783486771443,"data":{"turn":1,"step":2}} diff --git a/examples/sandbox-acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/sandbox-acp-agent/tests/snapshots/escalation-rejected/session.jsonl index ee2586bc34..8380fc962c 100644 --- a/examples/sandbox-acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/sandbox-acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -151,8 +151,8 @@ {"type":"assistant/chunk","seq":149,"time":1783486774569,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":150,"time":1783486774572,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to retry the exact command `printf 'escalated\\n' > escalated.txt && cat escalated.txt` with `sandbox_permissions` set to `workspace-write` and the justification they specified. They explicitly say they will reject the permission prompt, so after rejection I should explain in one short sentence and stop."},{"type":"tool-call","id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > escalated.txt && cat escalated.txt\", \"description\": \"Write escalated.txt with workspace-write permission\", \"sandbox_permissions\": \"workspace-write\", \"justification\": \"the user asked to write escalated.txt in the workspace\"}"}],"usage":{"inputTokens":1269,"outputTokens":197,"cacheReadTokens":0,"reasoningTokens":70}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149],"surfaceOp":"append"} {"type":"tool/call","seq":151,"time":1783486774572,"data":{"turn":1,"step":1,"callId":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > escalated.txt && cat escalated.txt\", \"description\": \"Write escalated.txt with workspace-write permission\", \"sandbox_permissions\": \"workspace-write\", \"justification\": \"the user asked to write escalated.txt in the workspace\"}"}} -{"type":"approval/asked","seq":152,"time":1783486774574,"data":{"id":"ed977255-38a3-4c1d-9f4e-0e258ef86e94","toolName":"bash","callId":"call_00_ODln9LCQtuvTw4FDZEfe3479","reason":"escalate sandbox to workspace-write: the user asked to write escalated.txt in the workspace"}} -{"type":"approval/decided","seq":153,"time":1783486774578,"data":{"id":"ed977255-38a3-4c1d-9f4e-0e258ef86e94","outcome":"rejected"}} +{"type":"approval/asked","seq":152,"time":1783486774574,"data":{"id":"c6f486d0-2225-49d1-940e-1e82a877580f","toolName":"bash","callId":"call_00_ODln9LCQtuvTw4FDZEfe3479","reason":"escalate sandbox to workspace-write: the user asked to write escalated.txt in the workspace"}} +{"type":"approval/decided","seq":153,"time":1783486774578,"data":{"id":"c6f486d0-2225-49d1-940e-1e82a877580f","outcome":"rejected"}} {"type":"tool/result","seq":154,"time":1783486774579,"data":{"turn":1,"step":1,"callId":"call_00_ODln9LCQtuvTw4FDZEfe3479","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"workspace-write\""}],"isError":true},"sourceEventSeqs":[151],"surfaceOp":"append"} {"type":"step/end","seq":155,"time":1783486774579,"data":{"turn":1,"step":1}} {"type":"step/start","seq":156,"time":1783486774580,"data":{"turn":1,"step":2}} diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 31e6512ae0..f25d9a24dd 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -549,6 +549,12 @@ export function apply(ctx: Context): void { } } + // bash, bash_output, and bash_kill declare no `isConcurrencySafe`, so they + // default to exclusive: bash spawns/awaits a real process, bash_output reads a + // mutable per-task output cursor (a delta since last read), and bash_kill + // mutates task state. They stay exclusive until a bash-OWNED read-only command + // classifier can prove which invocations (e.g. `pwd`, `ls`) are side-effect- + // free; the loop never infers shell safety from a command string. ctx.tools.register(defineTool({ name: 'bash', description: bashDescription(escalationModes), diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 6b137abd39..d5a9fd026a 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -199,6 +199,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'register(definition: ToolDefinition): () => void', 'get(name: string): ToolDefinition | undefined', 'schemas(): ToolSchema[]', + 'executionMode(exec: ToolExecution): ToolExecutionMode', 'async execute(exec: ToolExecution): Promise', ], }, @@ -893,7 +894,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolDefinition', - declaration: 'export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolExecution): Promise;\n timeoutMs?: number;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}', + declaration: 'export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolExecution): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}', }, { name: 'ToolErrorInfo', @@ -907,6 +908,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ToolExecution', declaration: 'export interface ToolExecution {\n callId: CallId;\n name: string;\n arguments: unknown;\n agent?: Agent;\n signal?: AbortSignal;\n}', }, + { + name: 'ToolExecutionMode', + declaration: 'export type ToolExecutionMode = {\n kind: \'parallel\';\n} | {\n kind: \'exclusive\';\n};', + }, { name: 'ToolExecutionResult', declaration: 'export interface ToolExecutionResult {\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContext?: HookContext;\n meta?: unknown;\n}', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 6137737457..7bbbbfea84 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -26,14 +26,15 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo ```ts interface Config { agents: Array<{ - id: string // required + id: string // required model?: string - cwd?: string // optional workspace cwd for the fresh session + cwd?: string // optional workspace cwd for the fresh session + maxParallelToolCalls?: number // positive integer; per-agent parallel tool-call cap (default 10) }> } ``` -Agents listed in config are auto-created at startup. `cwd` applies only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. There is no per-agent persona: the deployment persona is `dsh-system-prompt`'s own `persona` config, shared by every agent in the context. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from the `assemble({ agent })` context — runtime facts of the agents THIS loop drives, unlike the `harness:identity`/`deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. +Agents listed in config are auto-created at startup. `cwd` applies only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. `maxParallelToolCalls` (a positive integer, default `DEFAULT_MAX_PARALLEL_TOOL_CALLS` = `10`) bounds how many parallel-safe calls one assistant step runs at once; `1` restores fully serial execution. It is validated in the schema (`z.number().step(1).min(1)`), so a bad value fails config load rather than being silently dropped. There is no per-agent persona: the deployment persona is `dsh-system-prompt`'s own `persona` config, shared by every agent in the context. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from the `assemble({ agent })` context — runtime facts of the agents THIS loop drives, unlike the `harness:identity`/`deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. ### Classes @@ -67,10 +68,12 @@ forever: stream llm.stream(freeze({header..., messages: prefix+boundary})) → session('assistant/chunk') message = waterfall agent/step-result session('assistant/message') - each tool-call: session('tool/call') - → tools.execute() [waterfall tools/pre-execute → dispatch → tools/post-execute] - → session('tool/result') - append buffered post-execute additionalContext as session('context/message')(s) + schedule tool-calls: group by tools.executionMode (exclusive call = barrier; + run of parallel-safe calls = one rolling-pool group, ≤ maxParallelToolCalls in flight) + each STARTED call: session('tool/call') ⟵ model-order per started call; log positions + → ordered tools/pre-execute → pooled dispatch/body → ordered tools/post-execute may interleave with sibling results as the pool replenishes + commit cursor appends session('tool/result') in MODEL order (slot-buffered) + append buffered post-execute additionalContext (model call order) as session('context/message')(s) drain steering → session('steering/message') cont = waterfall agent/turn-continuation → ContinuationDecision ({action:'continue', reason?} records reason as next-step steering) @@ -83,6 +86,8 @@ forever: Error containment: a throwing plugin ends the **turn**, never the loop. Dispose mid-turn emits `agent/status('disposed')` and ends with reason `disposed`. A step that hits the model's output-token ceiling makes the turn end `max-tokens` (the rule: any `max-tokens` step in the turn surfaces as `max-tokens`; `disposed`/`aborted`/`error` still take precedence) — distinct from a clean `completed` stop. +Tool scheduling: within one assistant step the loop partitions tool calls into ordered groups via `ctx.tools.executionMode` — an exclusive call is its own group (an ordering barrier), a run of consecutive parallel-safe calls is one group. A parallel group runs in a rolling pool: up to `maxParallelToolCalls` calls start in model order, and each settle starts the next until the group drains. Only dispatch/body overlaps — `tools/pre-execute`/`tools/post-execute` run in model call order, each STARTED call appends its own `tool/call` (whose log position may interleave with sibling `tool/result`s), and a model-order commit cursor appends `tool/result` from slot-buffered settlements so derived history stays model-ordered (pairing by the assistant message + `callId`). `additionalContext` from the group is injected in model call order after every result. Abort stops replenishment, drains only already-started calls to results, drops buffered context, and re-raises so `runTurn` owns the end reason; a group not yet started appends no `tool/call`. `maxParallelToolCalls: 1` is byte-for-byte the old serial path. + Cancellation: `agent.cancel()` is the single public stop primitive — it clears the queued + steering FIFOs, aborts the in-flight step, and drives a turn-scoped marker the driver checks at every point a turn could start or continue (right after the idle wait, after the `running` flip, before each step, and at the continuation gate) so a turn about to start is dropped. A cancelled turn ends `aborted`; a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. The marker is reset once per loop iteration, so a cancel governs exactly one turn and never leaks onto a later prompt. (The loop still aborts its own per-step `AbortController` directly on disposal and from `cancel()`; that controller is loop-internal, not a public verb.) ### What is NOT here diff --git a/packages/core/agent-loop/src/constants.ts b/packages/core/agent-loop/src/constants.ts new file mode 100644 index 0000000000..de18e0411e --- /dev/null +++ b/packages/core/agent-loop/src/constants.ts @@ -0,0 +1,15 @@ +/** + * 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. + * + * @module dsh-agent-loop/constants + */ + +/** + * Default cap on simultaneously in-flight tool calls within one assistant step, + * when {@link AgentOptions.maxParallelToolCalls} is unset. Matches the + * rolling-pool size Claude Code uses; a group larger than the cap is not + * truncated — the cap limits concurrency, not the group. + */ +export const DEFAULT_MAX_PARALLEL_TOOL_CALLS = 10 diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 4af0d464ec..3d256570c4 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -29,6 +29,22 @@ declare module 'cordis' { } } +declare module '@deepseek-ai/dsh-agent' { + interface AgentOptions { + /** + * Maximum tool calls this agent runs concurrently within one assistant step + * (a positive integer; defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}). + * The loop's rolling pool starts up to this many parallel-safe calls at once + * and replenishes as each settles; `1` preserves the fully serial path. + * A merge-extensible field — the loop owns it (it neither the agent nor the + * subagent seam sets it), read in `runStep` when scheduling a parallel group. + */ + maxParallelToolCalls?: number + } +} + +export { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts' + /** * Plugin config: the agents to create — or resume, via `resumeSessionId` — * declaratively at startup, so a cordis.yml deployment needs no code. @@ -40,6 +56,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-`. Sourced from an env var in @@ -81,6 +102,9 @@ export class AgentLoop extends Service implements AgentFactory { model: z.string(), cwd: z.string(), resumeSessionId: z.string(), + // A positive integer; a bad value (0, negative, fractional) fails config + // validation here rather than being silently dropped from cordis.yml. + maxParallelToolCalls: z.number().step(1).min(1), })).default([]), }) as unknown as z @@ -144,6 +168,7 @@ export class AgentLoop extends Service implements AgentFactory { * @returns the running agent, owned by the calling fiber (no handle). */ create(id: AgentId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent { + this.validateAgentOptions(options) this.assertAgentIdFree(id) // Config/programmatic path: prepare the session and let start() fold its // lifecycle into the agent's composite effect (so a fiber unload tears the @@ -168,6 +193,7 @@ export class AgentLoop extends Service implements AgentFactory { * @returns the handle whose dispose tears down exactly this agent. */ createAgent(options: CreateAgentOptions): AgentHandle { + this.validateAgentOptions(options.agentOptions ?? {}) // Check the agent id BEFORE preparing the session: register() would reject a // duplicate id only AFTER the session enters the store, leaving an orphaned // live session (and lazy persistence state) that blocks reuse of that id. @@ -196,6 +222,7 @@ export class AgentLoop extends Service implements AgentFactory { * @returns the handle for the agent resumed on the reconstructed session. */ async resume(options: ResumeAgentOptions): Promise { + this.validateAgentOptions(options.agentOptions ?? {}) // Read the service through `ctx.get('sessionPersistence')` — a direct // global-store lookup keyed by the isolate symbol — NOT // `this.ctx.sessionPersistence`. AgentLoop deliberately does NOT inject @@ -228,6 +255,7 @@ export class AgentLoop extends Service implements AgentFactory { * AgentLoop's static inject, so they resolve fine). */ private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise { + this.validateAgentOptions(options.agentOptions ?? {}) this.assertAgentIdFree(options.agentId) const { meta, events } = await persistence.load(options.resumeSessionId) // Re-check the agent id AFTER the await: the pre-load check above can go @@ -267,6 +295,14 @@ export class AgentLoop extends Service implements AgentFactory { } } + /** Validate merge-extended options the loop owns before any session is prepared or loaded. */ + private validateAgentOptions(options: AgentOptions): void { + const { maxParallelToolCalls } = options + if (maxParallelToolCalls !== undefined && (!Number.isInteger(maxParallelToolCalls) || maxParallelToolCalls < 1)) { + throw new Error('maxParallelToolCalls must be a positive integer') + } + } + /** * Shared: construct a ReactLoopAgent over a PREPARED (not-yet-entered) * session, then build the ONE composite effect that owns the whole agent diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 33ddee8f64..87b82f7563 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -10,7 +10,7 @@ import type { Context } from 'cordis' import type { FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm' import { BlockAssembler, HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm' -import type { ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' +import type { ContinuationDecision, PromptDecision } from '@deepseek-ai/dsh-agent' import { canonicalHeader } from '@deepseek-ai/dsh-session' import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' import { createTransmissionLog, recordRequestHeader } from './request-log.ts' @@ -18,6 +18,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 type { ReactLoopAgent } from './agent.ts' /** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */ @@ -172,11 +173,12 @@ export interface LoopHandle { * session('assistant/chunk') * msg = waterfall agent/step-result ⟵ BEFORE the log append, so the * session('assistant/message' {content, usage?}) session records what actually ran - * each tool-call in msg (sequential, abort-checked): - * session('tool/call'); ctx.tools.execute() ⟵ tools/pre-execute (allow/deny/ask) - * → dispatch → tools/post-execute - * session('tool/result') - * append buffered post-execute additionalContext → session('context/message')(s) + * schedule tool-calls in msg by ctx.tools.executionMode (exclusive = barrier; + * consecutive parallel-safe = one rolling-pool group, ≤ maxParallelToolCalls in flight): + * each STARTED call: session('tool/call'); tools/pre-execute (MODEL order) + * → tools/execute dispatch/body (parallel pool) → tools/post-execute (MODEL order) + * session('tool/result') committed in MODEL order (slot-buffered) + * append buffered post-execute additionalContext (model order) → session('context/message')(s) * drain steering → session('steering/message') * session('step/end') ⟵ durable step boundary (no agent/* mirror) * cont = waterfall agent/turn-continuation ⟵ ContinuationDecision; default @@ -864,68 +866,26 @@ async function runStep( ) } - // --- Tool execution (sequential; parallel execution is a TODO) --- - // ToolRegistry.execute converts tool failures (including aborts) into - // isError results, so abort is re-checked around every call here. + // --- Tool execution (scheduled by per-call concurrency safety) --- + // executeToolCalls groups the step's calls by ctx.tools.executionMode and runs + // parallel-safe runs through a rolling pool. Only dispatch/body overlaps: + // tools/pre-execute and tools/post-execute run in model order, tool/result is + // committed in model order, and the returned additionalContext buffer is + // 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: HookContext[] = [] - for (const call of toolCalls) { - /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ - if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) - const callEvent = session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments }) - let parsedArguments: unknown - try { - parsedArguments = call.arguments ? JSON.parse(call.arguments) : {} - } catch { - parsedArguments = call.arguments - } - // TODO(pre-tool-input-rewrite): tools/pre-execute deliberately cannot rewrite - // `arguments` — tool/call (the audit record) and assistant/message (the - // model-history source) are logged BEFORE execute, and live consumers (ACP, - // tool-bash presentation) read the pre-execution args, so an execution-only - // rewrite would desync the UI from what ran. Designing that consistently is - // its own proposed RFC (docs/rfc/proposed/feature/…-pre-tool-input-rewrite.md). - const result = await ctx.tools.execute({ - callId: call.id, - name: call.name, - arguments: parsedArguments, - agent, - signal, - }) - session.append('tool/result', { - turn, step, - // The correlation id MUST be the loop's authoritative call.id (the - // model-transcript id that deriveMessages turns into toolCallId), NOT - // result.callId — a post-execute waterfall listener returning a - // mismatched id would otherwise orphan the call↔result pairing in the - // next model request. A listener-internal id, if ever needed, belongs in - // a separate diagnostic field, never overloaded onto callId. - callId: call.id, - content: result.content, - isError: result.isError, - ...result.error ? { error: result.error } : {}, - // The tool's private presentation payload (e.g. a result-time diff), - // persisted so a UI bridge reproduces the card on replay. - ...result.meta !== undefined ? { meta: result.meta } : {}, - }, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] }) - // Buffer (don't append yet) any post-execute additionalContext for this call. - if (result.additionalContext) pendingContext.push(result.additionalContext) - // signal CAN flip during the await above (abort() inside a tool); - // the analyzer can't see through the await boundary. - /* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */ - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) - /* v8 ignore stop */ - } + const pendingContext = await executeToolCalls(ctx, agent, turn, step, toolCalls, signal) // Append buffered post-execute context AFTER every tool/result, preserving // tool-call/result adjacency across the whole batch. inject() appends into the - // open turn (a context/message at its chronological position). + // open turn (a context/message at its chronological position). The scheduler + // returns the buffer in model call order. for (const context of pendingContext) { agent.inject(context.content, { source: context.source }) } diff --git a/packages/core/agent-loop/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts new file mode 100644 index 0000000000..69ec2b0751 --- /dev/null +++ b/packages/core/agent-loop/src/tool-calls.ts @@ -0,0 +1,326 @@ +/** + * 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 it via `ctx.tools.executionMode`, partitions the + * calls into ordered groups (one exclusive call, or a run of consecutive + * parallel-safe calls), and executes each group — a parallel group through a + * rolling pool bounded by the agent's `maxParallelToolCalls`. + * + * 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 `additionalContext` is 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`. + * + * @module dsh-agent-loop/tool-calls + */ + +import type { Context } from 'cordis' +import { assertNever, type ToolCallBlock } from '@deepseek-ai/dsh-llm' +import type { HookContext } from '@deepseek-ai/dsh-agent' +import type { Session } from '@deepseek-ai/dsh-session' +import { TOOL_REGISTRY_SCHEDULER, type ToolExecution, type ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import type { ReactLoopAgent } from './agent.ts' +import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.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 object handed to the tool pipeline. */ + exec: ToolExecution +} + +/** A settled call's slot, filled in model order before ordered finalization. */ +interface Slot { + /** 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. + * + * Appends `tool/call` (per started call) and `tool/result` (in model order) to + * the session, and returns the ordered `additionalContext` 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). + * @returns the per-step `additionalContext` buffer in model call order. + */ +export async function executeToolCalls( + ctx: Context, + agent: ReactLoopAgent, + turn: number, + step: number, + toolCalls: ToolCallBlock[], + signal: AbortSignal, +): Promise { + const { session, options } = agent + const maxParallel = options.maxParallelToolCalls ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS + + // 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. + const planned: PlannedCall[] = toolCalls.map(block => ({ + block, + exec: { + callId: block.id, + name: block.name, + arguments: parseArguments(block.arguments), + agent, + signal, + }, + })) + + // Partition into ordered groups: an exclusive call is its own group (a + // barrier), a run of consecutive parallel-safe calls is one group. Grouping + // uses executionMode so an exclusive tool between two reads splits them into + // separate ordered groups (no read/write race inside one assistant step). + const groups = groupByMode(ctx, planned) + + const pendingContext: HookContext[] = [] + for (const group of groups) { + // Groups are never empty (groupByMode only pushes non-empty runs/singletons). + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- non-empty group + const first = group[0]! + if (group.length === 1 && ctx.tools.executionMode(first.exec).kind === 'exclusive') { + await runExclusive(ctx, session, turn, step, first, signal, pendingContext) + } else { + await runParallelGroup(ctx, session, turn, step, group, signal, maxParallel, pendingContext) + } + } + return pendingContext +} + +/** Parse a model-produced raw arguments string, falling back to the raw string on invalid JSON (empty ⇒ `{}`). */ +function parseArguments(raw: string): unknown { + try { + return raw ? JSON.parse(raw) : {} + } catch { + return raw + } +} + +/** + * Group planned calls into ordered runs: each exclusive call is a singleton + * group; consecutive parallel-safe calls coalesce into one group. `executionMode` + * is queried once per call here and again by the caller to pick the exclusive + * fast-path — both reads are pure and cheap. + */ +function groupByMode(ctx: Context, planned: PlannedCall[]): PlannedCall[][] { + const groups: PlannedCall[][] = [] + let run: PlannedCall[] = [] + const flush = (): void => { + if (run.length > 0) { + groups.push(run) + run = [] + } + } + for (const call of planned) { + if (ctx.tools.executionMode(call.exec).kind === 'parallel') { + run.push(call) + } else { + flush() + groups.push([call]) + } + } + flush() + return groups +} + +/** + * The exclusive single-call path keeps the public one-call pipeline sequential: + * abort-check, `tool/call`, pre/dispatch/post via `ctx.tools.execute`, + * `tool/result`, buffer context, post-await abort-check. + */ +async function runExclusive( + ctx: Context, + session: Session, + turn: number, + step: number, + call: PlannedCall, + signal: AbortSignal, + pendingContext: HookContext[], +): Promise { + /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ + if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) + const callSeq = appendToolCall(session, turn, step, call.block) + const result = await ctx.tools.execute(call.exec) + appendToolResult(session, turn, step, call.block, result, callSeq) + if (result.additionalContext) pendingContext.push(result.additionalContext) + // signal CAN flip during the await above (abort() inside a tool); the analyzer + // can't see through the await boundary. + /* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */ + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) + /* v8 ignore stop */ +} + +/** + * The rolling-pool path for a group of parallel-safe calls. Starts calls in + * model order up to `maxParallel`, and whenever one settles starts the next + * unstarted call until the group is exhausted. Settled dispatches land in + * model-order slots; a commit cursor appends `tool/result` (and collects + * `additionalContext`) 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. + */ +async function runParallelGroup( + ctx: Context, + session: Session, + turn: number, + step: number, + group: PlannedCall[], + signal: AbortSignal, + maxParallel: number, + pendingContext: HookContext[], +): Promise { + /* 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. + 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 additionalContext. + const commitReady = async (): Promise => { + while (committed < group.length) { + const slot = slots[committed] + if (slot === undefined) break + const call = group[committed] + const result = slot.needsPost + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index + ? await ctx.tools[TOOL_REGISTRY_SCHEDULER].finalize(call!.exec, slot.result) + : 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]!) + if (result.additionalContext) pendingContext.push(result.additionalContext) + committed++ + } + } + + const inFlight = new Map>() + + const startCall = async (index: number): Promise => { + // 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) + started++ + const prepared = await ctx.tools[TOOL_REGISTRY_SCHEDULER].prepare(call.exec) + switch (prepared.kind) { + case 'dispatch': { + const promise = ctx.tools[TOOL_REGISTRY_SCHEDULER].dispatch(call.exec).then((result) => { + slots[index] = { result, needsPost: true } + return index + }) + inFlight.set(index, promise) + break + } + case 'post-result': + slots[index] = { result: prepared.result, needsPost: true } + break + case 'final-result': + slots[index] = { result: prepared.result, needsPost: false } + break + /* v8 ignore next -- closed-union exhaustiveness guard */ + default: + assertNever(prepared, 'tool-call scheduler prepare result') + } + } + + const fillPool = async (): Promise => { + while (!aborted && nextToStart < group.length && inFlight.size < maxParallel) { + await startCall(nextToStart) + nextToStart++ + await commitReady() + // The signal CAN flip while an ordered pre-execute listener is running. + 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. + 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. + // 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. + /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ + throw new Error(String(signal.reason ?? 'aborted')) + } + // A defensive check the started count matches what we committed — a parallel + // group with no abort commits every started slot, and started === group.length. + /* v8 ignore next -- unreachable: a non-aborted group starts and commits all calls */ + if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls') +} + +/** Append the `tool/call` audit event for one started call; returns its seq (the tool/result's provenance). */ +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`. */ +function appendToolResult( + session: Session, + turn: number, + step: number, + block: ToolCallBlock, + result: ToolExecutionResult, + callSeq: number, +): void { + session.append('tool/result', { + turn, step, + // The correlation id MUST be the loop's authoritative call.id (the + // model-transcript id deriveMessages turns into toolCallId), NOT + // result.callId — a post-execute listener returning a mismatched id would + // otherwise orphan the call↔result pairing in the next model request. + callId: block.id, + content: result.content, + isError: result.isError, + ...result.error ? { error: result.error } : {}, + // The tool's private presentation payload (e.g. a result-time diff), + // persisted so a UI bridge reproduces the card on replay. + ...result.meta !== undefined ? { meta: result.meta } : {}, + }, { surfaceOp: 'append', sourceEventSeqs: [callSeq] }) +} diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts new file mode 100644 index 0000000000..af4e68f9a7 --- /dev/null +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -0,0 +1,462 @@ +/** + * The per-step tool-call scheduler (`tool-calls.ts`): grouping by + * `ctx.tools.executionMode`, the rolling pool for parallel groups, model-order + * `tool/result` commit despite out-of-order settlement, interleaved `tool/call` + * audit records, ordered `tools/pre-execute`/`tools/post-execute`, + * model-ordered `additionalContext`, 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). + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { CallId, StreamChunk } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import LlmService from '@deepseek-ai/dsh-llm' +import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { MockAdapter, textResponse } from './mock-adapter.ts' + +async function harness(adapter: MockAdapter) { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + return ctx +} + +function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { dispose(); resolve() } + }) + }) +} + +function events(agent: ReactLoopAgent): SessionEvent[] { + return [...agent.session.events] +} + +/** An assistant message with N tool-call blocks named `name` (ids c1..cN, arg = index). */ +function multiCall(calls: { id: string; name: string; args: object }[]): StreamChunk[] { + const chunks: StreamChunk[] = [] + calls.forEach((call, index) => { + chunks.push( + { type: 'block-start', index, blockType: 'tool-call' }, + { type: 'block-end', index, block: { type: 'tool-call', id: CallId(call.id), name: call.name, arguments: JSON.stringify(call.args) } }, + ) + }) + chunks.push( + { type: 'usage', usage: { inputTokens: 5, outputTokens: 5 } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ) + return chunks +} + +/** A parallel-safe tool whose calls block until the test releases them by callId. */ +function gatedParallelTool(name: string) { + const gates = new Map void>() + const started: string[] = [] + const tool = defineTool({ + name, + description: `gated ${name}`, + parameters: { id: { type: 'string', required: true } }, + isConcurrencySafe: () => true, + async execute(args) { + started.push(args.id) + await new Promise((resolve) => { gates.set(args.id, resolve) }) + return [{ type: 'text', text: `done-${args.id}` }] + }, + }) + 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()] }, + } +} + +/** Poll until `predicate` holds, letting microtasks/timers drain between checks. */ +async function until(predicate: () => boolean): Promise { + for (let i = 0; i < 1000 && !predicate(); i++) await new Promise(r => setTimeout(r, 0)) + if (!predicate()) throw new Error('until: condition never held') +} + +describe('tool-call scheduler: grouping and barriers', () => { + it('runs parallel-safe siblings concurrently (all start before any completes)', async () => { + const adapter = new MockAdapter([ + multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }, { id: 'c3', name: 'p', args: { id: '3' } }]), + textResponse('done'), + ]) + const ctx = await harness(adapter) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + const agent = ctx.agentLoop.create(AgentId('a1'), { 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') + await waitForIdle(ctx, agent) + }) + + 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([ + { id: 'c1', name: 'r', args: { id: 'A1' } }, + { id: 'c2', name: 'w', args: { id: 'A2' } }, + { id: 'c3', name: 'r', args: { id: 'A3' } }, + ]), + textResponse('done'), + ]) + const ctx = await harness(adapter) + ctx.tools.register(defineTool({ + name: 'r', description: 'read', parameters: { id: { type: 'string', required: true } }, + isConcurrencySafe: () => true, + async execute(args) { order.push(`r-start-${args.id}`); order.push(`r-end-${args.id}`); return [{ type: 'text', text: 'r' }] }, + })) + ctx.tools.register(defineTool({ + name: 'w', description: 'write', parameters: { id: { type: 'string', required: true } }, + async execute(args) { order.push(`w-${args.id}`); return [{ type: 'text', text: 'w' }] }, + })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + 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']) + }) +}) + +describe('tool-call scheduler: model-order results despite out-of-order settlement', () => { + it('commits tool/result in model order even when a later call settles first', async () => { + const adapter = new MockAdapter([ + multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]), + textResponse('done'), + ]) + const ctx = await harness(adapter) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + 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') + expect(beforeFirst).toEqual([]) + gated.release('1') + await waitForIdle(ctx, agent) + + const results = events(agent).filter(e => e.type === 'tool/result') + expect(results.map(e => e.data.callId)).toEqual([CallId('c1'), CallId('c2')]) + }) + + it('derived history pairs calls in model order regardless of tool/call log interleaving', async () => { + const adapter = new MockAdapter([ + multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]), + textResponse('done'), + ]) + const ctx = await harness(adapter) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await until(() => gated.started.length === 2) + 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')]) + }) +}) + +describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => { + it('rejects invalid programmatic maxParallelToolCalls values before creating agents', async () => { + const ctx = await harness(new MockAdapter([])) + + expect(() => ctx.agentLoop.create(AgentId('bad-zero'), { model: 'mock', maxParallelToolCalls: 0 })) + .toThrow('maxParallelToolCalls must be a positive integer') + expect(() => ctx.agentLoop.createAgent({ + agentId: AgentId('bad-fractional'), + sessionId: SessionId('bad-fractional-session'), + agentOptions: { model: 'mock', maxParallelToolCalls: 1.5 }, + })).toThrow('maxParallelToolCalls must be a positive integer') + }) + + it('starts at most the cap, replenishing as calls settle', async () => { + const adapter = new MockAdapter([ + multiCall([1, 2, 3, 4].map(n => ({ id: `c${n}`, name: 'p', args: { id: String(n) } }))), + textResponse('done'), + ]) + const ctx = await harness(adapter) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', maxParallelToolCalls: 2 }) + + 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']) + expect(events(agent) + .filter(e => e.type === 'tool/call' || e.type === 'tool/result') + .map(e => `${e.type}:${String(e.data.callId)}`) + .slice(0, 4)) + .toEqual(['tool/call:c1', 'tool/call:c2', 'tool/result:c1', 'tool/call:c3']) + gated.release('2'); gated.release('3') + await until(() => gated.started.length === 4) + gated.release('4') + await waitForIdle(ctx, agent) + expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId)) + .toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')]) + }) + + it('maxParallelToolCalls: 1 is fully serial (no second start before the first settles)', async () => { + const adapter = new MockAdapter([ + multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]), + textResponse('done'), + ]) + const ctx = await harness(adapter) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', maxParallelToolCalls: 1 }) + agent.send([{ type: 'text', text: 'go' }]) + await until(() => gated.started.length === 1) + await new Promise(r => setTimeout(r, 5)) + expect(gated.started).toEqual(['1']) + gated.release('1') + await until(() => gated.started.length === 2) + gated.release('2') + await waitForIdle(ctx, agent) + }) +}) + +describe('tool-call scheduler: ordered middleware and additionalContext', () => { + it('tools/pre-execute and tools/post-execute observe model call order', async () => { + const adapter = new MockAdapter([ + multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }, { id: 'c3', name: 'p', args: { id: '3' } }]), + textResponse('done'), + ]) + const ctx = await harness(adapter) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + const pre: string[] = [] + const post: string[] = [] + ctx.on('tools/pre-execute', async (exec, next): Promise => { pre.push(String(exec.callId)); return next() }) + ctx.on('tools/post-execute', async (exec, _result, next): Promise => { post.push(String(exec.callId)); return next() }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + 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) + + expect(pre).toEqual([CallId('c1'), CallId('c2'), CallId('c3')].map(String)) + expect(post).toEqual([CallId('c1'), CallId('c2'), CallId('c3')].map(String)) + }) + + it('injects additionalContext in model call order, not settlement order', async () => { + const adapter = new MockAdapter([ + multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]), + textResponse('done'), + ]) + const ctx = await harness(adapter) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + ctx.on('tools/post-execute', async (exec, _result): Promise => + ({ kind: 'accept', additionalContext: { content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + agent.send([{ type: 'text', text: 'go' }]) + await until(() => gated.started.length === 2) + gated.release('2'); gated.release('1') + 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']) + const lastResult = log.findLastIndex(e => e.type === 'tool/result') + const firstContext = log.findIndex(e => e.type === 'context/message') + expect(lastResult).toBeLessThan(firstContext) + }) + + it('keeps pre-produced deny/error results ordered without dispatching those calls', async () => { + const adapter = new MockAdapter([ + multiCall([ + { id: 'c1', name: 'p', args: { id: '1' } }, + { id: 'c2', name: 'p', args: { id: '2' } }, + { id: 'c3', name: 'p', args: { id: '3' } }, + ]), + textResponse('done'), + ]) + const ctx = await harness(adapter) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + const post: string[] = [] + ctx.on('tools/pre-execute', async (exec, next): Promise => { + if (exec.callId === CallId('c2')) return { kind: 'deny', reason: 'blocked by policy' } + if (exec.callId === CallId('c3')) throw new Error('pre exploded') + return next() + }) + ctx.on('tools/post-execute', async (exec, _result, next): Promise => { + post.push(String(exec.callId)) + return next() + }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + agent.send([{ type: 'text', text: 'go' }]) + await until(() => gated.started.length === 1) + gated.release('1') + await waitForIdle(ctx, agent) + + expect(gated.started).toEqual(['1']) + expect(post).toEqual(['c1', 'c2']) + const results = events(agent).filter(e => e.type === 'tool/result') + expect(results.map(e => e.data.callId)).toEqual([CallId('c1'), CallId('c2'), CallId('c3')]) + expect((results[1]!.data.content[0] as { text: string }).text).toContain('blocked by policy') + expect((results[2]!.data.content[0] as { text: string }).text).toContain('pre exploded') + }) +}) + +describe('tool-call scheduler: abort handling', () => { + it('starts no calls when the signal is already aborted before a parallel group', async () => { + const adapter = new MockAdapter([ + multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]), + textResponse('should never be requested'), + ]) + const ctx = await harness(adapter) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + ctx.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'assistant/message') { + ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('already aborted') + } + }) + + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + + expect(gated.started).toEqual([]) + expect(events(agent).filter(e => e.type === 'tool/call')).toEqual([]) + expect(events(agent).filter(e => e.type === 'tool/result')).toEqual([]) + }) + + it('stops starting siblings when abort fires during ordered pre-execute', async () => { + const adapter = new MockAdapter([ + multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]), + textResponse('should never be requested'), + ]) + const ctx = await harness(adapter) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + ctx.on('tools/pre-execute', async (exec, next): Promise => { + if (exec.callId === CallId('c1')) { + ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('pre cancelled') + } + return next() + }) + + agent.send([{ type: 'text', text: 'go' }]) + await until(() => gated.started.length === 1) + await new Promise(r => setTimeout(r, 5)) + expect(gated.started).toEqual(['1']) + gated.release('1') + await waitForIdle(ctx, agent) + + expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId)) + .toEqual([CallId('c1')]) + expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId)) + .toEqual([CallId('c1')]) + }) + + it('stops replenishing after abort, commits started results, and drops buffered additionalContext', async () => { + const adapter = new MockAdapter([ + multiCall([1, 2, 3, 4].map(n => ({ id: `c${n}`, name: 'p', args: { id: String(n) } }))), + textResponse('should never be requested'), + ]) + const ctx = await harness(adapter) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + ctx.on('tools/post-execute', async (exec, _result, next): Promise => ({ + ...await next(), + additionalContext: { content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } }, + })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', maxParallelToolCalls: 2 }) + + agent.send([{ type: 'text', text: 'go' }]) + await until(() => gated.started.length === 2) + ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('stop now') + gated.release('1') + gated.release('2') + await waitForIdle(ctx, agent) + + expect(gated.started).toEqual(['1', '2']) + expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId)) + .toEqual([CallId('c1'), CallId('c2')]) + expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId)) + .toEqual([CallId('c1'), CallId('c2')]) + expect(events(agent).filter(e => e.type === 'context/message')).toEqual([]) + }) + + it('does not run an exclusive barrier after a parallel group aborts', async () => { + const adapter = new MockAdapter([ + multiCall([ + { id: 'c1', name: 'p', args: { id: '1' } }, + { id: 'c2', name: 'p', args: { id: '2' } }, + { id: 'c3', name: 'x', args: { id: '3' } }, + ]), + textResponse('should never be requested'), + ]) + const ctx = await harness(adapter) + const gated = gatedParallelTool('p') + const exclusive: string[] = [] + ctx.tools.register(gated.tool) + ctx.tools.register(defineTool({ + name: 'x', + description: 'exclusive', + parameters: { id: { type: 'string', required: true } }, + async execute(args) { exclusive.push(args.id); return [{ type: 'text', text: 'x' }] }, + })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', maxParallelToolCalls: 2 }) + + agent.send([{ type: 'text', text: 'go' }]) + await until(() => gated.started.length === 2) + ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('stop before barrier') + gated.release('1') + gated.release('2') + await waitForIdle(ctx, agent) + + expect(exclusive).toEqual([]) + expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId)) + .toEqual([CallId('c1'), CallId('c2')]) + }) +}) diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 0bf1c4bf08..5ae46fe098 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -19,6 +19,7 @@ tools: - `ctx.tools.get(name: string): ToolDefinition | undefined` - `ctx.tools.schemas(): ToolSchema[]` Schemas of all registered tools (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)). - `ctx.tools.execute(exec: ToolExecution): Promise` Execute one tool call through the `tools/pre-execute` → `tools/execute` → `tools/post-execute` pipeline. +- `ctx.tools.executionMode(exec: ToolExecution): ToolExecutionMode` Classify how one call may be scheduled relative to its step-siblings — `{ kind: 'parallel' }` only when the registered tool's `isConcurrencySafe(exec.arguments)` returns `true`, else `{ kind: 'exclusive' }` (unknown tool, no declaration, non-`true`, or a thrown check). The agent-loop scheduler uses it to group calls; host-only, never model-visible. ### Injected services @@ -35,8 +36,9 @@ tools: ### Key types -- `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). It also carries an optional cooperative timeout budget `timeoutMs?: number` (ms) enforced by `@deepseek-ai/dsh-timeout-policy`, never sent to the model. +- `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). It also carries an optional cooperative timeout budget `timeoutMs?: number` (ms) enforced by `@deepseek-ai/dsh-timeout-policy`, and an optional synchronous `isConcurrencySafe(args): boolean` concurrency classifier read by `executionMode` — both host-only scheduler metadata, never sent to the model. - `ToolExecution` — one pending tool call: `{ callId, name, arguments, agent?, signal? }`. +- `ToolExecutionMode` — `{ kind: 'parallel' } | { kind: 'exclusive' }`, returned by `executionMode`. Object-tagged (not a bare boolean) so a future resource-grouping dimension (e.g. `{ kind: 'exclusive', group: 'session:...' }`) can extend a variant without a breaking change. - `ToolExecutionResult` — outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). `additionalContext` (a `HookContext`) ferries any `tools/post-execute` context up to the loop, which buffers it and appends it as a `context/message` after all `tool/result`s in the step. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards it onto the `tool/result` session event for result-card rendering. - `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite (changing `arguments`) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when a deployment mounts it (`allowed-once` proceeds to dispatch; `rejected`/`cancelled`/`unavailable` deny with distinct reasons) and degrades to `deny` when none is mounted or the execution carries no agent. - `PostToolDecision` — `{kind:'accept', content?, additionalContext?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContext?}` (turn it into an `isError` whose content is the corrective feedback). Output replacement is clean because `tool/result` is logged AFTER `execute()` returns. @@ -83,6 +85,8 @@ See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, an `defineTool` also validates an optional `timeoutMs` at definition time when present: it must be a positive finite number, or the helper throws — the budget is attached to the produced `ToolDefinition` (for `@deepseek-ai/dsh-timeout-policy`) and never reaches the model. +`defineTool` also accepts an optional `isConcurrencySafe(args): boolean` — the per-call concurrency classifier the agent-loop scheduler reads via `executionMode`. `args` is the typed `InferArgs` shape. It is soft-validated exactly like the presenters: an arg mismatch yields `false` (the conservative exclusive default), never the hard `ToolArgsError`. Declaring `true` is a contract — the tool body must not mutate parent-owned async state (`exec.agent.session.append`, `agent.inject`) during `execute`; its only ordered outputs are the returned content, `meta`, error, and `additionalContext`. The one exception is a synchronous, side-effect-only commutative recorder (the `fs/observed` version recorder is the worked example); anything richer stays exclusive. Host-only, never model-visible. + ### Structured-output schema subset A separate vocabulary for callers that DEMAND a machine-readable value from an agent — the subagent seam's `SubagentStartRequest.outputSchema` (and, by extension, a workflow's `agent({ schema })`). Unlike `SchemaSpec` (the author-facing DSL for tool parameters), a `StructuredOutputSchema` is an object-rooted **raw JSON Schema subset** as data: it travels verbatim to the model as a forced tool's `parameters`, and the produced value is validated against it. @@ -133,12 +137,16 @@ const bash = defineTool({ Under `mode: code` (or `both`) the registry turns the tool surface into a programming API, per the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): the model writes a TypeScript program (the body of an async function) and passes it to the ONE wire tool `run_code`; the program runs in `ctx.codeRuntime` (the [code-execution seam](../../code-runtime/README.md) — the shipped backend is a worker thread) with one async binding per registered tool (`await tools.bash({...})`), and ONLY what it prints or returns re-enters the model's context. - **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of every registered tool except `run_code` (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is TOTAL: constructs outside the `defineTool` subset degrade to `unknown`, never throw. -- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized BEFORE dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and the logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order — the tool contract carries no concurrency-safety metadata yet), gated by `tools/pre-execute`/`tools/post-execute` like any native call (a deny reaches the program as a binding rejection), and logged as one `tool/code-dispatch` session event (log-only: `deriveMessages()` never surfaces it) with the deterministic sub-id `:code:`. A failed sub-call REJECTS the program-side promise with the tool's error text — real code error handling, no bespoke envelope. A sub-call's `additionalContext` is deliberately DROPPED (no safe outlet mid-run without breaking tool-call/result adjacency; deferred until a real hook needs it through Code Mode). +- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized BEFORE dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and the logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order — Code Mode's in-program dispatch stays serial even though native sibling calls parallelize; lifting that is follow-up work for the Code Mode bridge), gated by `tools/pre-execute`/`tools/post-execute` like any native call (a deny reaches the program as a binding rejection), and logged as one `tool/code-dispatch` session event (log-only: `deriveMessages()` never surfaces it) with the deterministic sub-id `:code:`. A failed sub-call REJECTS the program-side promise with the tool's error text — real code error handling, no bespoke envelope. A sub-call's `additionalContext` is deliberately DROPPED (no safe outlet mid-run without breaking tool-call/result adjacency; deferred until a real hook needs it through Code Mode). - **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from. The wire collapse is the registry's own contribution (`systemPrompt.tools()` is mode-aware), so the logged `request/header` records it for free — under `code`, the assembled tool list is exactly `[run_code]`, pinned by tests and the snapshot goldens. Try it: `pnpm run demo:code-mode` ([the coding-agent example's Code Mode overlay](../../../examples/coding-agent/README.md#code-mode)); `pnpm run demo:code-mode acp` serves the same mode over ACP instead of the REPL. +### Parallel execution + +A `ToolDefinition` declares per-call concurrency safety via `isConcurrencySafe(args)`; the registry's `executionMode(exec)` turns that into `{ kind: 'parallel' | 'exclusive' }`. The agent loop groups a step's calls by mode — a run of consecutive parallel calls executes in a rolling pool (bounded by the agent's `maxParallelToolCalls`), an exclusive call runs alone as an ordering barrier. Only dispatch/body overlaps; `tools/pre-execute` and `tools/post-execute` still observe model call order, and `tool/result` events are appended in model order (see [`dsh-agent-loop`](../agent-loop/README.md)). The conservative first declarations: `web_search`, `web_fetch`, filesystem `read`, and `subagent` are parallel-safe; `write`/`edit`/`todo_write`/`bash`/`bash_output`/`bash_kill` stay exclusive. `run_code` stays exclusive and its in-program dispatch stays serial. + ### What is NOT here (TODO) -- **Tool shapes review** — when real tools land (e.g. a concurrency-safety hint for parallel execution); phase 1 executes tool calls sequentially. -- **Parallel execution** — the loop currently iterates tool calls sequentially. +- **A `tools/execution-mode` waterfall** — `executionMode` is a plain method today; a Cordis seam letting hook/MCP/provider policy downgrade a tool's baseline decision is the future insertion point, not needed for the conservative first declaration set. +- **A bash read-only classifier** — `bash`/`bash_output`/`bash_kill` stay exclusive until the bash package can prove which commands are read-only; the loop never infers shell safety. diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index b624f468bb..26bf72c7e6 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -136,11 +136,6 @@ declare module 'cordis' { } } -// TODO(review): revisit these shapes when the first real tools and -// sandbox/permission plugins land (e.g. a concurrency-safety hint for -// parallel execution — Claude Code partitions read-only tools; phase 1 -// executes sequentially). - /** * What a tool's `execute` returns. The bare {@link ContentBlock}`[]` form is the * common case (model-facing content only); the object form additionally attaches @@ -163,6 +158,31 @@ export 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 @@ -209,6 +229,53 @@ export interface ToolExecution { signal?: AbortSignal } +/** + * How a single tool call may be scheduled relative to its siblings in one + * assistant step, as decided by {@link ToolRegistry.executionMode}. `parallel` + * calls may run concurrently within a rolling pool; an `exclusive` call runs + * alone and forms an ordering barrier. Object-tagged (rather than a bare + * boolean) so a future resource-grouping dimension can extend a variant — e.g. + * `{ kind: 'exclusive', group: 'session:...' }` — without a breaking change. + */ +export type ToolExecutionMode = + | { kind: 'parallel' } + | { kind: 'exclusive' } + +/** + * Internal result of the scheduler-owned `tools/pre-execute` stage. Exported + * only so `dsh-agent-loop` can split ordered middleware from concurrent + * dispatch without exposing named staged service methods on `ctx.tools`. + * @internal + */ +export type ScheduledToolPreparation = + | { kind: 'dispatch' } + | { kind: 'post-result'; result: ToolExecutionResult } + | { kind: 'final-result'; result: ToolExecutionResult } + +/** + * Internal scheduler view of the registry pipeline. `dsh-agent-loop` uses this + * symbol-keyed entry point to keep `tools/pre-execute` and `tools/post-execute` + * ordered while overlapping only `tools/execute` dispatch/body. Ordinary + * callers use {@link ToolRegistry.execute}; this symbol is not a plugin seam. + * @internal + */ +export interface ToolRegistryScheduler { + /** Run the ordered pre-execute gate and decide what stage follows. */ + prepare(exec: ToolExecution): Promise + /** Run only the around-dispatch/body stage. */ + dispatch(exec: ToolExecution): Promise + /** Run ordered post-execute finalization for a dispatch/pre result. */ + finalize(exec: ToolExecution, result: ToolExecutionResult): Promise +} + +/** + * Symbol-keyed internal scheduler entry point on {@link ToolRegistry}. The + * generated service catalog deliberately skips computed members, so this does + * not create a named public staged API. + * @internal + */ +export const TOOL_REGISTRY_SCHEDULER: unique symbol = Symbol('@deepseek-ai/dsh-tools.scheduler') + /** Structured error metadata for a failed tool call (alongside the model-facing text). */ export interface ToolErrorInfo { name: string @@ -239,7 +306,6 @@ export interface ToolExecutionResult { * text in `content` is always present; this is extra structure for code. */ error?: ToolErrorInfo - /** /** * Extra model-facing context a `tools/post-execute` listener attached for the * NEXT request (Claude Code's PostToolUse `additionalContext`). It is NOT part @@ -353,6 +419,13 @@ export class ToolRegistry extends Service { mode: z.union(['native', 'code', 'both'] as const).default('native'), }) + /** Internal staged view consumed by `dsh-agent-loop`'s parallel scheduler. */ + readonly [TOOL_REGISTRY_SCHEDULER]: ToolRegistryScheduler = { + prepare: exec => this.prepareScheduledExecution(exec), + dispatch: exec => this.dispatchScheduledExecution(exec), + finalize: (exec, result) => this.finalizeScheduledExecution(exec, result), + } + private store = new Map() private readonly mode: ToolPresentationMode @@ -472,23 +545,47 @@ export class ToolRegistry extends Service { } /** - * Execute one tool call through the `tools/pre-execute` → `tools/execute` - * (around dispatch) → `tools/post-execute` pipeline. `pre-execute` is the gate - * (allow/deny), `tools/execute` wraps core dispatch (a timeout/retry/metrics - * seam), and `post-execute` is the inspect/transform seam; core dispatch sits - * as the base `next()` of the `tools/execute` waterfall. The whole thing is - * wrapped in one outer try/catch so a throwing listener (in any waterfall) - * becomes an `isError` result instead of failing the turn; the tool body ALSO - * keeps its own inner try/catch, so a thrown tool becomes an `isError` result - * that `tools/execute` and `post-execute` listeners can still inspect. If the - * tool is not registered, the result is an `isError` carrying a `UNKNOWN_TOOL` - * structured error. A thrown {@link HarnessError} surfaces its `{ name, code }` - * on the result. - * @param exec - the call to run (name, parsed arguments, caller agent, signal). - * @returns the final result after every waterfall; failures resolve as - * `isError` results, never rejections. + * Classify how one pending call may be scheduled relative to its siblings in + * the same assistant step. Looks up the registered tool and calls its + * `isConcurrencySafe(exec.arguments)` classifier. The default is exclusive: + * an unknown tool, a tool with no `isConcurrencySafe` declaration, a check + * that returns any non-`true` value, and a check that THROWS all resolve to + * `{ kind: 'exclusive' }` — only an explicit `true` yields `{ kind: 'parallel' }`. + * + * This is a plain method, not a cordis waterfall: the conservative first + * declaration set needs no policy-driven downgrade, and the method boundary + * leaves room to introduce a `tools/execution-mode` seam later if a real + * deployment needs hook, MCP, or provider policy to override a tool's baseline + * decision. + * @param exec - the call to classify (its `name` selects the tool, its parsed + * `arguments` feed the classifier). No I/O runs and `exec` is not mutated. + * @returns `{ kind: 'parallel' }` only when the registered tool's check + * returns `true`; `{ kind: 'exclusive' }` otherwise. */ - async execute(exec: ToolExecution): Promise { + executionMode(exec: ToolExecution): ToolExecutionMode { + const tool = this.store.get(exec.name) + if (!tool?.isConcurrencySafe) return { kind: 'exclusive' } + try { + return tool.isConcurrencySafe(exec.arguments) ? { kind: 'parallel' } : { kind: 'exclusive' } + } catch { + // A thrown classifier is a tool-authoring bug, not a scheduling signal: + // fail closed to exclusive so a broken check can never widen concurrency. + return { kind: 'exclusive' } + } + } + + /** + * Run the ordered `tools/pre-execute` gate for the agent-loop scheduler. This + * is an internal factoring point, not a plugin seam; ordinary callers use + * {@link execute}, which still performs the full sequential pipeline. A + * non-allow decision returns a result that still needs ordered post-execute + * finalization; a throwing pre listener returns a final error result. + * @param exec - the call to prepare. + * @returns whether the scheduler should dispatch the tool, post-process a + * pre-produced result, or use a final error result as-is. + * @internal + */ + private async prepareScheduledExecution(exec: ToolExecution): Promise { try { // --- Gate: tools/pre-execute. An `ask` resolves through the approval // seam (or degrades) to allow/deny before the shared deny path. --- @@ -503,16 +600,27 @@ export class ToolRegistry extends Service { content: [{ type: 'text', text: `Error: ${decision.reason}` }], isError: true, } - return await this.postExecute(exec, denied) + return { kind: 'post-result', result: denied } } + return { kind: 'dispatch' } + } catch (error: unknown) { + return { kind: 'final-result', result: toolErrorResult(exec.callId, error) } + } + } - // --- Around-dispatch: tools/execute. The base `next` is the dispatch- - // with-normalization thunk — the tool body's own try/catch turns a throw - // into an isError result so a wrapper (and post-execute) can inspect it; - // an unknown tool routes through the same catch. A `tools/execute` listener - // (e.g. a timeout plugin) wraps this thunk: it may mutate `exec` before - // delegating and inspect the normalized result after. --- - const result = await this.ctx.waterfall( + /** + * Run only the concurrent dispatch/body stage for the agent-loop scheduler. + * The `tools/execute` around-dispatch waterfall wraps the normalized tool body + * here; ordered pre/post remain the scheduler's responsibility. Ordinary + * callers use {@link execute}. + * @param exec - the already-prepared call to dispatch. + * @returns the raw dispatch result before `tools/post-execute`; failures are + * normalized into `isError` results. + * @internal + */ + private async dispatchScheduledExecution(exec: ToolExecution): Promise { + try { + return await this.ctx.waterfall( this, 'tools/execute', exec, async (): Promise => { try { @@ -530,11 +638,7 @@ export class ToolRegistry extends Service { } }, ) - - return await this.postExecute(exec, result) } catch (error: unknown) { - // Outer backstop: a throwing pre/post-execute listener (or the waterfall - // machinery) becomes an isError result, never a turn failure. return toolErrorResult(exec.callId, error) } } @@ -577,6 +681,50 @@ export class ToolRegistry extends Service { } } + /** + * Run the ordered `tools/post-execute` finalization stage for the agent-loop + * scheduler. This is an internal factoring point paired with + * {@link prepareScheduledExecution} and {@link dispatchScheduledExecution}; + * ordinary callers use {@link execute}. + * @param exec - the call whose dispatch result is being finalized. + * @param result - the dispatch result or pre-produced denial result. + * @returns the final tool result after post-execute; throwing listeners are + * normalized into `isError` results. + * @internal + */ + private async finalizeScheduledExecution(exec: ToolExecution, result: ToolExecutionResult): Promise { + try { + return await this.postExecute(exec, result) + } catch (error: unknown) { + return toolErrorResult(exec.callId, error) + } + } + + /** + * Execute one tool call through the `tools/pre-execute` → `tools/execute` + * (around dispatch) → `tools/post-execute` pipeline. `pre-execute` is the gate + * (allow/deny), `tools/execute` wraps core dispatch (a timeout/retry/metrics + * seam), and `post-execute` is the inspect/transform seam; core dispatch sits + * as the base `next()` of the `tools/execute` waterfall. The staged scheduler + * helpers above are internal factoring points for the agent loop; this public + * one-call API remains the sequential composition direct callers use. Failures + * in any stage resolve as `isError` results instead of failing the turn. If the + * tool is not registered, the result is an `isError` carrying a `UNKNOWN_TOOL` + * structured error. A thrown {@link HarnessError} surfaces its `{ name, code }` + * on the result. + * @param exec - the call to run (name, parsed arguments, caller agent, signal). + * @returns the final result after every waterfall; failures resolve as + * `isError` results, never rejections. + */ + async execute(exec: ToolExecution): Promise { + const prepared = await this.prepareScheduledExecution(exec) + if (prepared.kind === 'final-result') return prepared.result + const result = prepared.kind === 'post-result' + ? prepared.result + : await this.dispatchScheduledExecution(exec) + return await this.finalizeScheduledExecution(exec, result) + } + /** * Run the `tools/post-execute` waterfall over a dispatched `result` and apply * its {@link PostToolDecision}: `accept` keeps the call successful (replacing diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index 1a428ffd40..a3ea4e1d32 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -302,6 +302,16 @@ export interface DefineToolOptions { * is never sent to the model. */ timeoutMs?: number + /** + * Optional synchronous concurrency-safety classifier (see + * {@link ToolDefinition.isConcurrencySafe}). `args` is the typed, schema- + * validated shape — zero casts. Validated SOFTLY, mirroring the presenters: + * on an arg mismatch the produced classifier returns `false` (the conservative + * exclusive default) instead of the hard {@link ToolArgsError} the execute path + * raises, since replay/scheduling may feed older-schema args. Host-only — never + * sent to the model. + */ + isConcurrencySafe?(args: InferArgs): boolean /** * Tool execution function. `args` is typed as {@link InferArgs} — zero * casts needed. Returns either a bare {@link ContentBlock}`[]` (model-facing @@ -357,9 +367,9 @@ export interface DefineToolOptions { * execute body, and optional presenters. * @returns a registry-ready {@link ToolDefinition}: its `execute` validates the * raw args first (throwing {@link ToolArgsError} on mismatch, which the - * registry turns into an isError result), and its presenters validate softly - * (returning undefined on mismatch, since replay may feed them older-schema - * args). + * registry turns into an isError result), and its presenters and + * `isConcurrencySafe` classifier validate softly (returning undefined/`false` + * on mismatch, since replay/scheduling may feed them older-schema args). */ export function defineTool(options: DefineToolOptions): ToolDefinition { // Object-literal execute methods don't use `this`; the reference is safe. @@ -369,6 +379,8 @@ export function defineTool(options: DefineToolOptions): const userPresentCall = options.presentCall // eslint-disable-next-line @typescript-eslint/unbound-method const userPresentResult = options.presentResult + // eslint-disable-next-line @typescript-eslint/unbound-method + const userIsConcurrencySafe = options.isConcurrencySafe if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) { throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`) } @@ -403,5 +415,15 @@ export function defineTool(options: DefineToolOptions): return userPresentResult(args as InferArgs, result) } } + // Concurrency classification is host-only scheduler metadata (never sent to + // the model) and, like the presenters, may run against replay/scheduling args + // from an older schema — so it validates SOFTLY: an arg mismatch returns + // `false` (conservative exclusive default), never the hard ToolArgsError. + if (userIsConcurrencySafe) { + tool.isConcurrencySafe = (args: unknown): boolean => { + if (validateArgs(options.parameters, args).length > 0) return false + return userIsConcurrencySafe(args as InferArgs) + } + } return tool } diff --git a/packages/core/tools/tests/execution-mode.spec.ts b/packages/core/tools/tests/execution-mode.spec.ts new file mode 100644 index 0000000000..443581d239 --- /dev/null +++ b/packages/core/tools/tests/execution-mode.spec.ts @@ -0,0 +1,133 @@ +/** + * Per-call concurrency classification: `ToolDefinition.isConcurrencySafe`, + * `defineTool()`'s soft-validated forwarding of it, and the registry's + * `executionMode(exec)` decision. Also proves the classifier never leaks into + * the model-facing `schemas()` projection. + */ + +import { describe, expect, expectTypeOf, it } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { + defineTool, + type ToolDefinition, + type ToolExecution, + type ToolExecutionMode, +} from '@deepseek-ai/dsh-tools' + +async function setup() { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + return ctx +} + +function exec(name: string, args: unknown): ToolExecution { + return { callId: CallId('c1'), name, arguments: args } +} + +describe('ToolRegistry.executionMode', () => { + it('returns parallel only when the registered tool declares isConcurrencySafe → true', async () => { + const ctx = await setup() + ctx.tools.register(defineTool({ + name: 'safe', + description: 'parallel-safe', + parameters: {}, + isConcurrencySafe: () => true, + async execute() { return [] }, + })) + expect(ctx.tools.executionMode(exec('safe', {}))).toEqual({ kind: 'parallel' }) + }) + + it('defaults to exclusive for a tool with no isConcurrencySafe declaration', async () => { + const ctx = await setup() + ctx.tools.register(defineTool({ + name: 'plain', + description: 'no declaration', + parameters: {}, + async execute() { return [] }, + })) + expect(ctx.tools.executionMode(exec('plain', {}))).toEqual({ kind: 'exclusive' }) + }) + + it('returns exclusive for an unknown tool', async () => { + const ctx = await setup() + expect(ctx.tools.executionMode(exec('nonexistent', {}))).toEqual({ kind: 'exclusive' }) + }) + + it('returns exclusive when the classifier returns false for these args', async () => { + const ctx = await setup() + // Input-sensitive: safe to read, unsafe to write — the same tool differs by args. + ctx.tools.register(defineTool({ + name: 'rw', + description: 'read or write', + parameters: { mode: { type: 'string', required: true } }, + isConcurrencySafe: args => args.mode === 'read', + async execute() { return [] }, + })) + expect(ctx.tools.executionMode(exec('rw', { mode: 'read' }))).toEqual({ kind: 'parallel' }) + expect(ctx.tools.executionMode(exec('rw', { mode: 'write' }))).toEqual({ kind: 'exclusive' }) + }) + + it('a defineTool classifier soft-fails to exclusive on invalid args (no ToolArgsError)', async () => { + const ctx = await setup() + // The typed classifier would read args.mode, but the required arg is missing: + // soft validation returns false (exclusive) rather than throwing, matching the + // presenter pattern. Executing the same bad args WOULD raise ToolArgsError. + ctx.tools.register(defineTool({ + name: 'needs-mode', + description: 'requires mode', + parameters: { mode: { type: 'string', required: true } }, + isConcurrencySafe: () => true, + async execute() { return [] }, + })) + expect(ctx.tools.executionMode(exec('needs-mode', {}))).toEqual({ kind: 'exclusive' }) + }) + + it('a thrown classifier fails closed to exclusive (raw definition)', async () => { + const ctx = await setup() + // A hand-rolled ToolDefinition (not via defineTool) whose check throws. + const raw: ToolDefinition = { + name: 'thrower', + description: 'classifier throws', + parameters: { type: 'object', properties: {} }, + isConcurrencySafe() { throw new Error('boom') }, + async execute() { return [] }, + } + ctx.tools.register(raw) + expect(ctx.tools.executionMode(exec('thrower', {}))).toEqual({ kind: 'exclusive' }) + }) + + it('a raw definition (no defineTool) receives the raw parsed value', async () => { + const ctx = await setup() + let seen: unknown + ctx.tools.register({ + name: 'raw-safe', + description: 'raw', + parameters: { type: 'object', properties: {} }, + isConcurrencySafe(args) { seen = args; return true }, + async execute() { return [] }, + }) + expect(ctx.tools.executionMode(exec('raw-safe', { anything: 1 }))).toEqual({ kind: 'parallel' }) + expect(seen).toEqual({ anything: 1 }) + }) + + it('isConcurrencySafe never reaches the model-facing schemas() projection', async () => { + const ctx = await setup() + ctx.tools.register(defineTool({ + name: 'safe', + description: 'parallel-safe', + parameters: { x: { type: 'string', required: true } }, + isConcurrencySafe: () => true, + async execute() { return [] }, + })) + const schema = ctx.tools.schemas()[0] as unknown as Record + expect(Object.keys(schema).sort()).toEqual(['description', 'name', 'parameters']) + expect(schema.isConcurrencySafe).toBeUndefined() + }) + + it('ToolExecutionMode is the object-tagged union', () => { + expectTypeOf().toEqualTypeOf<{ kind: 'parallel' } | { kind: 'exclusive' }>() + }) +}) diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index fedd1ff7a2..5817c0eb95 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -46,4 +46,6 @@ The tool passes `exec` (the tool-execution context) as the opaque `actor` on eve `fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event. +This is exactly why `read` declares `isConcurrencySafe: () => true` while `write`/`edit` do not: `read`'s only side effect is that synchronous commutative recorder (same-target concurrent reads converge to one observed version), so the agent loop may run sibling reads in parallel. `write`/`edit` mutate the filesystem and stay exclusive barriers — the provider re-checks the observed version inside its per-target lock before mutating, so a stale read never corrupts (it only forces a re-read). See the [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md). + The read rendering (line windowing + output formatting) lives in `src/read-render.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them. diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index 039d8742e9..dc43177726 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -92,6 +92,11 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' }, limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${caps.limit}.` }, }, + // Read-only. Its one side effect is the synchronous, commutative `fs/observed` + // version recorder (a WeakMap write; see below and the fs-policy plugin), so + // concurrent same-target reads converge to one observed version. write/edit + // stay exclusive barriers and re-check versions in-lock before mutating. + isConcurrencySafe: () => true, async execute(args, exec): Promise { const input = parseReadArgs(args, caps.limit) const cwd = sessionCwd(exec) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 53f912cce2..e394e45452 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -106,6 +106,16 @@ describe('registration', () => { expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['edit', 'read', 'write']) }) + it('declares read parallel-safe while write/edit remain exclusive', async () => { + const { ctx } = await setup() + expect(ctx.tools.executionMode({ callId: CallId('read-safe'), name: 'read', arguments: { file_path: 'a.txt' } })) + .toEqual({ kind: 'parallel' }) + expect(ctx.tools.executionMode({ callId: CallId('write-exclusive'), name: 'write', arguments: { file_path: 'a.txt', content: 'x' } })) + .toEqual({ kind: 'exclusive' }) + expect(ctx.tools.executionMode({ callId: CallId('edit-exclusive'), name: 'edit', arguments: { file_path: 'a.txt', old_string: 'x', new_string: 'y' } })) + .toEqual({ kind: 'exclusive' }) + }) + it('registers prompt sections for each tool', async () => { const { ctx } = await setup() const prompt = renderPrompt(await ctx.systemPrompt.assemble()) diff --git a/packages/subagent/README.md b/packages/subagent/README.md index 87930167e9..eb491b8f77 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -14,4 +14,6 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a pure library — both depend on it, neither on the other), the out-of-process `subagent-acp` backend builds on the `subagent-subprocess` library (the credential env scrub, the dispose ladder, isolated config dirs) and ships alongside them here; the test-only `dsh-subagent-mock` (in [support](../support/README.md)) is separate. All **product** packages except the mock. +`SubagentProvider.start()` must be safe to call concurrently for independent runs: the `subagent` tool is parallel-safe, so one parent step may issue several subagent calls at once. Each backend reads the parent synchronously at start (a snapshot, never mutated or re-read during the run) — `fork` seeds each child from the parent's completed-turn prefix, which the open in-flight turn cannot change, so concurrent forks inside one open step all see the same stable prefix. A resource-limited provider may queue or cap internally, but must not require the loop to serialize every call. + The proposal and design rationale: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md). diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index fb512c0bdb..cb19b27d34 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -182,6 +182,19 @@ export interface SubagentProvider { * Start a child run. The service has already validated that every requested * start-time capability is supported, so an implementation may assume e.g. * `request.maxDepth` is honorable when present. + * + * MUST be safe to call concurrently for independent runs: the `subagent` tool + * is parallel-safe, so a parent step may issue several subagent calls at once, + * each invoking `start()` before an earlier run settles. An implementation + * reads the parent SYNCHRONOUSLY at start (a snapshot — never mutating or + * re-reading it during the run) so concurrent starts inside the parent's one + * open step all observe the same stable state; the fork backend seeds each + * child from the parent's completed-turn prefix, which the open in-flight turn + * cannot change. A provider backed by a limited resource may queue internally, + * apply its own capacity cap, or return a typed failure for the affected run — + * but it must NOT require the parent loop to serialize every `subagent` call. + * @param request - the start request (prompt, parent, and any start-time options). + * @returns the started {@link SubagentRun}. */ start(request: SubagentStartRequest): SubagentRun } diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 6fe26d3083..936965bc72 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -21,3 +21,7 @@ The tool description and the `prompt` parameter description are DERIVED from the `execute` starts a run on the configured provider and **awaits `run.result` inside a `try/finally` that always `dispose()`s the run** — the owned child agent/session is torn down on every path (success, error, abort), never leaked. The tool's abort signal (`exec.signal`) is bridged to `run.cancel()`. A non-`completed` stop reason (aborted/error/max-tokens/refusal) maps to an `isError` tool result rather than returning partial output as success. Background / poll collection is deferred (see the [RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md)); this cut blocks the parent turn until the child finishes. + +## Concurrency + +The tool declares `isConcurrencySafe: () => true`: each call starts an independent child run and returns only its final answer, touching no parent-agent state, and `SubagentProvider.start()` is contractually concurrent-safe for independent runs (see [subagent/](../README.md)). So the agent loop may run several `subagent` calls from one assistant step in parallel, and the tool description tells the model it may issue independent tasks together when their work scopes do not overlap. The subagent tool stays synchronous (one result = the child's final answer); background spawning + later collection is separate future work. diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index f48ef4345e..e487d49046 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -119,7 +119,8 @@ export function providerWording(inherits: boolean): { description: string; promp + 'completed turns so far (it does not see the current in-flight turn), returning only its final ' + 'result. Use this when the subtask builds on this conversation\'s context — a follow-up analysis, ' + 'a review, a continuation — without consuming this conversation\'s context for the work itself. ' - + 'You receive only its final answer, not its intermediate steps.', + + 'You receive only its final answer, not its intermediate steps. You may issue several subagent ' + + 'calls in one message to run independent tasks concurrently when their work scopes do not overlap.', promptDescription: 'The task for the subagent. It already sees this conversation\'s completed turns, so build on them ' + 'freely and state only what is new.', @@ -131,7 +132,8 @@ export function providerWording(inherits: boolean): { description: string; promp + '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.', + + '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.', promptDescription: 'The complete, self-contained task for the subagent. It does not share this ' + 'conversation\'s context, so include everything it needs.', @@ -165,6 +167,12 @@ export function apply(ctx: Context, config: Config): void { description: wording.promptDescription, }, }, + // Each call starts an independent child run and returns only its final + // answer; the tool touches no parent-agent state. SubagentProvider.start() + // is contractually safe to call concurrently for independent runs (a + // resource-limited provider queues internally), so sibling subagent calls + // may run in parallel. + isConcurrencySafe: () => true, async execute(args, exec): Promise { const parent = exec.agent if (!parent) { diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 611069ef76..f08521ef20 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -68,6 +68,15 @@ describe('dsh-tool-subagent', () => { expect(Object.keys(props).sort()).toEqual(['description', 'prompt']) }) + it('declares each subagent call parallel-safe through the shared tool scheduler contract', async () => { + const ctx = await setup({ provider: 'mock' }) + expect(ctx.tools.executionMode({ + callId: CallId('subagent-safe'), + name: 'subagent', + arguments: { description: 'do work', prompt: 'Reply OK' }, + })).toEqual({ kind: 'parallel' }) + }) + it.each([ { stopReason: 'aborted' as const, fragment: 'cancelled' }, { stopReason: 'error' as const, fragment: 'failed' }, diff --git a/packages/web/tool-web/README.md b/packages/web/tool-web/README.md index ab1326a21e..858b91e397 100644 --- a/packages/web/tool-web/README.md +++ b/packages/web/tool-web/README.md @@ -11,6 +11,8 @@ Each tool is registered independently; a product that wants only one disables th | `web_search` | `query` (string) | Discovery. Returns an optional answer plus source URLs. `max_results` is **not** model-facing — the tool sets the bound (the `searchMaxResults` config, default 8) and passes it to the seam. | | `web_fetch` | `url` (string) | Retrieves a specific URL. HTML bodies are rendered to markdown-ish text; text bodies pass through. A non-2xx status is reported, not an error. The tool-call timeout is deployment policy (`dsh-timeout-policy`), not a model argument. | +Both tools declare `isConcurrencySafe: () => true` — they are read-only (fetch a provider/URL, return content, mutate no parent-agent state), so the agent loop may run sibling web calls in parallel. + ## Config | Key | Default | Meaning | diff --git a/packages/web/tool-web/src/fetch.ts b/packages/web/tool-web/src/fetch.ts index 571ce00797..9246134fc7 100644 --- a/packages/web/tool-web/src/fetch.ts +++ b/packages/web/tool-web/src/fetch.ts @@ -99,6 +99,9 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number): void { url: { type: 'string', required: true, description: 'The HTTP(S) URL to fetch.' }, }, timeoutMs, + // Read-only: fetching a URL returns content and mutates no parent-agent + // state — safe to run concurrently with sibling calls. + isConcurrencySafe: () => true, async execute(args, exec): Promise { const input = parseFetchArgs(args) const result = await ctx.web.fetch( diff --git a/packages/web/tool-web/src/search.ts b/packages/web/tool-web/src/search.ts index a7587d328b..c4e715e51e 100644 --- a/packages/web/tool-web/src/search.ts +++ b/packages/web/tool-web/src/search.ts @@ -109,6 +109,9 @@ export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs: query: { type: 'string', required: true, description: 'The search query.' }, }, timeoutMs, + // Read-only: a search hits the provider and returns content, mutating no + // parent-agent state — safe to run concurrently with sibling calls. + isConcurrencySafe: () => true, async execute(args, exec): Promise { const input = parseSearchArgs(args) const result = await ctx.web.search( diff --git a/packages/web/tool-web/tests/tool-web.spec.ts b/packages/web/tool-web/tests/tool-web.spec.ts index 4bb2728df7..543bd8e234 100644 --- a/packages/web/tool-web/tests/tool-web.spec.ts +++ b/packages/web/tool-web/tests/tool-web.spec.ts @@ -166,6 +166,10 @@ describe('tool-web registration', () => { const names = ctx.tools.schemas().map(s => s.name) expect(names).toContain('web_search') expect(names).toContain('web_fetch') + expect(ctx.tools.executionMode({ callId: CallId('search-safe'), name: 'web_search', arguments: { query: 'q' } })) + .toEqual({ kind: 'parallel' }) + expect(ctx.tools.executionMode({ callId: CallId('fetch-safe'), name: 'web_fetch', arguments: { url: 'https://a.test' } })) + .toEqual({ kind: 'parallel' }) await fiber.dispose() expect(ctx.tools.schemas().map(s => s.name)).not.toContain('web_search') }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 7c6666542b..951e5789fa 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -91,6 +91,7 @@ export const LINK_MAP: Record = { TurnEndReason: 'session.md', ToolDefinition: 'tools.md', ToolExecution: 'tools.md', + ToolExecutionMode: 'tools.md', ToolExecutionResult: 'tools.md', ApprovalOutcome: 'approval.md', ApprovalPolicy: 'approval.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index ec92045069..72470f5707 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -674,10 +674,14 @@ function renderLifecycle(): string { ` Session-->>SDK: ${mermaidCode('session/event')} ${mermaidCode('assistant/chunk')}*`, ` Driver->>Hooks: ${mermaidCode('agent/step-result')} waterfall`, ` Driver->>Session: ${mermaidCode('assistant/message')}`, - ` Driver->>Session: ${mermaidCode('tool/call')}`, - ' Driver->>Tools: execute through pre and post waterfalls', - ' Tools-->>Session: tool-owned events when applicable', - ` Driver->>Session: ${mermaidCode('tool/result')} and ${mermaidCode('step/end')}`, + ' Driver->>Tools: group calls by executionMode', + ' loop started tool calls (bounded pool)', + ` Driver->>Session: ${mermaidCode('tool/call')} pending audit`, + ' Driver->>Tools: ordered pre / pooled dispatch / ordered post', + ' Tools-->>Session: tool-owned events when applicable', + ' end', + ` Driver->>Session: ${mermaidCode('tool/result')} in model order`, + ` Driver->>Session: ${mermaidCode('step/end')}`, ` Driver->>Hooks: ${mermaidCode('agent/turn-continuation')} waterfall`, ` Driver->>Session: ${mermaidCode('turn/end')}`, ` Driver->>Persistence: ${mermaidCode('session/flush')} parallel checkpoint`, diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index e87d945497..3f2443ab79 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -40,6 +40,7 @@ { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaSpec", "source": "packages/core/tools/src/schema.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "InferArgs", "source": "packages/core/tools/src/schema.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionMode", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "PreToolDecision", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "PostToolDecision", "source": "packages/core/tools/src/index.ts" }, From fdf5e08548a8350c3ffb53eafc44c1d43ef65725 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 13 Jul 2026 11:55:51 +0800 Subject: [PATCH 02/14] test(tool-fs): cover stale read observation fail-closed --- packages/fs/tool-fs/tests/integration.spec.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index c0973197eb..3ee23a2590 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -398,6 +398,35 @@ describe('signal, concurrency, and the fs/observed contract', () => { expect(onDisk === 'ONE value here' || onDisk === 'base TWO here').toBe(true) }) + it('a stale observed version from an older read fails closed at edit CAS', async () => { + await writeFile(join(dir, 'a.txt'), 'older content\n') + const target = await ctx.fs.resolve('a.txt') + const firstInfo = await ctx.fs.stat(target) + if (!firstInfo) throw new Error('expected first stat') + + expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false) + + await writeFile(join(dir, 'a.txt'), 'newer current content\n') + const secondInfo = await ctx.fs.stat(target) + if (!secondInfo) throw new Error('expected second stat') + expect(secondInfo.version).not.toBe(firstInfo.version) + expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false) + + // Simulate an older concurrent read finishing last and overwriting the + // observed-state WeakMap with the stale version it saw before the external + // file change. The provider's in-lock CAS is still the safety boundary. + ctx.emit('fs/observed', target, firstInfo.version, { agent: { session } }) + + const edit = await callOwned('edit', { + file_path: 'a.txt', + old_string: 'newer', + new_string: 'edited', + }) + expect(edit.isError).toBe(true) + expect(edit.error).toMatchObject({ code: 'FS_STALE_VERSION' }) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('newer current content\n') + }) + it('a throwing fs/observed listener surfaces as isError, but the mutation already hit disk', async () => { // fs/observed is a plain ctx.emit AFTER the write succeeded; a throwing // listener cannot roll the write back — it only turns the tool result into From ca2dd34291447a68faad44a4b3bcb16dc417f6e3 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 13 Jul 2026 17:31:01 +0800 Subject: [PATCH 03/14] fix(agent-loop): fail closed on invalid parallel scheduling --- packages/core/agent-loop/src/tool-calls.ts | 8 +++++++ .../core/agent-loop/tests/tool-calls.spec.ts | 21 +++++++++++++++++++ packages/core/tools/src/index.ts | 3 ++- .../core/tools/tests/execution-mode.spec.ts | 13 ++++++++++++ 4 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/core/agent-loop/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts index a187283701..ae6f44d4dc 100644 --- a/packages/core/agent-loop/src/tool-calls.ts +++ b/packages/core/agent-loop/src/tool-calls.ts @@ -144,6 +144,13 @@ function groupByMode(ctx: Context, planned: PlannedCall[]): PlannedCall[][] { return groups } +/** Validate the live per-agent cap at the point it controls dispatch. */ +function assertMaxParallelToolCalls(maxParallel: number): void { + if (!Number.isInteger(maxParallel) || maxParallel < 1) { + throw new Error('maxParallelToolCalls must be a positive integer') + } +} + /** * The exclusive single-call path keeps the public one-call pipeline sequential: * abort-check, `tool/call`, pre/dispatch/post via `ctx.tools.execute`, @@ -196,6 +203,7 @@ async function runParallelGroup( ): Promise { /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) + assertMaxParallelToolCalls(maxParallel) const slots: (Slot | undefined)[] = group.map(() => undefined) // callSeqs[i] is the `tool/call` event seq for started slot i (its provenance diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index d664946bc4..cd4e202b9b 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -202,6 +202,27 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => })).rejects.toThrow('maxParallelToolCalls must be a positive integer') }) + it('fails loud if maxParallelToolCalls is mutated invalid after agent creation', async () => { + const adapter = new MockAdapter([ + multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]), + textResponse('must not run after unanswered tool calls'), + ]) + const ctx = await harness(adapter) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', maxParallelToolCalls: 2 }) + ;(agent.options as { maxParallelToolCalls: number }).maxParallelToolCalls = 0 + + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + + expect(gated.started).toEqual([]) + expect(adapter.requests).toHaveLength(1) + 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') + }) + it('starts at most the cap, replenishing as calls settle', async () => { const adapter = new MockAdapter([ multiCall([1, 2, 3, 4].map(n => ({ id: `c${n}`, name: 'p', args: { id: String(n) } }))), diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 92a6c20d92..f2eac7349b 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -971,7 +971,8 @@ export class ToolRegistry extends Service { const tool = this.get(exec.name, exec.agent) if (!tool?.isConcurrencySafe) return { kind: 'exclusive' } try { - return tool.isConcurrencySafe(exec.arguments) ? { kind: 'parallel' } : { kind: 'exclusive' } + const concurrencySafe: unknown = tool.isConcurrencySafe(exec.arguments) + return concurrencySafe === true ? { kind: 'parallel' } : { kind: 'exclusive' } } catch { return { kind: 'exclusive' } } diff --git a/packages/core/tools/tests/execution-mode.spec.ts b/packages/core/tools/tests/execution-mode.spec.ts index b4c686188b..ca33baa143 100644 --- a/packages/core/tools/tests/execution-mode.spec.ts +++ b/packages/core/tools/tests/execution-mode.spec.ts @@ -99,6 +99,19 @@ describe('ToolRegistry.executionMode', () => { expect(ctx.tools.executionMode(exec('thrower', {}))).toEqual({ kind: 'exclusive' }) }) + it('a truthy non-boolean classifier result fails closed to exclusive (raw definition)', async () => { + const ctx = await setup() + const raw = { + name: 'truthy', + description: 'classifier returns a truthy string', + parameters: { type: 'object', properties: {} }, + isConcurrencySafe() { return 'yes' }, + async execute() { return [] }, + } as unknown as ToolDefinition + ctx.tools.register(raw) + expect(ctx.tools.executionMode(exec('truthy', {}))).toEqual({ kind: 'exclusive' }) + }) + it('a raw definition (no defineTool) receives the raw parsed value', async () => { const ctx = await setup() let seen: unknown From 8c8e5fdd2432657dd5f530ace53e90a10114697a Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 13 Jul 2026 19:14:36 +0800 Subject: [PATCH 04/14] fix(agent-loop): validate parallel cap before logging calls --- ...2026-07-10-parallel-tool-call-execution.md | 2 ++ packages/core/agent-loop/src/loop.ts | 12 +++++++++--- packages/core/agent-loop/src/tool-calls.ts | 19 +++++++++++++++++-- .../core/agent-loop/tests/tool-calls.spec.ts | 1 + 4 files changed, 29 insertions(+), 5 deletions(-) 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') From eae8b8ce2e9109d1bf827d7c3d2c2804c38d90d4 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 14 Jul 2026 11:55:39 +0800 Subject: [PATCH 05/14] feat(ui): configure maxParallelToolCalls for factory-created agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The acp and stdio-agent plugins only forwarded `model` into their created agents, so every factory/ACP deployment was pinned to the agent-loop default parallel cap with no cordis.yml override. Add a `maxParallelToolCalls` config field (positive-integer validated) to both, threaded through the existing per-agent options path — symmetric with `model`. --- docs/config-catalog.md | 12 ++++++++++++ packages/ui/acp/README.md | 1 + packages/ui/acp/src/index.ts | 16 +++++++++++++--- packages/ui/acp/tests/stream-update.spec.ts | 2 ++ packages/ui/stdio-agent/README.md | 1 + packages/ui/stdio-agent/src/index.ts | 10 ++++++++++ .../ui/stdio-agent/tests/stdio-agent.spec.ts | 11 +++++++++++ 7 files changed, 50 insertions(+), 3 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3ef3f6fad7..0a242d31b1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -18,6 +18,12 @@ Requires: `agents` · `sessions` · `sessionPersistence` · `tools` · `userInte export interface AcpConfig { /** Model name for created agents (must have a registered adapter). */ model?: string + /** + * Maximum tool calls each created agent runs concurrently within one assistant + * step (a positive integer; the agent loop defaults it when omitted). `1` + * preserves fully serial execution. + */ + maxParallelToolCalls?: number /** * Transport stream override. Production omits this (the plugin wires * `process.stdin`/`process.stdout` via `ndJsonStream`). Tests inject an @@ -591,6 +597,12 @@ Source: [`packages/skill/skill-local/src/index.ts:39`](../packages/skill/skill-l export interface Config { /** Model name for the `main` agent (must have a registered adapter). */ model: string + /** + * Maximum tool calls the `main` agent runs concurrently within one assistant + * step (a positive integer; the agent loop defaults it when omitted). `1` + * preserves fully serial execution. + */ + maxParallelToolCalls?: number /** Deployment persona (the system-prompt plugin's `persona` config). */ persona?: string /** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */ diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index f4947e5d25..1043b13004 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -15,6 +15,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline ` | Key | Default | Meaning | |---|---|---| | `model` | — | Model name for created agents (must have a registered adapter). | +| `maxParallelToolCalls` | (agent-loop default) | Positive integer cap on tool calls each created agent runs concurrently within one assistant step; `1` is fully serial. | (No persona key: `dsh-system-prompt`'s own `persona` config supplies the global default section, so ACP-created agents render it without the bridge carrying prompt text. An agent-scoped same-name section may still shadow that default.) diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 5a5ce9e51f..ade9e0ccd0 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -248,6 +248,12 @@ function stringArrayContent( export interface AcpConfig { /** Model name for created agents (must have a registered adapter). */ model?: string + /** + * Maximum tool calls each created agent runs concurrently within one assistant + * step (a positive integer; the agent loop defaults it when omitted). `1` + * preserves fully serial execution. + */ + maxParallelToolCalls?: number /** * Transport stream override. Production omits this (the plugin wires * `process.stdin`/`process.stdout` via `ndJsonStream`). Tests inject an @@ -260,6 +266,9 @@ export interface AcpConfig { export const Config: Schema = Schema.object({ model: Schema.string(), + // A positive integer; a bad value (0, negative, fractional) fails config + // validation here rather than being silently dropped from cordis.yml. + maxParallelToolCalls: Schema.number().step(1).min(1), }) /** @@ -1010,12 +1019,13 @@ export function apply(ctx: Context, config: AcpConfig): void { * Build per-agent options from the plugin config, omitting absent fields * (exactOptionalPropertyTypes: never assign `undefined` to an optional key). * Exported for unit coverage of both the present and absent branches. - * @param config - the plugin config carrying the optional model name. - * @returns the per-agent options, with `model` present only when configured. + * @param config - the plugin config carrying the optional model name and parallel cap. + * @returns the per-agent options, with each field present only when configured. */ -export function agentOptions(config: AcpConfig): { model?: string } { +export function agentOptions(config: AcpConfig): { model?: string; maxParallelToolCalls?: number } { return { ...config.model !== undefined ? { model: config.model } : {}, + ...config.maxParallelToolCalls !== undefined ? { maxParallelToolCalls: config.maxParallelToolCalls } : {}, } } diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index cb3eab3545..afd3e22686 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -818,5 +818,7 @@ describe('agentOptions', () => { it('includes only the fields present in config', () => { expect(agentOptions({})).toEqual({}) expect(agentOptions({ model: 'm' })).toEqual({ model: 'm' }) + expect(agentOptions({ maxParallelToolCalls: 3 })).toEqual({ maxParallelToolCalls: 3 }) + expect(agentOptions({ model: 'm', maxParallelToolCalls: 1 })).toEqual({ model: 'm', maxParallelToolCalls: 1 }) }) }) diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md index 27f0d20be7..448089e904 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/ui/stdio-agent/README.md @@ -26,6 +26,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte | Key | Default | Routed to | |---|---|---| | `model` | (required) | the pre-created `main` agent's model | +| `maxParallelToolCalls` | (agent-loop default) | positive integer cap on tool calls the `main` agent runs concurrently within one assistant step (`1` is fully serial), routed to `dsh-agent-loop` | | `persona` | — | the deployment persona template (may reference `{{model}}`), routed to `dsh-system-prompt` | | `toolOrder` | — | explicit model-facing tool order (a name list with one `''` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | diff --git a/packages/ui/stdio-agent/src/index.ts b/packages/ui/stdio-agent/src/index.ts index 1037c28112..6535c5b054 100644 --- a/packages/ui/stdio-agent/src/index.ts +++ b/packages/ui/stdio-agent/src/index.ts @@ -65,6 +65,12 @@ export const name = 'stdio-agent' export interface Config { /** Model name for the `main` agent (must have a registered adapter). */ model: string + /** + * Maximum tool calls the `main` agent runs concurrently within one assistant + * step (a positive integer; the agent loop defaults it when omitted). `1` + * preserves fully serial execution. + */ + maxParallelToolCalls?: number /** Deployment persona (the system-prompt plugin's `persona` config). */ persona?: string /** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */ @@ -87,6 +93,9 @@ export interface Config { export const Config: z = z.object({ model: z.string().required(), + // A positive integer; a bad value (0, negative, fractional) fails config + // validation here rather than being silently dropped from cordis.yml. + maxParallelToolCalls: z.number().step(1).min(1), persona: z.string(), // The array default is forced to undefined: ABSENT means "lexicographic // order" (the owning dsh-system-prompt schema does the same), while @@ -116,6 +125,7 @@ export function apply(ctx: Context, config: Config): void { id: AgentId('main'), model: config.model, cwd: process.cwd(), + ...config.maxParallelToolCalls !== undefined ? { maxParallelToolCalls: config.maxParallelToolCalls } : {}, ...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {}, }], ...config.skills !== undefined ? { skills: config.skills } : {}, diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts index 0668d25fb0..c5f9c075f6 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -136,6 +136,17 @@ describe('dsh-stdio-agent app', () => { await ctx.fiber.dispose() }) + it('forwards maxParallelToolCalls onto the pre-created agent when set', async () => { + const ctx = await mount({ + model: 'mock', + maxParallelToolCalls: 3, + persistenceRoot: '/tmp/dsh-stdio-agent-spec-parallel', + skills: await isolatedSkillsConfig(), + }) + expect(ctx.get('agents')?.get(AgentId('main'))?.options.maxParallelToolCalls).toBe(3) + await ctx.fiber.dispose() + }) + it('exposes its name and Config schema', () => { expect(stdioAgent.name).toBe('stdio-agent') expect(stdioAgent.Config).toBeDefined() From 5ab8f2e328f0f9b762b9a4fd1697786c681d868d Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 14 Jul 2026 11:56:27 +0800 Subject: [PATCH 06/14] docs: correct fs/observed concurrency-safety wording The read tool's isConcurrencySafe rationale called the fs/observed recorder "commutative" and said concurrent reads "converge to one observed version", overstating the guarantee: the WeakMap record is last-writer-wins. Safety comes from write/edit re-checking the version in their in-lock CAS (a stale observation only forces a later edit to fail closed with FS_STALE_VERSION), as the RFC already states. Align the read comment, the ToolDefinition JSDoc, both READMEs, and the regenerated catalogs. --- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/tools.md | 6 ++++-- packages/core/tools/README.md | 2 +- packages/core/tools/src/index.ts | 6 ++++-- packages/fs/tool-fs/README.md | 2 +- packages/fs/tool-fs/src/read.ts | 10 ++++++---- 7 files changed, 18 insertions(+), 12 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0a242d31b1..b06942002d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -975,7 +975,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:482`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:484`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-user-approval` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e8ab41247f..8b427f9bff 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -277,7 +277,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:574`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:576`](../../packages/core/tools/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index bc231d6b2e..2e4a269537 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -40,8 +40,10 @@ interface ToolDefinition extends ToolSchema { * 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). + * updates are commutative OR fail closed for concurrent calls by the same + * session (the `fs/observed` version recorder is the worked example: its + * WeakMap record is last-writer-wins, and a stale observation only makes a + * later write/edit fail closed at its in-lock version CAS). */ isConcurrencySafe?(args: unknown): boolean /** diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 8866de6d8c..4f29f4b6e1 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -85,7 +85,7 @@ See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, an `defineTool` also validates an optional `timeoutMs` at definition time when present: it must be a positive finite number, or the helper throws — the budget is attached to the produced `ToolDefinition` (for `@deepseek-ai/dsh-timeout-policy`) and never reaches the model. -`defineTool` also accepts an optional `isConcurrencySafe(args): boolean` — the per-call concurrency classifier the agent-loop scheduler reads via `executionMode`. `args` is the typed `InferArgs` shape. It is soft-validated exactly like the presenters: an arg mismatch yields `false` (the conservative exclusive default), never the hard `ToolArgsError`. Declaring `true` is a contract — the tool body must not mutate parent-owned async state (`exec.agent.session.append`, `agent.inject`) during `execute`; its only ordered outputs are the returned content, `meta`, error, and `additionalContext`. The one exception is a synchronous, side-effect-only commutative recorder (the `fs/observed` version recorder is the worked example); anything richer stays exclusive. Host-only, never model-visible. +`defineTool` also accepts an optional `isConcurrencySafe(args): boolean` — the per-call concurrency classifier the agent-loop scheduler reads via `executionMode`. `args` is the typed `InferArgs` shape. It is soft-validated exactly like the presenters: an arg mismatch yields `false` (the conservative exclusive default), never the hard `ToolArgsError`. Declaring `true` is a contract — the tool body must not mutate parent-owned async state (`exec.agent.session.append`, `agent.inject`) during `execute`; its only ordered outputs are the returned content, `meta`, error, and `additionalContext`. The one exception is a synchronous, side-effect-only recorder whose updates are commutative or fail closed (the `fs/observed` version recorder is the worked example: its record is last-writer-wins, and a stale observation only makes a later write/edit fail closed at its in-lock version CAS); anything richer stays exclusive. Host-only, never model-visible. ### Structured-output schema subset diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index f2eac7349b..0a26ff7089 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -217,8 +217,10 @@ export interface ToolDefinition extends ToolSchema { * 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). + * updates are commutative OR fail closed for concurrent calls by the same + * session (the `fs/observed` version recorder is the worked example: its + * WeakMap record is last-writer-wins, and a stale observation only makes a + * later write/edit fail closed at its in-lock version CAS). */ isConcurrencySafe?(args: unknown): boolean /** diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 5817c0eb95..7cd9b3d641 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -46,6 +46,6 @@ The tool passes `exec` (the tool-execution context) as the opaque `actor` on eve `fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event. -This is exactly why `read` declares `isConcurrencySafe: () => true` while `write`/`edit` do not: `read`'s only side effect is that synchronous commutative recorder (same-target concurrent reads converge to one observed version), so the agent loop may run sibling reads in parallel. `write`/`edit` mutate the filesystem and stay exclusive barriers — the provider re-checks the observed version inside its per-target lock before mutating, so a stale read never corrupts (it only forces a re-read). See the [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md). +This is exactly why `read` declares `isConcurrencySafe: () => true` while `write`/`edit` do not: `read`'s only side effect is that synchronous version recorder (same-target concurrent reads race last-writer-wins on the observed version), so the agent loop may run sibling reads in parallel. `write`/`edit` mutate the filesystem and stay exclusive barriers — the provider re-checks the observed version inside its per-target lock before mutating, so a stale read never corrupts (it only forces a re-read via `FS_STALE_VERSION`). See the [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md). The read rendering (line windowing + output formatting) lives in `src/read-render.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them. diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index dc43177726..ad60dc7afb 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -92,10 +92,12 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' }, limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${caps.limit}.` }, }, - // Read-only. Its one side effect is the synchronous, commutative `fs/observed` - // version recorder (a WeakMap write; see below and the fs-policy plugin), so - // concurrent same-target reads converge to one observed version. write/edit - // stay exclusive barriers and re-check versions in-lock before mutating. + // Read-only. Its one side effect is the synchronous `fs/observed` version + // recorder (a WeakMap write; see below and the fs-policy plugin): concurrent + // same-target reads race last-writer-wins on that record, which is safe because + // it is NOT the safety boundary — write/edit stay exclusive barriers and + // re-check the version in-lock, so a stale observation only makes a later edit + // fail closed with FS_STALE_VERSION. isConcurrencySafe: () => true, async execute(args, exec): Promise { const input = parseReadArgs(args, caps.limit) From 3b1d1bfa1207ebd9344fe87115102e9b10863e5e Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 16 Jul 2026 11:36:16 +0800 Subject: [PATCH 07/14] refactor(agent-loop): unify tool-call scheduling on one rolling pool + factory cap default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run every ordered group through the same rolling pool: an exclusive call is a pool of one (a barrier), dropping the separate runExclusive path and the redundant post-grouping executionMode re-query. Behavior is unchanged — the parallel-tool-calls snapshot and the full scheduler unit suite (barriers, cap, abort, model-order results) stay green. Add AgentLoop.Config.maxParallelToolCalls as a factory-wide default applied to every agent create/createAgent/resume mints (per-agent option overrides it), forwarded through agent-core so it reaches front doors that expose no cap field of their own. Trim the isConcurrencySafe JSDoc to the local contract and link the parallel-tool-call RFC for the full rationale; document the field on the canonical core-data-structures page. --- docs/config-catalog.md | 17 ++++- docs/cordis-catalog/services.md | 4 +- docs/core-data-structures/core.md | 2 +- docs/core-data-structures/tools.md | 22 +++---- ...2026-07-10-parallel-tool-call-execution.md | 6 +- packages/core/agent-core/README.md | 6 +- packages/core/agent-core/src/index.ts | 10 ++- .../core/agent-core/tests/agent-core.spec.ts | 10 +++ packages/core/agent-loop/src/index.ts | 44 +++++++++++-- packages/core/agent-loop/src/tool-calls.ts | 65 +++++-------------- .../core/agent-loop/tests/tool-calls.spec.ts | 42 ++++++++++++ packages/core/tools/src/index.ts | 22 +++---- 12 files changed, 162 insertions(+), 88 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9a586fa495..dbee6b350f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -76,6 +76,11 @@ Source: [`packages/ui/acp-agent/src/index.ts:31`](../packages/ui/acp-agent/src/i export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ agents?: AgentLoopConfig['agents'] + /** + * The factory-wide default concurrent tool-call cap applied to every agent + * this bundle creates (see dsh-agent-loop's `Config.maxParallelToolCalls`). + */ + maxParallelToolCalls?: AgentLoopConfig['maxParallelToolCalls'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ @@ -108,6 +113,16 @@ Requires: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` ```ts config-catalog /** Plugin configuration for declarative startup agents. */ export interface Config { + /** + * Default concurrent tool-call cap applied to every agent this factory + * creates (declarative startup agents and factory callers such as the ACP, + * stdio, and SDK front doors that go through `create`/`createAgent`/`resume`). + * A positive integer; a per-agent `maxParallelToolCalls` overrides it, and an + * agent with neither falls back to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}. + * This is the single `cordis.yml` knob that reaches agents whose front door + * does not expose its own cap field. + */ + maxParallelToolCalls?: number /** Agents created or resumed at plugin startup. */ agents: (AgentOptions & { /** Registry identity for the live agent. */ @@ -964,7 +979,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:391`](../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` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 864a89dbfa..830184e804 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -19,7 +19,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:364`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:374`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` @@ -257,7 +257,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:447`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:445`](../../packages/core/tools/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 9a9a1706fc..42c11a84f0 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -347,7 +347,7 @@ interface Agent { } ``` -`AgentStatus` is `'idle' | 'running' | 'disposed'`, and `AgentId` is branded. `AgentOptions` is merge-extensible and currently includes `model?`. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. +`AgentStatus` is `'idle' | 'running' | 'disposed'`, and `AgentId` is branded. `AgentOptions` is merge-extensible and currently includes `model?` and `maxParallelToolCalls?` (the loop's per-agent concurrent tool-call cap; the owning field, defaulted by `AgentLoop.Config` and, absent that, by `DEFAULT_MAX_PARALLEL_TOOL_CALLS`). Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits. diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 078e10ac29..5d4973ffe8 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -30,20 +30,18 @@ interface ToolDefinition extends ToolSchema { * * 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`. + * returns `false` on invalid args). 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- + * Declaring `true` is a contract: during `execute` the tool body must NOT + * mutate the parent agent's session or other parent-owned async state (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 OR fail closed for concurrent calls by the same - * session (the `fs/observed` version recorder is the worked example: its - * WeakMap record is last-writer-wins, and a stale observation only makes a - * later write/edit fail closed at its in-lock version CAS). + * `additionalContext` on the loop's ordered post-execute path. A synchronous, + * side-effect-only recorder whose updates are commutative or fail closed for + * concurrent same-session calls is the one exception (`fs/observed` is the + * worked example). Full contract and rationale: the parallel-tool-call RFC + * (docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md). */ isConcurrencySafe?(args: unknown): boolean /** 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 c8cd96622a..23e0f0d22e 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 @@ -45,11 +45,11 @@ A parallel-safe declaration is a contract. The tool body must not mutate the par 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. +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; grouping classifies each call exactly once. `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. +Parallelism is per agent. `AgentOptions.maxParallelToolCalls` is a positive integer, defaults to `DEFAULT_MAX_PARALLEL_TOOL_CALLS` (`10`), and reaches an agent three ways in precedence order: the per-agent option, the factory-wide `AgentLoop.Config.maxParallelToolCalls` applied to every agent the loop creates (declarative startup agents and factory callers such as the ACP, stdio, and SDK front doors), then the built-in default. The factory default is the single `cordis.yml` knob for agents whose front door exposes no cap field of its own. Setting the value to `1` preserves serial execution for that agent. The TypeScript `AgentOptions` vocabulary and both `AgentLoop.Config` fields 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. +Every group runs through the same 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. An exclusive group is a pool of one — a barrier — so the loop needs no separate serial path. 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. diff --git a/packages/core/agent-core/README.md b/packages/core/agent-core/README.md index fac4f4819a..594c4a8c82 100644 --- a/packages/core/agent-core/README.md +++ b/packages/core/agent-core/README.md @@ -39,11 +39,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement ```ts import type { Config } from '@deepseek-ai/dsh-agent-core' -// { agents?, persona?, toolOrder?, tools?, skills? } — the schema intersects the owner schemas, -// so validation and defaulting can never drift from the owners. +// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, skills? } — the schema +// intersects the owner schemas, so validation and defaulting can never drift from the owners. ``` -The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; and `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure. +The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `maxParallelToolCalls` to `agent-loop` as the factory-wide default concurrent tool-call cap for every agent it creates; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; and `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure. ## Why a code bundle, not a shared YAML include diff --git a/packages/core/agent-core/src/index.ts b/packages/core/agent-core/src/index.ts index 14ffbd6133..fcb4e87f1e 100644 --- a/packages/core/agent-core/src/index.ts +++ b/packages/core/agent-core/src/index.ts @@ -46,6 +46,11 @@ export interface SkillConfig { export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ agents?: AgentLoopConfig['agents'] + /** + * The factory-wide default concurrent tool-call cap applied to every agent + * this bundle creates (see dsh-agent-loop's `Config.maxParallelToolCalls`). + */ + maxParallelToolCalls?: AgentLoopConfig['maxParallelToolCalls'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ @@ -95,5 +100,8 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(invariants) ctx.plugin(toolBash) ctx.plugin(toolSkill, config.skills?.tool ?? {}) - ctx.plugin(AgentLoop, { agents: config.agents ?? [] }) + ctx.plugin(AgentLoop, { + agents: config.agents ?? [], + ...config.maxParallelToolCalls !== undefined ? { maxParallelToolCalls: config.maxParallelToolCalls } : {}, + }) } diff --git a/packages/core/agent-core/tests/agent-core.spec.ts b/packages/core/agent-core/tests/agent-core.spec.ts index 0bf0660364..fe240da8ed 100644 --- a/packages/core/agent-core/tests/agent-core.spec.ts +++ b/packages/core/agent-core/tests/agent-core.spec.ts @@ -117,6 +117,16 @@ describe('dsh-agent-core bundle', () => { await ctx.fiber.dispose() }) + it('forwards the factory-wide maxParallelToolCalls default to agents without a per-agent cap', async () => { + const ctx = await mount({ + agents: [{ id: AgentId('main'), model: 'mock' }], + maxParallelToolCalls: 3, + }) + const main = ctx.get('agents')?.get(AgentId('main')) + expect(main?.options.maxParallelToolCalls).toBe(3) + await ctx.fiber.dispose() + }) + it('tolerates a schema-bypassing direct apply (the ?? fallbacks fire)', async () => { // ctx.plugin validates + defaults the bundle config first; a direct apply // skips the schema, so the forwarding `?? []` / `?? ''` are what fire. diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 17447a0b1d..86df80cd8e 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -344,6 +344,16 @@ export { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts' /** Plugin configuration for declarative startup agents. */ export interface Config { + /** + * Default concurrent tool-call cap applied to every agent this factory + * creates (declarative startup agents and factory callers such as the ACP, + * stdio, and SDK front doors that go through `create`/`createAgent`/`resume`). + * A positive integer; a per-agent `maxParallelToolCalls` overrides it, and an + * agent with neither falls back to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}. + * This is the single `cordis.yml` knob that reaches agents whose front door + * does not expose its own cap field. + */ + maxParallelToolCalls?: number /** Agents created or resumed at plugin startup. */ agents: (AgentOptions & { /** Registry identity for the live agent. */ @@ -366,6 +376,9 @@ export class AgentLoop extends Service implements AgentFactory { /** Runtime schema for declarative agents. */ static Config = z.object({ + // The factory-wide default cap; a per-agent value overrides it. A positive + // integer, validated here so a bad cordis.yml value fails at load. + maxParallelToolCalls: z.number().step(1).min(1), agents: z.array(z.object({ id: z.string().required(), model: z.string(), @@ -410,6 +423,22 @@ export class AgentLoop extends Service implements AgentFactory { } } + /** + * Merge the factory-wide default cap into one agent's options. A per-agent + * `maxParallelToolCalls` wins; otherwise the `Config.maxParallelToolCalls` + * default applies, reaching factory callers (ACP/stdio/SDK front doors) whose + * own config does not set a cap. Absent both, the loop falls back to + * {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS} at schedule time. + * @param options - the caller-supplied agent options. + * @returns options with the default cap applied when the caller omitted one. + */ + private withFactoryDefaults(options: AgentOptions): AgentOptions { + if (options.maxParallelToolCalls !== undefined || this.config.maxParallelToolCalls === undefined) { + return options + } + return { ...options, maxParallelToolCalls: this.config.maxParallelToolCalls } + } + /** * Create an agent on a fresh per-run session, owned by the accessing fiber. * Constructor-driven config calls use the loop fiber itself. @@ -419,13 +448,14 @@ export class AgentLoop extends Service implements AgentFactory { * @returns the published running agent. */ create(id: AgentId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent { - validateAgentOptions(options) + const resolved = this.withFactoryDefaults(options) + validateAgentOptions(resolved) const loopCtx = this.runtime.ctx const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id) try { const sessionId = SessionId(`${id}-session-${randomUUID()}`) const session = loopCtx.sessions.prepare(sessionId, { meta }) - const agent = transaction.prepare(options, session) + const agent = transaction.prepare(resolved, session) transaction.publish('startup') return agent } catch (error: unknown) { @@ -443,7 +473,8 @@ export class AgentLoop extends Service implements AgentFactory { * @returns the published handle. */ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise { - validateAgentOptions(options.agentOptions ?? {}) + const agentOptions = this.withFactoryDefaults(options.agentOptions ?? {}) + validateAgentOptions(agentOptions) const transaction = new AgentCreationTransaction( this.runtime.ctx, ownerCtx, @@ -456,7 +487,7 @@ export class AgentLoop extends Service implements AgentFactory { ...options.seed === undefined ? {} : { seed: options.seed }, ...options.meta === undefined ? {} : { meta: options.meta }, }) - const agent = transaction.prepare(options.agentOptions ?? {}, session) + const agent = transaction.prepare(agentOptions, session) await transaction.waitFor(options.setup?.(agent.ctx)) transaction.assertActive() return transaction.publish('startup') @@ -475,7 +506,6 @@ export class AgentLoop extends Service implements AgentFactory { * @returns the published handle. */ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise { - validateAgentOptions(options.agentOptions ?? {}) const persistence = this.runtime.ctx.get('sessionPersistence') if (persistence === undefined) { throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)') @@ -489,6 +519,8 @@ export class AgentLoop extends Service implements AgentFactory { persistence: SessionPersistence, options: ResumeAgentOptions, ): Promise { + const agentOptions = this.withFactoryDefaults(options.agentOptions ?? {}) + validateAgentOptions(agentOptions) const transaction = new AgentCreationTransaction( this.runtime.ctx, ownerCtx, @@ -508,7 +540,7 @@ export class AgentLoop extends Service implements AgentFactory { ...loaded.meta.seedLength === undefined ? {} : { seedLength: loaded.meta.seedLength }, }, }) - const agent = transaction.prepare(options.agentOptions ?? {}, session) + const agent = transaction.prepare(agentOptions, session) await transaction.waitFor(options.setup?.(agent.ctx)) transaction.assertActive() return transaction.publish('resume') diff --git a/packages/core/agent-loop/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts index 6ee1dc5906..d117823a43 100644 --- a/packages/core/agent-loop/src/tool-calls.ts +++ b/packages/core/agent-loop/src/tool-calls.ts @@ -3,8 +3,9 @@ * the assistant message's `tool-call` blocks; this module parses each call's * arguments once, classifies it via `ctx.tools.executionMode`, partitions the * calls into ordered groups (one exclusive call, or a run of consecutive - * parallel-safe calls), and executes each group — a parallel group through a - * rolling pool bounded by the agent's `maxParallelToolCalls`. + * parallel-safe calls), and runs every group through the same rolling pool + * bounded by the agent's `maxParallelToolCalls` — an exclusive group is a pool + * of one. * * 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 @@ -95,16 +96,13 @@ export async function executeToolCalls( // separate ordered groups (no read/write race inside one assistant step). const groups = groupByMode(ctx, planned) + // Every group runs through the same rolling pool: an exclusive call is a + // singleton group (pool of one, a barrier), a parallel-safe run is one group + // bounded by the cap. `groupByMode` already classified each call, so the loop + // does not re-query `executionMode` here. const pendingContext: HookContext[] = [] for (const group of groups) { - // Groups are never empty (groupByMode only pushes non-empty runs/singletons). - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- non-empty group - const first = group[0]! - if (group.length === 1 && ctx.tools.executionMode(first.exec).kind === 'exclusive') { - await runExclusive(ctx, session, turn, step, first, signal, pendingContext) - } else { - await runParallelGroup(ctx, session, turn, step, group, signal, maxParallel, pendingContext) - } + await runGroup(ctx, session, turn, step, group, signal, maxParallel, pendingContext) } return pendingContext } @@ -135,8 +133,8 @@ function parseArguments(raw: string): unknown { /** * Group planned calls into ordered runs: each exclusive call is a singleton * group; consecutive parallel-safe calls coalesce into one group. `executionMode` - * is queried once per call here and again by the caller to pick the exclusive - * fast-path — both reads are pure and cheap. + * is the sole classification point — the caller runs every group through the + * rolling pool without re-querying it. The read is pure and cheap. */ function groupByMode(ctx: Context, planned: PlannedCall[]): PlannedCall[][] { const groups: PlannedCall[][] = [] @@ -167,46 +165,19 @@ function assertMaxParallelToolCalls(maxParallel: number): void { } /** - * The exclusive single-call path keeps the public one-call pipeline sequential: - * abort-check, `tool/call`, pre/dispatch/post via `ctx.tools.execute`, - * `tool/result`, buffer context, post-await abort-check. - */ -async function runExclusive( - ctx: Context, - session: Session, - turn: number, - step: number, - call: PlannedCall, - signal: AbortSignal, - pendingContext: HookContext[], -): Promise { - /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ - if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) - const callSeq = appendToolCall(session, turn, step, call.block) - const result = await ctx.tools.execute(call.exec) - appendToolResult(session, turn, step, call.block, result, callSeq) - if (result.additionalContext) pendingContext.push(result.additionalContext) - // signal CAN flip during the await above (abort() inside a tool); the analyzer - // can't see through the await boundary. - /* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */ - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) - /* v8 ignore stop */ -} - -/** - * The rolling-pool path for a group of parallel-safe calls. Starts calls in - * model order up to `maxParallel`, and whenever one settles starts the next - * unstarted call until the group is exhausted. Settled dispatches land in - * model-order slots; a commit cursor appends `tool/result` (and collects - * `additionalContext`) only while the next slot is ready, so the log stays - * model-ordered regardless of completion order. + * 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`, and whenever one settles starts the next unstarted call + * until the group is exhausted. Settled dispatches land in model-order slots; a + * commit cursor appends `tool/result` (and collects `additionalContext`) 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. */ -async function runParallelGroup( +async function runGroup( ctx: Context, session: Session, turn: number, diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 987b358720..a270ad6da1 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -274,6 +274,48 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => gated.release('2') await waitForIdle(ctx, agent) }) + + it('applies the factory-wide Config default to agents that set no per-agent cap', async () => { + const adapter = new MockAdapter([ + multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]), + textResponse('done'), + ]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + // Factory default of 1 (no per-agent cap set below) must serialize. + await ctx.plugin(AgentLoop, { agents: [], maxParallelToolCalls: 1 }) + ctx.llm.registerAdapter(['mock'], adapter) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + expect(agent.options.maxParallelToolCalls).toBe(1) + + agent.send([{ type: 'text', text: 'go' }]) + await until(() => gated.started.length === 1) + await new Promise(r => setTimeout(r, 5)) + expect(gated.started).toEqual(['1']) + gated.release('1') + await until(() => gated.started.length === 2) + gated.release('2') + await waitForIdle(ctx, agent) + }) + + it('lets a per-agent cap override the factory-wide Config default', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [], maxParallelToolCalls: 1 }) + ctx.llm.registerAdapter(['mock'], new MockAdapter([])) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', maxParallelToolCalls: 4 }) + expect(agent.options.maxParallelToolCalls).toBe(4) + }) }) describe('tool-call scheduler: ordered middleware and additionalContext', () => { diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 3575554ac1..4f1741ab39 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -142,20 +142,18 @@ export interface ToolDefinition extends ToolSchema { * * 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`. + * returns `false` on invalid args). 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- + * Declaring `true` is a contract: during `execute` the tool body must NOT + * mutate the parent agent's session or other parent-owned async state (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 OR fail closed for concurrent calls by the same - * session (the `fs/observed` version recorder is the worked example: its - * WeakMap record is last-writer-wins, and a stale observation only makes a - * later write/edit fail closed at its in-lock version CAS). + * `additionalContext` on the loop's ordered post-execute path. A synchronous, + * side-effect-only recorder whose updates are commutative or fail closed for + * concurrent same-session calls is the one exception (`fs/observed` is the + * worked example). Full contract and rationale: the parallel-tool-call RFC + * (docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md). */ isConcurrencySafe?(args: unknown): boolean /** From 91da66e7150f54a9cfaa3c9d9a7b19406b37d33f Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 16 Jul 2026 14:36:16 +0800 Subject: [PATCH 08/14] refactor(agent-loop): simplify parallel tool-call cap config --- docs/config-catalog.md | 30 ++--- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/core.md | 2 +- ...2026-07-10-parallel-tool-call-execution.md | 119 ++++++++---------- packages/core/agent-core/README.md | 2 +- packages/core/agent-core/src/index.ts | 4 +- .../core/agent-core/tests/agent-core.spec.ts | 5 +- packages/core/agent-loop/README.md | 4 +- packages/core/agent-loop/src/agent.ts | 10 +- packages/core/agent-loop/src/constants.ts | 8 +- packages/core/agent-loop/src/index.ts | 88 ++++--------- packages/core/agent-loop/src/loop.ts | 17 ++- packages/core/agent-loop/src/tool-calls.ts | 28 +---- packages/core/agent-loop/tests/agent.spec.ts | 22 +++- .../tests/contract-regressions.spec.ts | 6 +- .../core/agent-loop/tests/tool-calls.spec.ts | 91 ++++++-------- packages/ui/acp/README.md | 1 - packages/ui/acp/src/index.ts | 12 +- packages/ui/acp/tests/stream-update.spec.ts | 2 - packages/ui/stdio-agent/README.md | 2 +- packages/ui/stdio-agent/src/index.ts | 7 +- .../ui/stdio-agent/tests/stdio-agent.spec.ts | 4 +- 22 files changed, 182 insertions(+), 284 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dbee6b350f..abe03d6461 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -18,8 +18,6 @@ Requires: `agents` · `sessions` · `sessionPersistence` · `tools` · `userInte export interface AcpConfig { /** Model name for created agents (must have a registered adapter). */ model?: string - /** Positive-integer concurrent tool-call cap for each created agent; `1` is serial. */ - maxParallelToolCalls?: number /** Runtime-only transport override for tests; production uses stdio. */ stream?: Stream } @@ -77,8 +75,8 @@ export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ agents?: AgentLoopConfig['agents'] /** - * The factory-wide default concurrent tool-call cap applied to every agent - * this bundle creates (see dsh-agent-loop's `Config.maxParallelToolCalls`). + * Concurrent tool-call cap shared by every agent this bundle's loop creates + * (see dsh-agent-loop's `Config.maxParallelToolCalls`). */ maxParallelToolCalls?: AgentLoopConfig['maxParallelToolCalls'] /** The deployment persona (see dsh-system-prompt's `Config`). */ @@ -111,16 +109,12 @@ Source: [`packages/core/agent-core/src/index.ts:46`](../packages/core/agent-core Requires: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` ```ts config-catalog -/** Plugin configuration for declarative startup agents. */ +/** Agent-loop plugin configuration. */ export interface Config { /** - * Default concurrent tool-call cap applied to every agent this factory - * creates (declarative startup agents and factory callers such as the ACP, - * stdio, and SDK front doors that go through `create`/`createAgent`/`resume`). - * A positive integer; a per-agent `maxParallelToolCalls` overrides it, and an - * agent with neither falls back to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}. - * This is the single `cordis.yml` knob that reaches agents whose front door - * does not expose its own cap field. + * 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}. */ maxParallelToolCalls?: number /** Agents created or resumed at plugin startup. */ @@ -129,11 +123,6 @@ export interface Config { id: AgentId /** Optional workspace for a 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 /** Persisted session to resume instead of creating a fresh session. */ resumeSessionId?: SessionId })[] @@ -142,7 +131,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:346`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:334`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -647,9 +636,8 @@ export interface Config { /** Model name for the `main` agent (must have a registered adapter). */ model: string /** - * Maximum tool calls the `main` agent runs concurrently within one assistant - * step (a positive integer; the agent loop defaults it when omitted). `1` - * preserves fully serial execution. + * Concurrent parallel-safe tool-call cap for the bundled agent loop. A + * positive integer; the loop defaults it when omitted and `1` is serial. */ maxParallelToolCalls?: number /** Deployment persona (the system-prompt plugin's `persona` config). */ diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 830184e804..12a41b2aed 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -19,7 +19,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:374`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:353`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 42c11a84f0..9a9a1706fc 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -347,7 +347,7 @@ interface Agent { } ``` -`AgentStatus` is `'idle' | 'running' | 'disposed'`, and `AgentId` is branded. `AgentOptions` is merge-extensible and currently includes `model?` and `maxParallelToolCalls?` (the loop's per-agent concurrent tool-call cap; the owning field, defaulted by `AgentLoop.Config` and, absent that, by `DEFAULT_MAX_PARALLEL_TOOL_CALLS`). Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. +`AgentStatus` is `'idle' | 'running' | 'disposed'`, and `AgentId` is branded. `AgentOptions` is merge-extensible and currently includes `model?`. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits. 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 23e0f0d22e..74a8ef6c61 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 @@ -4,109 +4,100 @@ 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. +An assistant message may contain several sibling `tool-call` blocks. Running them serially adds the latency of independent reads, web requests, and subagent runs even though the model has already requested them together. -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. +Concurrency is a host scheduling concern, not model-facing tool metadata. The loop needs to decide which calls may overlap without hardcoding tool names or exposing scheduler policy in the JSON schema. -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. +The session log remains authoritative: every started call has an audit event, every started call receives a result, and model history observes results in the original call order regardless of completion order. ## Decision -`ToolDefinition` carries an optional host-only classifier: +Each tool may provide an optional `isConcurrencySafe(args)` classifier. It is synchronous and pure: it examines only the current call's parsed arguments and performs no I/O or mutation. Only an explicit `true` opts in; a missing classifier, invalid arguments, a thrown classifier, or any other return value makes the call exclusive. The canonical type contract lives in the [tool data structures](../../../core-data-structures/tools.md). + +The classifier is deliberately unary. Returning `true` is the tool's promise that this call may overlap with any sibling call that also returns `true`; the scheduler does not compare calls or prove that their resource accesses are compatible. + +Arguments still support input-sensitive classification. A tool may classify a read-only operation as parallel and a mutating operation as exclusive. The interface cannot express relational rules such as "these writes are safe only when their paths differ," so a call whose safety depends on a sibling remains exclusive. + +`defineTool()` validates arguments before invoking a typed classifier. Invalid arguments classify as exclusive and produce the ordinary argument error only if the call executes. `ctx.tools.executionMode(exec)` resolves the live tool definition and returns the tagged `parallel` or `exclusive` mode; unknown tools fail closed to exclusive. + +A tagged mode, rather than a public boolean scheduler API, leaves room for a future resource-aware mode without changing the classifier contract. + +## Scheduling and ordering + +The loop waits for the complete assistant message, parses every call once, creates a distinct `ToolExecution` for each call, and scans them in model order. Consecutive parallel calls form one group; every exclusive call forms a singleton group and an ordering barrier. Groups execute sequentially. + +For example: ```text -export interface ToolDefinition extends ToolSchema { - execute(args: unknown, exec: ToolExecution): Promise - isConcurrencySafe?(args: unknown): boolean -} +[parallel read(A), parallel read(B), exclusive write(A), parallel read(C)] + +→ [read(A), read(B)] +→ [write(A)] +→ [read(C)] ``` -`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. +`read(A)` and `read(B)` may overlap. `write(A)` starts after both finish, and `read(C)` starts after the write finishes. -The registry exposes the scheduling decision as a plain method: +Every group uses a rolling pool bounded by `maxParallelToolCalls`: the loop starts calls in model order up to the cap and starts another whenever one settles. An exclusive group is a pool of one. A cap of `1` preserves serial execution. -```text -export type ToolExecutionMode = - | { kind: 'parallel' } - | { kind: 'exclusive' } -``` +Only dispatch and the tool body overlap. `tools/pre-execute` and `tools/post-execute` run in model order because middleware may maintain ordering-sensitive state. `tools/execute` wrappers run around concurrent dispatches and therefore must be reentrant across distinct executions. -```text -class ToolRegistry { - executionMode(exec: ToolExecutionInput): ToolExecutionMode -} -``` +Each started call appends `tool/call` immediately before its pre-execute gate. Completed dispatches occupy model-order slots, and a commit cursor appends `tool/result` and collects `additionalContext` only when the next slot is ready. Live surfaces may show several pending calls, but results and post-tool context remain model-ordered. -`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:...' }`. +An abort before a group starts records no calls from that group. An abort during a group stops replenishment, waits for already-started calls, commits their results in order, drops their buffered additional context, and then ends the step through the existing abort path. Calls that never start have no audit event. -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 or fail closed 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` state synchronously, and write/edit remain exclusive barriers that re-check versions before mutating; a stale observation can only make the provider CAS reject with `FS_STALE_VERSION`. +Code Mode remains outside this scheduler because the model emits one native `run_code` call. `run_code` and its internal dispatch queue remain serial; native sibling calls in `mode: 'both'` use the normal scheduler. -## Scheduling +## Safety contract -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. +A tool that returns `true` promises that its body is safe to run at the same time as other parallel calls. It must not directly mutate the parent session or other parent-owned state; it returns its outputs to the loop, which commits them in model order. -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; grouping classifies each call exactly once. `loop.ts` calls the helper so the turn/step lifecycle remains readable. +Any shared state touched during execution must be concurrency-safe. This includes tool wrappers and providers: they may serialize internally or enforce their own capacity, but they must support concurrent dispatch without corrupting state. -Parallelism is per agent. `AgentOptions.maxParallelToolCalls` is a positive integer, defaults to `DEFAULT_MAX_PARALLEL_TOOL_CALLS` (`10`), and reaches an agent three ways in precedence order: the per-agent option, the factory-wide `AgentLoop.Config.maxParallelToolCalls` applied to every agent the loop creates (declarative startup agents and factory callers such as the ACP, stdio, and SDK front doors), then the built-in default. The factory default is the single `cordis.yml` knob for agents whose front door exposes no cap field of its own. Setting the value to `1` preserves serial execution for that agent. The TypeScript `AgentOptions` vocabulary and both `AgentLoop.Config` fields validate the cap, so invalid `cordis.yml` values fail during config validation. +## Configuration and declarations -Every group runs through the same 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. An exclusive group is a pool of one — a barrier — so the loop needs no separate serial path. A group larger than the cap is not truncated; the cap limits simultaneous in-flight calls only. +`maxParallelToolCalls` is a positive AgentLoop deployment cap shared by every agent the factory creates. It defaults to `10`; `1` preserves serial execution. Exact fields and defaults live in the generated [configuration catalog](../../../config-catalog.md). -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. +The shipped declarations are conservative. Web search, web fetch, filesystem read, and subagent calls opt in. Filesystem writes and edits, bash tools, workflow, user interaction, todo mutation, Code Mode, and Cordis mutation tools remain exclusive. Bash stays exclusive until its owning package supplies a proven input-sensitive classifier. -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. +Filesystem read relies on a narrow recorder exception: its synchronous observation updates may settle out of order, but write and edit re-check the observed version before mutation, so stale state only produces `FS_STALE_VERSION`. -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. +The subagent declaration requires providers to accept concurrent `start()` calls for independent runs. A provider may queue, enforce its own capacity, or return a typed failure instead of requiring the parent loop to serialize every subagent call. -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. +## Verification -## Tool declarations +Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, the rolling cap, distinct execution objects, middleware order, ordered results and context, and abort draining. First-party tests pin each parallel declaration. -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. +Snapshot coverage pins the visible multi-call transcript: pending calls may overlap while completed results remain model-ordered. Code Mode coverage pins its serial boundary. No provider-backed e2e is required because scheduling is deterministic loop 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. +**Keep serial execution.** This avoids new ordering and abort cases but retains unnecessary latency for independent sibling calls. -**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. +**Use one tool-level boolean.** A fixed `supportsParallelToolCalls` flag is smaller but cannot distinguish a tool's read-only and mutating operations. The argument-sensitive classifier preserves that distinction. -**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. +**Use stateful classification.** Giving the classifier a live agent, registry, or I/O access makes the decision depend on when it runs and creates a gap between classification and dispatch. Mutable authorization and stale-state checks remain execution-time responsibilities. -**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. +**Use sibling-aware or resource-aware classification.** The scheduler could compare calls pairwise or let each call declare resource read/write claims. This can parallelize non-conflicting writes, but it requires shared resource identity and conflict semantics across unrelated tools. The unary contract instead gives up that concurrency and fails closed when safety is relational. -**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. +**Parallelize the complete tool pipeline.** This keeps the loop on the public one-call API but runs pre- and post-execute middleware concurrently. Existing guards and hook bridges may carry ordered state, so only dispatch overlaps. -**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. +**Expose staged methods or a scheduling waterfall.** Public `prepare` / `dispatch` / `finalize` methods or a `tools/execution-mode` event add extension surface before another consumer needs it. The loop uses an internal scheduler view, while `executionMode(exec)` remains the insertion point for a future policy seam. -**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. +**Start calls while the model streams.** This may reduce latency further but changes assistant-message authority, replay, and call/result pairing. The scheduler starts only after the assistant message is complete. -**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. +**Use fixed-size windows.** Waiting for every call in one window before starting the next leaves capacity idle behind a slow call. The rolling pool preserves the cap without that delay. + +**Expose concurrency metadata to the model.** The model can already emit sibling calls. Host scheduling metadata would enlarge requests without improving tool choice. ## 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. +The design is fail-closed and simple for tool authors, but it cannot exploit concurrency whose safety depends on comparing siblings. A tool that opts in too broadly can expose latent shared-state races. -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. +Parallel calls may begin in cases where serial execution would have aborted before reaching them. The scheduler therefore records only started calls, drains them on abort, and never starts replacements after cancellation. -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. +Ordered commits may hold a fast result behind a slow earlier sibling. This preserves replay and model-history order while live surfaces still show pending progress. -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 and external calls can compete for quota or process capacity. Providers own their capacity controls; the loop cap only limits calls from one agent step. -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. +Tool registration is a scheduling boundary. The scheduler currently plans all groups before dispatch, so an earlier registry mutation can make a later classification stale. Binding dispatch to the classified definition or reclassifying after exclusive barriers remains a named correctness gap. diff --git a/packages/core/agent-core/README.md b/packages/core/agent-core/README.md index 594c4a8c82..ea9d94b011 100644 --- a/packages/core/agent-core/README.md +++ b/packages/core/agent-core/README.md @@ -43,7 +43,7 @@ import type { Config } from '@deepseek-ai/dsh-agent-core' // intersects the owner schemas, so validation and defaulting can never drift from the owners. ``` -The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `maxParallelToolCalls` to `agent-loop` as the factory-wide default concurrent tool-call cap for every agent it creates; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; and `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure. +The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `maxParallelToolCalls` to `agent-loop` as the shared concurrent tool-call cap for every agent it creates; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; and `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure. ## Why a code bundle, not a shared YAML include diff --git a/packages/core/agent-core/src/index.ts b/packages/core/agent-core/src/index.ts index fcb4e87f1e..7e4da95921 100644 --- a/packages/core/agent-core/src/index.ts +++ b/packages/core/agent-core/src/index.ts @@ -47,8 +47,8 @@ export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ agents?: AgentLoopConfig['agents'] /** - * The factory-wide default concurrent tool-call cap applied to every agent - * this bundle creates (see dsh-agent-loop's `Config.maxParallelToolCalls`). + * Concurrent tool-call cap shared by every agent this bundle's loop creates + * (see dsh-agent-loop's `Config.maxParallelToolCalls`). */ maxParallelToolCalls?: AgentLoopConfig['maxParallelToolCalls'] /** The deployment persona (see dsh-system-prompt's `Config`). */ diff --git a/packages/core/agent-core/tests/agent-core.spec.ts b/packages/core/agent-core/tests/agent-core.spec.ts index fe240da8ed..4657bb2a40 100644 --- a/packages/core/agent-core/tests/agent-core.spec.ts +++ b/packages/core/agent-core/tests/agent-core.spec.ts @@ -117,13 +117,12 @@ describe('dsh-agent-core bundle', () => { await ctx.fiber.dispose() }) - it('forwards the factory-wide maxParallelToolCalls default to agents without a per-agent cap', async () => { + it('forwards the global maxParallelToolCalls config to agent-loop', async () => { const ctx = await mount({ agents: [{ id: AgentId('main'), model: 'mock' }], maxParallelToolCalls: 3, }) - const main = ctx.get('agents')?.get(AgentId('main')) - expect(main?.options.maxParallelToolCalls).toBe(3) + expect(ctx.get('agentLoop')?.config.maxParallelToolCalls).toBe(3) await ctx.fiber.dispose() }) diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index e35089b409..1460203cd7 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -29,17 +29,17 @@ 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 agents: Array<{ id: string // required model?: string - maxParallelToolCalls?: number // positive integer; default 10; 1 is serial resumeSessionId?: string // load this persisted session instead of creating one cwd?: string // optional workspace cwd for the fresh session }> } ``` -Configured agents start automatically. `cwd` applies only to fresh sessions; `resumeSessionId` retains persisted metadata. `maxParallelToolCalls` bounds the rolling pool for parallel-safe calls and defaults to `10`. They use the deployment persona. Programmatic setup can shadow it per agent. This plugin supplies the per-agent `model` and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`. +Configured agents start automatically. `cwd` applies only to fresh sessions; `resumeSessionId` retains persisted metadata. `maxParallelToolCalls` bounds every agent's rolling pool for parallel-safe calls and defaults to `10`. They use the deployment persona, which programmatic setup can shadow per agent. This plugin supplies the per-agent `model` and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`. ### Exported concrete class diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index a2288c65b7..72834db998 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -54,15 +54,16 @@ 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. * @returns the agent and closures bound only to that exact instance. */ export function prepareReactLoopAgent( - ctx: Context, id: AgentId, options: AgentOptions, session: Session, + ctx: Context, id: AgentId, options: AgentOptions, session: Session, maxParallelToolCalls: number, ): PreparedReactLoopAgent { if (claimedDriverSessions.has(session)) { throw new Error(`session "${session.id}" already has a concrete agent driver`) } - const agent = new ReactLoopAgent(ctx, id, options, session) + const agent = new ReactLoopAgent(ctx, id, options, session, maxParallelToolCalls) claimedDriverSessions.add(session) const dispose = () => agent[stopDriver]() return { @@ -143,6 +144,8 @@ 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. */ + private readonly maxParallelToolCalls: number /** * Durability checkpoints started by idle {@link inject} calls. `inject()` is * synchronous, so it cannot await them itself; the driver disposer drains @@ -155,7 +158,9 @@ export class ReactLoopAgent implements Agent { public readonly id: AgentId, public readonly options: AgentOptions, public readonly session: Session, + maxParallelToolCalls: number, ) { + this.maxParallelToolCalls = maxParallelToolCalls const { promise, resolve } = Promise.withResolvers() this.disposed = promise this.resolveDisposed = resolve @@ -329,6 +334,7 @@ export class ReactLoopAgent implements Agent { this.driverStarted = true this.done = runLoop(this.loopCtx, this, { inbox: this.#inbox, + maxParallelToolCalls: this.maxParallelToolCalls, setStatus: (status) => { this.setStatus(status) }, setAbort: controller => void (this.currentAbort = controller), disposed: this.disposed, diff --git a/packages/core/agent-loop/src/constants.ts b/packages/core/agent-loop/src/constants.ts index de18e0411e..72ba7051c2 100644 --- a/packages/core/agent-loop/src/constants.ts +++ b/packages/core/agent-loop/src/constants.ts @@ -7,9 +7,9 @@ */ /** - * Default cap on simultaneously in-flight tool calls within one assistant step, - * when {@link AgentOptions.maxParallelToolCalls} is unset. Matches the - * rolling-pool size Claude Code uses; a group larger than the cap is not - * truncated — the cap limits concurrency, not the group. + * 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. */ export const DEFAULT_MAX_PARALLEL_TOOL_CALLS = 10 diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 86df80cd8e..3c9c807e15 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -32,6 +32,7 @@ import { ReactLoopAgent, } from './agent.ts' import type { PreparedReactLoopAgent } from './agent.ts' +import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts' export { ReactLoopAgent } from './agent.ts' @@ -73,12 +74,13 @@ function signalAbortError(id: AgentId, signal: AbortSignal): Error { return new Error(`agent "${id}" creation aborted`, { cause: signal.reason }) } -/** Validate merge-extended options the loop owns before a session is published. */ -function validateAgentOptions(options: AgentOptions): void { - const { maxParallelToolCalls } = options - if (maxParallelToolCalls !== undefined && (!Number.isInteger(maxParallelToolCalls) || maxParallelToolCalls < 1)) { +/** Resolve the deployment-wide scheduler cap at the owning config boundary. */ +function resolveMaxParallelToolCalls(value: number | undefined): number { + const maxParallelToolCalls = value ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS + if (!Number.isInteger(maxParallelToolCalls) || maxParallelToolCalls < 1) { throw new Error('maxParallelToolCalls must be a positive integer') } + return maxParallelToolCalls } /** @@ -171,13 +173,13 @@ class AgentCreationTransaction { } /** Construct the driver and scope, then install their complete ordered lifecycle. */ - prepare(options: AgentOptions, session: Session): ReactLoopAgent { + prepare(options: AgentOptions, session: Session, maxParallelToolCalls: number): ReactLoopAgent { this.assertActive() const gate = Promise.withResolvers() this.preparing = gate.promise try { this.session = session - const driver = prepareReactLoopAgent(this.loopCtx, this.id, options, session) + const driver = prepareReactLoopAgent(this.loopCtx, this.id, options, session, maxParallelToolCalls) this.driver = driver const agent = driver.agent const scope = createScope(this.loopCtx, agent) @@ -326,32 +328,14 @@ declare module 'cordis' { } } -declare module '@deepseek-ai/dsh-agent' { - interface AgentOptions { - /** - * Maximum tool calls this agent runs concurrently within one assistant step - * (a positive integer; defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}). - * The loop's rolling pool starts up to this many parallel-safe calls at once - * and replenishes as each settles; `1` preserves the fully serial path. - * A merge-extensible field — the loop owns it (it neither the agent nor the - * subagent seam sets it), read in `runStep` when scheduling a parallel group. - */ - maxParallelToolCalls?: number - } -} +export { DEFAULT_MAX_PARALLEL_TOOL_CALLS } -export { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts' - -/** Plugin configuration for declarative startup agents. */ +/** Agent-loop plugin configuration. */ export interface Config { /** - * Default concurrent tool-call cap applied to every agent this factory - * creates (declarative startup agents and factory callers such as the ACP, - * stdio, and SDK front doors that go through `create`/`createAgent`/`resume`). - * A positive integer; a per-agent `maxParallelToolCalls` overrides it, and an - * agent with neither falls back to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}. - * This is the single `cordis.yml` knob that reaches agents whose front door - * does not expose its own cap field. + * 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}. */ maxParallelToolCalls?: number /** Agents created or resumed at plugin startup. */ @@ -360,11 +344,6 @@ export interface Config { id: AgentId /** Optional workspace for a 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 /** Persisted session to resume instead of creating a fresh session. */ resumeSessionId?: SessionId })[] @@ -376,26 +355,25 @@ export class AgentLoop extends Service implements AgentFactory { /** Runtime schema for declarative agents. */ static Config = z.object({ - // The factory-wide default cap; a per-agent value overrides it. A positive - // integer, validated here so a bad cordis.yml value fails at load. - maxParallelToolCalls: z.number().step(1).min(1), + // 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(), model: z.string(), cwd: z.string(), resumeSessionId: z.string(), - // A positive integer; a bad value (0, negative, fractional) fails config - // validation here rather than being silently dropped from cordis.yml. - maxParallelToolCalls: z.number().step(1).min(1), })).default([]), }) as unknown as z private readonly ownership: FactoryOwnership + /** Resolved immutable scheduler cap shared by every driver from 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 } constructor(ctx: Context, public config: Config) { super(ctx, 'agentLoop') + this.maxParallelToolCalls = resolveMaxParallelToolCalls(config.maxParallelToolCalls) this.ownership = new FactoryOwnership(ctx.fiber) this.runtime = { ctx } ctx.effect(() => () => this.ownership.dispose(), 'agentLoop.transactions()') @@ -423,22 +401,6 @@ export class AgentLoop extends Service implements AgentFactory { } } - /** - * Merge the factory-wide default cap into one agent's options. A per-agent - * `maxParallelToolCalls` wins; otherwise the `Config.maxParallelToolCalls` - * default applies, reaching factory callers (ACP/stdio/SDK front doors) whose - * own config does not set a cap. Absent both, the loop falls back to - * {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS} at schedule time. - * @param options - the caller-supplied agent options. - * @returns options with the default cap applied when the caller omitted one. - */ - private withFactoryDefaults(options: AgentOptions): AgentOptions { - if (options.maxParallelToolCalls !== undefined || this.config.maxParallelToolCalls === undefined) { - return options - } - return { ...options, maxParallelToolCalls: this.config.maxParallelToolCalls } - } - /** * Create an agent on a fresh per-run session, owned by the accessing fiber. * Constructor-driven config calls use the loop fiber itself. @@ -448,14 +410,12 @@ export class AgentLoop extends Service implements AgentFactory { * @returns the published running agent. */ create(id: AgentId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent { - const resolved = this.withFactoryDefaults(options) - validateAgentOptions(resolved) const loopCtx = this.runtime.ctx const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id) try { const sessionId = SessionId(`${id}-session-${randomUUID()}`) const session = loopCtx.sessions.prepare(sessionId, { meta }) - const agent = transaction.prepare(resolved, session) + const agent = transaction.prepare(options, session, this.maxParallelToolCalls) transaction.publish('startup') return agent } catch (error: unknown) { @@ -473,8 +433,7 @@ export class AgentLoop extends Service implements AgentFactory { * @returns the published handle. */ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise { - const agentOptions = this.withFactoryDefaults(options.agentOptions ?? {}) - validateAgentOptions(agentOptions) + const agentOptions = options.agentOptions ?? {} const transaction = new AgentCreationTransaction( this.runtime.ctx, ownerCtx, @@ -487,7 +446,7 @@ export class AgentLoop extends Service implements AgentFactory { ...options.seed === undefined ? {} : { seed: options.seed }, ...options.meta === undefined ? {} : { meta: options.meta }, }) - const agent = transaction.prepare(agentOptions, session) + const agent = transaction.prepare(agentOptions, session, this.maxParallelToolCalls) await transaction.waitFor(options.setup?.(agent.ctx)) transaction.assertActive() return transaction.publish('startup') @@ -519,8 +478,7 @@ export class AgentLoop extends Service implements AgentFactory { persistence: SessionPersistence, options: ResumeAgentOptions, ): Promise { - const agentOptions = this.withFactoryDefaults(options.agentOptions ?? {}) - validateAgentOptions(agentOptions) + const agentOptions = options.agentOptions ?? {} const transaction = new AgentCreationTransaction( this.runtime.ctx, ownerCtx, @@ -540,7 +498,7 @@ export class AgentLoop extends Service implements AgentFactory { ...loaded.meta.seedLength === undefined ? {} : { seedLength: loaded.meta.seedLength }, }, }) - const agent = transaction.prepare(agentOptions, session) + const agent = transaction.prepare(agentOptions, session, this.maxParallelToolCalls) await transaction.waitFor(options.setup?.(agent.ctx)) transaction.assertActive() return transaction.publish('resume') diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 7efbceef5e..a04f653b65 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -17,7 +17,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, resolveMaxParallelToolCalls } from './tool-calls.ts' +import { executeToolCalls } from './tool-calls.ts' import type { ReactLoopAgent } from './agent.ts' import type { Inbox } from './inbox.ts' @@ -73,6 +73,8 @@ 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. */ + readonly maxParallelToolCalls: number setStatus(status: 'idle' | 'running'): void setAbort(controller: AbortController | undefined): void /** Resolves when the agent is disposed — unblocks the idle wait. */ @@ -326,7 +328,8 @@ async function runTurn( let stepOutcome: { hadToolCalls: boolean; finish: FinishReason } | { error: Error } try { stepOutcome = await runStep( - ctx, events, agent, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal) + ctx, events, agent, turn, step, assembly, fullSystemPrompt, boundaryMessages, + transmission, abort.signal, handle.maxParallelToolCalls) } catch (error: unknown) { stepOutcome = { error: toError(error) } } finally { @@ -470,6 +473,7 @@ async function runStep( boundaryMessages: Message[], transmission: TransmissionLog, signal: AbortSignal, + maxParallelToolCalls: number, ): Promise<{ hadToolCalls: boolean; finish: FinishReason }> { const { session, options } = agent @@ -545,12 +549,7 @@ async function runStep( let message: Message = assembler.message() message = await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message)) - // Validate the live cap before logging model-visible tool calls so bad mutable - // options cannot leave unanswered calls in the transcript. const toolCalls = message.content.filter(block => block.type === 'tool-call') - const maxParallel = toolCalls.length > 0 - ? resolveMaxParallelToolCalls(agent.options.maxParallelToolCalls) - : undefined // Empty messages exist only to carry usage; omit empty provenance. if (message.content.length > 0 || assembler.usage) { @@ -563,8 +562,8 @@ async function runStep( // The scheduler overlaps only dispatch/body for parallel-safe calls; policy, // results, and additional context remain in model order. - const pendingContext = maxParallel !== undefined - ? await executeToolCalls(ctx, agent, turn, step, toolCalls, signal, maxParallel) + 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. diff --git a/packages/core/agent-loop/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts index d117823a43..52328a080a 100644 --- a/packages/core/agent-loop/src/tool-calls.ts +++ b/packages/core/agent-loop/src/tool-calls.ts @@ -4,8 +4,8 @@ * arguments once, classifies it via `ctx.tools.executionMode`, partitions the * calls into ordered groups (one exclusive call, or a run of consecutive * parallel-safe calls), and runs every group through the same rolling pool - * bounded by the agent's `maxParallelToolCalls` — an exclusive group is a pool - * of one. + * bounded by the agent-loop's `maxParallelToolCalls` config — an exclusive + * group is a pool of one. * * 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 @@ -25,7 +25,6 @@ import type { HookContext } from '@deepseek-ai/dsh-agent' import type { Session } from '@deepseek-ai/dsh-session' import { TOOL_REGISTRY_SCHEDULER, type ToolExecution, type ToolExecutionInput, type ToolExecutionResult } from '@deepseek-ai/dsh-tools' import type { ReactLoopAgent } from './agent.ts' -import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts' /** One tool call after argument parsing, ready to schedule. */ interface PlannedCall { @@ -107,20 +106,6 @@ 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 { @@ -157,13 +142,6 @@ function groupByMode(ctx: Context, planned: PlannedCall[]): PlannedCall[][] { return groups } -/** Validate the live per-agent cap at the point it controls dispatch. */ -function assertMaxParallelToolCalls(maxParallel: number): void { - if (!Number.isInteger(maxParallel) || maxParallel < 1) { - throw new Error('maxParallelToolCalls must be a positive integer') - } -} - /** * 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 @@ -189,8 +167,6 @@ async function runGroup( ): Promise { /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) - assertMaxParallelToolCalls(maxParallel) - 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, diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 581b6fa207..2f3f23bcd2 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -6,7 +6,7 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS, ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { bindReactLoopAgentContext, prepareReactLoopAgent } from '../src/agent.ts' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -53,10 +53,14 @@ describe('ReactLoopAgent', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('exclusive-driver')) - const prepared = prepareReactLoopAgent(ctx, AgentId('first-driver'), { model: 'mock' }, session) + const prepared = prepareReactLoopAgent( + ctx, AgentId('first-driver'), { model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, + ) expect(() => prepared.agent.ctx).toThrow('context is not bound') - expect(() => prepareReactLoopAgent(ctx, AgentId('second-driver'), { model: 'mock' }, session)) + expect(() => prepareReactLoopAgent( + ctx, AgentId('second-driver'), { model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, + )) .toThrow('already has a concrete agent driver') await prepared.dispose() @@ -254,7 +258,9 @@ describe('ReactLoopAgent', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('test')) - const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session) + const prepared = prepareReactLoopAgent( + ctx, AgentId('bare'), { model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, + ) const { agent } = prepared prepared.markPublished() @@ -272,7 +278,9 @@ describe('ReactLoopAgent', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('pre-start-dispose')) - const prepared = prepareReactLoopAgent(ctx, AgentId('pre-start-dispose'), { model: 'mock' }, session) + const prepared = prepareReactLoopAgent( + ctx, AgentId('pre-start-dispose'), { model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, + ) await prepared.dispose() expect(prepared.agent.status).toBe('disposed') @@ -369,7 +377,9 @@ describe('ReactLoopAgent', () => { const adapter = new MockAdapter(['hang']) ctx.llm.registerAdapter(['mock'], adapter) const session = ctx.sessions.create(SessionId('bare')) - const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session) + const prepared = prepareReactLoopAgent( + ctx, AgentId('bare'), { model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, + ) const { agent } = prepared prepared.markPublished() const dispose = prepared.startDriver() diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 8130d4b893..00c5c45c09 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -5,7 +5,7 @@ import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId, type ContinuationDecision } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS, ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { prepareReactLoopAgent } from '../src/agent.ts' import * as Invariants from '@deepseek-ai/dsh-invariants' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -528,7 +528,9 @@ describe('turn numbering continues across seeded sessions', () => { ctx2.llm.registerAdapter(['mock'], second) const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] }) - const prepared = prepareReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded) + const prepared = prepareReactLoopAgent( + ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded, DEFAULT_MAX_PARALLEL_TOOL_CALLS, + ) const forked = prepared.agent prepared.markPublished() ctx2.effect(() => prepared.startDriver()) diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index a270ad6da1..8ca6170b03 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -12,7 +12,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CallId, StreamChunk } from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import LlmService from '@deepseek-ai/dsh-llm' import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' @@ -20,14 +20,17 @@ import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' -async function harness(adapter: MockAdapter) { +async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(AgentLoop, { + agents: [], + ...maxParallelToolCalls === undefined ? {} : { maxParallelToolCalls }, + }) ctx.llm.registerAdapter(['mock'], adapter) return ctx } @@ -190,38 +193,28 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme }) describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => { - it('rejects invalid programmatic maxParallelToolCalls values before creating agents', async () => { - const ctx = await harness(new MockAdapter([])) - - expect(() => ctx.agentLoop.create(AgentId('bad-zero'), { model: 'mock', maxParallelToolCalls: 0 })) - .toThrow('maxParallelToolCalls must be a positive integer') - await expect(ctx.agents.create({ - agentId: AgentId('bad-fractional'), - sessionId: SessionId('bad-fractional-session'), - agentOptions: { model: 'mock', maxParallelToolCalls: 1.5 }, - })).rejects.toThrow('maxParallelToolCalls must be a positive integer') + it('rejects invalid global maxParallelToolCalls config at plugin load', async () => { + await expect(harness(new MockAdapter([]), 0)).rejects.toThrow() + await expect(harness(new MockAdapter([]), 1.5)).rejects.toThrow() }) - it('fails loud if maxParallelToolCalls is mutated invalid after agent creation', async () => { - const adapter = new MockAdapter([ - multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]), - textResponse('must not run after unanswered tool calls'), - ]) - const ctx = await harness(adapter) - const gated = gatedParallelTool('p') - ctx.tools.register(gated.tool) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', maxParallelToolCalls: 2 }) - ;(agent.options as { maxParallelToolCalls: number }).maxParallelToolCalls = 0 + it('defensively rejects invalid caps when direct construction bypasses the config schema', () => { + expect(() => new AgentLoop(new Context(), { agents: [], maxParallelToolCalls: 0 })) + .toThrow('maxParallelToolCalls must be a positive integer') + expect(() => new AgentLoop(new Context(), { agents: [], maxParallelToolCalls: 1.5 })) + .toThrow('maxParallelToolCalls must be a positive integer') + }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) + it('defaults the cap when direct construction bypasses the config schema', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) - 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') + expect(() => new AgentLoop(ctx, { agents: [] })).not.toThrow() + await ctx.fiber.dispose() }) it('starts at most the cap, replenishing as calls settle', async () => { @@ -229,10 +222,10 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => multiCall([1, 2, 3, 4].map(n => ({ id: `c${n}`, name: 'p', args: { id: String(n) } }))), textResponse('done'), ]) - const ctx = await harness(adapter) + const ctx = await harness(adapter, 2) const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', maxParallelToolCalls: 2 }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) // Only 2 start initially (the cap). @@ -261,10 +254,10 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]), textResponse('done'), ]) - const ctx = await harness(adapter) + const ctx = await harness(adapter, 1) const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', maxParallelToolCalls: 1 }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 1) await new Promise(r => setTimeout(r, 5)) @@ -275,7 +268,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => await waitForIdle(ctx, agent) }) - it('applies the factory-wide Config default to agents that set no per-agent cap', async () => { + it('applies the global Config cap to every agent created by the factory', async () => { const adapter = new MockAdapter([ multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]), textResponse('done'), @@ -286,14 +279,12 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - // Factory default of 1 (no per-agent cap set below) must serialize. + // 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') ctx.tools.register(gated.tool) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - expect(agent.options.maxParallelToolCalls).toBe(1) - agent.send([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 1) await new Promise(r => setTimeout(r, 5)) @@ -304,18 +295,6 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => await waitForIdle(ctx, agent) }) - it('lets a per-agent cap override the factory-wide Config default', async () => { - const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt, { persona: '' }) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentLoop, { agents: [], maxParallelToolCalls: 1 }) - ctx.llm.registerAdapter(['mock'], new MockAdapter([])) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', maxParallelToolCalls: 4 }) - expect(agent.options.maxParallelToolCalls).toBe(4) - }) }) describe('tool-call scheduler: ordered middleware and additionalContext', () => { @@ -349,7 +328,7 @@ describe('tool-call scheduler: ordered middleware and additionalContext', () => multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]), textResponse('done'), ]) - const ctx = await harness(adapter) + const ctx = await harness(adapter, 2) const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) ctx.on('tools/post-execute', async (exec, _result): Promise => @@ -467,14 +446,14 @@ describe('tool-call scheduler: abort handling', () => { multiCall([1, 2, 3, 4].map(n => ({ id: `c${n}`, name: 'p', args: { id: String(n) } }))), textResponse('should never be requested'), ]) - const ctx = await harness(adapter) + const ctx = await harness(adapter, 2) const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) ctx.on('tools/post-execute', async (exec, _result, next): Promise => ({ ...await next(), additionalContext: { content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', maxParallelToolCalls: 2 }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) @@ -500,7 +479,7 @@ describe('tool-call scheduler: abort handling', () => { ]), textResponse('should never be requested'), ]) - const ctx = await harness(adapter) + const ctx = await harness(adapter, 2) const gated = gatedParallelTool('p') const exclusive: string[] = [] ctx.tools.register(gated.tool) @@ -510,7 +489,7 @@ describe('tool-call scheduler: abort handling', () => { parameters: { id: { type: 'string', required: true } }, async execute(args) { exclusive.push(args.id); return [{ type: 'text', text: 'x' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', maxParallelToolCalls: 2 }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index d157a5158b..dd705e8d8d 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -15,7 +15,6 @@ The plugin injects `agents`, `sessions`, `sessionPersistence`, `tools`, and `use | Key | Default | Meaning | |---|---|---| | `model` | — | Model name for created agents (must have a registered adapter). | -| `maxParallelToolCalls` | (agent-loop default) | Positive integer cap on tool calls each created agent runs concurrently within one assistant step; `1` is fully serial. | (No persona key: `dsh-system-prompt`'s own `persona` config supplies the global default section, so ACP-created agents render it without the bridge carrying prompt text. An agent-scoped same-name section may still shadow that default.) diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index ca6e31d008..c464ffd22b 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -203,17 +203,12 @@ function stringArrayContent( export interface AcpConfig { /** Model name for created agents (must have a registered adapter). */ model?: string - /** Positive-integer concurrent tool-call cap for each created agent; `1` is serial. */ - maxParallelToolCalls?: number /** Runtime-only transport override for tests; production uses stdio. */ stream?: Stream } export const Config: Schema = Schema.object({ model: Schema.string(), - // A positive integer; a bad value (0, negative, fractional) fails config - // validation here rather than being silently dropped from cordis.yml. - maxParallelToolCalls: Schema.number().step(1).min(1), }) /** Per-session bridge state keyed by ACP session id. */ @@ -856,13 +851,12 @@ export function apply(ctx: Context, config: AcpConfig): void { * Build per-agent options from the plugin config, omitting absent fields * (exactOptionalPropertyTypes: never assign `undefined` to an optional key). * Exported for unit coverage of both the present and absent branches. - * @param config - the plugin config carrying the optional model name and parallel cap. - * @returns the per-agent options, with each field present only when configured. + * @param config - the plugin config carrying the optional model name. + * @returns the per-agent options, with `model` present only when configured. */ -export function agentOptions(config: AcpConfig): { model?: string; maxParallelToolCalls?: number } { +export function agentOptions(config: AcpConfig): { model?: string } { return { ...config.model !== undefined ? { model: config.model } : {}, - ...config.maxParallelToolCalls !== undefined ? { maxParallelToolCalls: config.maxParallelToolCalls } : {}, } } diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index 1112cc3808..2afa49e1d4 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -794,7 +794,5 @@ describe('agentOptions', () => { it('includes only the fields present in config', () => { expect(agentOptions({})).toEqual({}) expect(agentOptions({ model: 'm' })).toEqual({ model: 'm' }) - expect(agentOptions({ maxParallelToolCalls: 3 })).toEqual({ maxParallelToolCalls: 3 }) - expect(agentOptions({ model: 'm', maxParallelToolCalls: 1 })).toEqual({ model: 'm', maxParallelToolCalls: 1 }) }) }) diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md index 853abd52e2..5c37043b42 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/ui/stdio-agent/README.md @@ -26,7 +26,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte | Key | Default | Routed to | |---|---|---| | `model` | (required) | the pre-created `main` agent's model | -| `maxParallelToolCalls` | agent-loop default | positive-integer concurrent tool-call cap for the `main` agent; `1` is serial | +| `maxParallelToolCalls` | agent-loop default | positive-integer concurrent tool-call cap shared by the bundled loop's agents; `1` is serial | | `persona` | — | the deployment persona template (may reference `{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` | | `toolOrder` | — | explicit model-facing tool order (a name list with one `''` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` | | `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-core` | diff --git a/packages/ui/stdio-agent/src/index.ts b/packages/ui/stdio-agent/src/index.ts index 65b38a0fd4..c3d6192a4b 100644 --- a/packages/ui/stdio-agent/src/index.ts +++ b/packages/ui/stdio-agent/src/index.ts @@ -37,9 +37,8 @@ export interface Config { /** Model name for the `main` agent (must have a registered adapter). */ model: string /** - * Maximum tool calls the `main` agent runs concurrently within one assistant - * step (a positive integer; the agent loop defaults it when omitted). `1` - * preserves fully serial execution. + * Concurrent parallel-safe tool-call cap for the bundled agent loop. A + * positive integer; the loop defaults it when omitted and `1` is serial. */ maxParallelToolCalls?: number /** Deployment persona (the system-prompt plugin's `persona` config). */ @@ -94,11 +93,11 @@ export function apply(ctx: Context, config: Config): void { ...config.persona !== undefined ? { persona: config.persona } : {}, ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {}, ...config.tools !== undefined ? { tools: config.tools } : {}, + ...config.maxParallelToolCalls !== undefined ? { maxParallelToolCalls: config.maxParallelToolCalls } : {}, agents: [{ id: AgentId('main'), model: config.model, cwd: process.cwd(), - ...config.maxParallelToolCalls !== undefined ? { maxParallelToolCalls: config.maxParallelToolCalls } : {}, ...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {}, }], ...config.skills !== undefined ? { skills: config.skills } : {}, diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts index 54853b7ca1..b5438caf77 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -126,14 +126,14 @@ describe('dsh-stdio-agent app', () => { await ctx.fiber.dispose() }) - it('forwards maxParallelToolCalls onto the pre-created agent when set', async () => { + it('forwards maxParallelToolCalls to the bundled agent loop', async () => { const ctx = await mount({ model: 'mock', maxParallelToolCalls: 3, persistenceRoot: '/tmp/dsh-stdio-agent-spec-parallel', skills: await isolatedSkillsConfig(), }) - expect(ctx.get('agents')?.get(AgentId('main'))?.options.maxParallelToolCalls).toBe(3) + expect(ctx.get('agentLoop')?.config.maxParallelToolCalls).toBe(3) await ctx.fiber.dispose() }) From bbf66b3a5ba9e2126d92dc5eaeecd8b84b4e8552 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 16 Jul 2026 15:27:36 +0800 Subject: [PATCH 09/14] docs(tools): trim concurrency classifier contract --- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 2 +- packages/core/tools/src/index.ts | 30 +++++++++--------------------- 3 files changed, 11 insertions(+), 23 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5df265f59c..9c1b5dd4c2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1086,7 +1086,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:388`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:376`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-user-approval` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ea287e4c0f..be4cdf73a8 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -277,7 +277,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:444`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:432`](../../packages/core/tools/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 3af6e80918..ff74debb81 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -132,28 +132,16 @@ export interface ToolDefinition extends ToolSchema { */ 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. + * Pure, synchronous host-only classifier for overlap with sibling tool calls. + * Only `true` opts in; omission, exceptions, and invalid `defineTool` + * arguments are treated as exclusive. * - * 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). The check performs no I/O and receives no - * live `Agent` or mutable `ToolExecution`. - * - * Declaring `true` is a contract: during `execute` the tool body must NOT - * mutate the parent agent's session or other parent-owned async state (no - * `exec.agent.session.append(...)`, no `agent.inject(...)`); its only parent- - * step outputs are the returned content, `meta`, structured error, and - * `additionalContext` on the loop's ordered post-execute path. A synchronous, - * side-effect-only recorder whose updates are commutative or fail closed for - * concurrent same-session calls is the one exception (`fs/observed` is the - * worked example). Full contract and rationale: the parallel-tool-call RFC - * (docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md). + * Opted-in executions must not mutate parent-owned state, and shared state + * they touch must be concurrency-safe. See the + * [parallel-tool-call RFC](../../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md) + * for the full safety contract and recorder exception. + * @param args - Parsed tool arguments. + * @returns Whether this call may join a parallel group. */ isConcurrencySafe?(args: unknown): boolean /** From 17fe9e5b1e2447629541afbef908ad65ee08bd1a Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 16 Jul 2026 15:52:35 +0800 Subject: [PATCH 10/14] fix(agent-loop): tighten parallel tool-call safety --- docs/agent-lifecycle.md | 15 ++++++++++----- docs/architecture.md | 7 +++---- docs/config-catalog.md | 10 ++++++++-- .../2026-07-10-parallel-tool-call-execution.md | 8 +++----- docs/tool-catalog.md | 2 +- .../advanced-toolchain/system-prompt.golden.md | 4 ++-- .../advanced-toolchain/tool-schemas.golden.json | 4 ++-- .../both-mode-turn/system-prompt.golden.md | 4 ++-- .../both-mode-turn/tool-schemas.golden.json | 4 ++-- .../code-mode-turn/system-prompt.golden.md | 4 ++-- .../snapshots/escalation-approved/session.jsonl | 4 ++-- .../snapshots/escalation-rejected/session.jsonl | 4 ++-- .../snapshots/hook-cc-pretool-ask/session.jsonl | 4 ++-- .../permission-switching/tool-schemas.golden.json | 4 ++-- .../snapshots/skill-load/tool-schemas.golden.json | 4 ++-- .../snapshots/text-turn/tool-schemas.golden.json | 4 ++-- .../workspace-edit/tool-schemas.golden.json | 4 ++-- packages/examples/acp-demo/README.md | 1 + packages/examples/acp-demo/src/index.ts | 12 +++++++++++- .../examples/acp-demo/tests/acp-agent.spec.ts | 11 +++++++++++ packages/subagent/README.md | 2 -- packages/subagent/subagent/src/types.ts | 7 ------- packages/subagent/tool-subagent/README.md | 2 +- packages/subagent/tool-subagent/src/index.ts | 9 ++------- .../tool-subagent/tests/tool-subagent.spec.ts | 6 +++--- scripts/gen-doc-graphs.ts | 15 ++++++++++----- 26 files changed, 88 insertions(+), 67 deletions(-) diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 60dfc6ed58..7de8e02bf1 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -35,12 +35,17 @@ sequenceDiagram Driver->>Hooks: agent/step-result waterfall Driver->>Session: assistant/message Driver->>Tools: group calls by executionMode - loop started tool calls (bounded pool) - Driver->>Session: tool/call pending audit - Driver->>Tools: ordered pre / pooled dispatch / ordered post - Tools-->>Session: tool-owned events when applicable + loop bounded rolling pool until group drains + opt capacity available for an unstarted call + Driver->>Session: tool/call pending audit + Driver->>Tools: ordered pre / pooled dispatch + Tools-->>Session: tool-owned events when applicable + end + opt next model-order result is ready + Driver->>Tools: ordered post + Driver->>Session: tool/result + end end - Driver->>Session: tool/result in model order Driver->>Session: step/end Driver->>Hooks: agent/turn-continuation waterfall Driver->>Hooks: agent/turn-stop serial terminal checkpoint diff --git a/docs/architecture.md b/docs/architecture.md index 3f1ec18764..82b60d3879 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -84,10 +84,9 @@ forever: 'assistant/message' 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 -> monotonic guards -> tools/execute -> tools/post-execute -> tools/result - 'tool/result' committed in model order (slot-buffered) + while the bounded pool has work: + capacity available -> 'tool/call' -> tools/pre-execute -> monotonic guards -> tools/execute + next model-order slot ready -> tools/post-execute -> 'tool/result' append post-tool context (model order) and steering 'step/end' agent/turn-continuation diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9c1b5dd4c2..a8ca75de0a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -37,11 +37,17 @@ Source: [`packages/ui/acp/src/index.ts:203`](../packages/ui/acp/src/index.ts) * deployment persona (forwarded to the system-prompt plugin); `toolOrder` is * the explicit model-facing tool order (forwarded to the system-prompt plugin); * `tools` is the tool registry's config (its presentation `mode`, forwarded - * through agent-spine-demo); `persistenceRoot` is the JSONL backend's directory. + * through agent-spine-demo); `maxParallelToolCalls` configures the bundled + * agent loop; `persistenceRoot` is the JSONL backend's directory. */ export interface Config { /** Model name for ACP-created agents (must have a registered adapter). */ model: string + /** + * Concurrent parallel-safe tool-call cap for the bundled agent loop. A + * positive integer; the loop defaults it when omitted and `1` is serial. + */ + maxParallelToolCalls?: number /** Deployment persona (the system-prompt plugin's `persona` config). */ persona?: string /** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */ @@ -61,7 +67,7 @@ export interface Config { Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/examples/acp-demo/src/index.ts:31`](../packages/examples/acp-demo/src/index.ts) +Source: [`packages/examples/acp-demo/src/index.ts:32`](../packages/examples/acp-demo/src/index.ts) ## `@deepseek-ai/dsh-agent-loop` 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 9f123cb3f4..9326fda865 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 @@ -4,7 +4,7 @@ Status: implemented ## Problem -An assistant message may contain several sibling `tool-call` blocks. Running them serially adds the latency of independent reads, web requests, and subagent runs even though the model has already requested them together. +An assistant message may contain several sibling `tool-call` blocks. Running them serially adds the latency of independent reads and web requests even though the model has already requested them together. Concurrency is a host scheduling concern, not model-facing tool metadata. The loop needs to decide which calls may overlap without hardcoding tool names or exposing scheduler policy in the JSON schema. @@ -58,12 +58,10 @@ Any shared state touched during execution must be concurrency-safe. This include `maxParallelToolCalls` is a positive AgentLoop deployment cap shared by every agent the factory creates. It defaults to `10`; `1` preserves serial execution. Exact fields and defaults live in the generated [configuration catalog](../../../config-catalog.md). -The shipped declarations are conservative. Web search, web fetch, filesystem read, and foreground subagent calls opt in. Background subagent starts remain exclusive because they register parent-owned task state. Filesystem writes and edits, bash tools, workflow, user interaction, todo mutation, Code Mode, and Cordis mutation tools also remain exclusive. Bash stays exclusive until its owning package supplies a proven input-sensitive classifier. +The shipped declarations are conservative. Web search, web fetch, and filesystem read opt in. Filesystem writes and edits, bash tools, subagent delegation, workflow, user interaction, todo mutation, Code Mode, and Cordis mutation tools remain exclusive. A subagent may share its parent's workspace or external resources, and the unary classifier cannot prove that sibling delegations have disjoint effects. Bash stays exclusive until its owning package supplies a proven input-sensitive classifier. Filesystem read relies on a narrow recorder exception: its synchronous observation updates may settle out of order, but write and edit re-check the observed version before mutation, so stale state only produces `FS_STALE_VERSION`. -The subagent declaration requires providers to accept concurrent `start()` calls for independent runs. A provider may queue, enforce its own capacity, or return a typed failure instead of requiring the parent loop to serialize every subagent call. - ## Verification Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, the rolling cap, distinct execution objects, middleware order, ordered results and context, and abort draining. First-party tests pin each parallel declaration. @@ -98,6 +96,6 @@ Parallel calls may begin in cases where serial execution would have aborted befo Ordered commits may hold a fast result behind a slow earlier sibling. This preserves replay and model-history order while live surfaces still show pending progress. -Concurrent subagents and external calls can compete for quota or process capacity. Providers own their capacity controls; the loop cap only limits calls from one agent step. +Concurrent external calls can compete for quota or process capacity. Providers own their capacity controls; the loop cap only limits calls from one agent step. Tool registration is a scheduling boundary. The scheduler currently plans all groups before dispatch, so an earlier registry mutation can make a later classification stale. Binding dispatch to the classified definition or reclassifying after exclusive barriers remains a named correctness gap. diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index c13274a7d9..121bfd09f7 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -357,7 +357,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. You may issue several subagent calls in one message to run independent tasks concurrently when their work scopes do not overlap. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. +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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. ```json { diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md index 30d34948c5..33bfa25590 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md @@ -64,7 +64,7 @@ declare const tools: { /** The exact skill name from the available skills list. */ name: string; }): Promise; - /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ + /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ subagent(args: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; @@ -73,7 +73,7 @@ declare const tools: { /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ run_in_background?: boolean; }): Promise; - /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. You may issue several subagent calls in one message to run independent tasks concurrently when their work scopes do not overlap. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ + /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ subagent_fork(args: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json index 61970ba85c..daf4e93ee8 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json @@ -132,7 +132,7 @@ }, { "name": "subagent", - "description": "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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "description": "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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", "parameters": { "type": "object", "properties": { @@ -157,7 +157,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. You may issue several subagent calls in one message to run independent tasks concurrently when their work scopes do not overlap. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", "parameters": { "type": "object", "properties": { diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md index 07ce7afd14..36aa94c53c 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md @@ -49,7 +49,7 @@ declare const tools: { /** The exact skill name from the available skills list. */ name: string; }): Promise; - /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ + /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ subagent(args: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; @@ -58,7 +58,7 @@ declare const tools: { /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ run_in_background?: boolean; }): Promise; - /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. You may issue several subagent calls in one message to run independent tasks concurrently when their work scopes do not overlap. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ + /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ subagent_fork(args: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.golden.json index 817f2a3294..52e7974409 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.golden.json @@ -79,7 +79,7 @@ }, { "name": "subagent", - "description": "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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "description": "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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", "parameters": { "type": "object", "properties": { @@ -104,7 +104,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. You may issue several subagent calls in one message to run independent tasks concurrently when their work scopes do not overlap. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", "parameters": { "type": "object", "properties": { diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md index 07ce7afd14..36aa94c53c 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md @@ -49,7 +49,7 @@ declare const tools: { /** The exact skill name from the available skills list. */ name: string; }): Promise; - /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ + /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ subagent(args: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; @@ -58,7 +58,7 @@ declare const tools: { /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ run_in_background?: boolean; }): Promise; - /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. You may issue several subagent calls in one message to run independent tasks concurrently when their work scopes do not overlap. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ + /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ subagent_fork(args: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index f7ef68e20f..a5c592b685 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -131,8 +131,8 @@ {"type":"assistant/chunk","seq":129,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"} {"type":"tool/call","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"53b0ac7f-9728-4c59-8c0d-fb007ce2cb60","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"53b0ac7f-9728-4c59-8c0d-fb007ce2cb60","outcome":"allowed-once"}} +{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"a54fc428-a072-486e-97f5-913970766bae","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"a54fc428-a072-486e-97f5-913970766bae","outcome":"allowed-once"}} {"type":"tool/result","seq":134,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[131],"surfaceOp":"append"} {"type":"step/end","seq":135,"time":1783962245400,"data":{"turn":1,"step":1}} {"type":"step/start","seq":136,"time":1783962245400,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index 4a7f6bc9fb..66f49c8350 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -155,8 +155,8 @@ {"type":"assistant/chunk","seq":153,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153],"surfaceOp":"append"} {"type":"tool/call","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"33b44536-0658-49f2-83ba-9a9cb9048cbd","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"33b44536-0658-49f2-83ba-9a9cb9048cbd","outcome":"rejected"}} +{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"8efbb6f0-1774-4c18-95ff-a8502ca2937a","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"8efbb6f0-1774-4c18-95ff-a8502ca2937a","outcome":"rejected"}} {"type":"tool/result","seq":158,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[155],"surfaceOp":"append"} {"type":"step/end","seq":159,"time":1783962246276,"data":{"turn":1,"step":1}} {"type":"step/start","seq":160,"time":1783962246276,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index cda99ed9ac..1fa40f9ace 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -55,8 +55,8 @@ {"type":"tool/call","seq":53,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":54,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":55,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"2e0af4ba-d9e7-4165-a2f9-313355f5c731","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"2e0af4ba-d9e7-4165-a2f9-313355f5c731","outcome":"rejected"}} +{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"47149e48-ec0b-4b29-9096-a27f94991e1e","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"47149e48-ec0b-4b29-9096-a27f94991e1e","outcome":"rejected"}} {"type":"tool/result","seq":58,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":59,"time":1783962235814,"data":{"turn":1,"step":1}} {"type":"step/start","seq":60,"time":1783962235814,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.golden.json index 127a1f962c..4b07d8edef 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.golden.json @@ -63,7 +63,7 @@ }, { "name": "subagent", - "description": "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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "description": "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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", "parameters": { "type": "object", "properties": { @@ -88,7 +88,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. You may issue several subagent calls in one message to run independent tasks concurrently when their work scopes do not overlap. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", "parameters": { "type": "object", "properties": { diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.golden.json index 127a1f962c..4b07d8edef 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.golden.json @@ -63,7 +63,7 @@ }, { "name": "subagent", - "description": "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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "description": "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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", "parameters": { "type": "object", "properties": { @@ -88,7 +88,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. You may issue several subagent calls in one message to run independent tasks concurrently when their work scopes do not overlap. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", "parameters": { "type": "object", "properties": { diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.golden.json index 127a1f962c..4b07d8edef 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.golden.json @@ -63,7 +63,7 @@ }, { "name": "subagent", - "description": "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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "description": "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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", "parameters": { "type": "object", "properties": { @@ -88,7 +88,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. You may issue several subagent calls in one message to run independent tasks concurrently when their work scopes do not overlap. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", "parameters": { "type": "object", "properties": { diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.golden.json index c74ab8b318..da5e23216c 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.golden.json @@ -117,7 +117,7 @@ }, { "name": "subagent", - "description": "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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "description": "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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", "parameters": { "type": "object", "properties": { @@ -142,7 +142,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. You may issue several subagent calls in one message to run independent tasks concurrently when their work scopes do not overlap. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", "parameters": { "type": "object", "properties": { diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index c778ab6c2e..122a1b284b 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -26,6 +26,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron | Key | Default | Routed to | |---|---|---| | `model` | (required) | the per-session agent template the bridge creates agents from | +| `maxParallelToolCalls` | agent-loop default | positive-integer concurrent tool-call cap shared by the bundled loop's agents; `1` is serial | | `persona` | — | the deployment persona template (may reference `{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` | | `toolOrder` | — | explicit model-facing tool order (a name list with one `''` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` | | `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` | diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index 917cb128a6..619aaf0ebb 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -26,11 +26,17 @@ export const name = 'acp-demo' * deployment persona (forwarded to the system-prompt plugin); `toolOrder` is * the explicit model-facing tool order (forwarded to the system-prompt plugin); * `tools` is the tool registry's config (its presentation `mode`, forwarded - * through agent-spine-demo); `persistenceRoot` is the JSONL backend's directory. + * through agent-spine-demo); `maxParallelToolCalls` configures the bundled + * agent loop; `persistenceRoot` is the JSONL backend's directory. */ export interface Config { /** Model name for ACP-created agents (must have a registered adapter). */ model: string + /** + * Concurrent parallel-safe tool-call cap for the bundled agent loop. A + * positive integer; the loop defaults it when omitted and `1` is serial. + */ + maxParallelToolCalls?: number /** Deployment persona (the system-prompt plugin's `persona` config). */ persona?: string /** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */ @@ -52,6 +58,9 @@ export interface Config { /* jscpd:ignore-start */ export const Config: z = z.object({ model: z.string().required(), + // A positive integer; a bad value (0, negative, fractional) fails config + // validation here rather than being silently dropped from cordis.yml. + maxParallelToolCalls: z.number().step(1).min(1), persona: z.string(), // The array default is forced to undefined: ABSENT means "lexicographic // order" (the owning dsh-system-prompt schema does the same), while @@ -79,6 +88,7 @@ export function apply(ctx: Context, config: Config): void { ...config.persona !== undefined ? { persona: config.persona } : {}, ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {}, ...config.tools !== undefined ? { tools: config.tools } : {}, + ...config.maxParallelToolCalls !== undefined ? { maxParallelToolCalls: config.maxParallelToolCalls } : {}, ...config.skills !== undefined ? { skills: config.skills } : {}, ...config.toolBash !== undefined ? { toolBash: config.toolBash } : {}, ...config.toolTasks !== undefined ? { toolTasks: config.toolTasks } : {}, diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index 285e4756d0..1a6bd0483a 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -113,6 +113,17 @@ describe('dsh-acp-demo composition', () => { await ctx.fiber.dispose() }) + it('forwards maxParallelToolCalls to the bundled agent loop', async () => { + const ctx = await mount({ + model: 'mock', + maxParallelToolCalls: 3, + persistenceRoot: '/tmp/dsh-acp-demo-test-parallel', + skills: await isolatedSkillsConfig(), + }) + expect(ctx.get('agentLoop')?.config.maxParallelToolCalls).toBe(3) + await ctx.fiber.dispose() + }) + it('forwards bundled tool config into agent-core', async () => { const ctx = await mount({ model: 'mock', diff --git a/packages/subagent/README.md b/packages/subagent/README.md index 4ee59eab46..62de4ffb08 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -14,6 +14,4 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), the out-of-process `subagent-acp` backend builds on the `subagent-subprocess` library (the credential env scrub, the dispose ladder, isolated config dirs) and ships alongside them here; the test-only `dsh-subagent-mock` (in [support](../support/README.md)) is separate. All **product** packages except the mock. -`SubagentProvider.start()` must be safe to call concurrently for independent runs: foreground `subagent` calls are parallel-safe, so one parent step may issue several at once. Background starts remain exclusive while registering parent-owned task state. Each backend reads the parent synchronously at start (a snapshot, never mutated or re-read during the run) — `fork` seeds each child from the parent's completed-turn prefix, which the open in-flight turn cannot change, so concurrent forks inside one open step all see the same stable prefix. A resource-limited provider may queue or cap internally, but must not require the loop to serialize every foreground call. - The proposal and design rationale: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md). diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 4fed9816ea..05bb40d575 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -186,13 +186,6 @@ export interface SubagentProvider { * honorable when present. If setup fails or `request.signal` aborts before * fulfillment, the provider owns and cleans all partial resources before this * promise rejects. Ownership transfers to the caller only on fulfillment. - * - * MUST be safe to call concurrently for independent runs: foreground - * `subagent` calls are parallel-safe, so a parent step may issue several at once, - * each invoking `start()` before an earlier run settles. An implementation - * snapshots the parent at start and must not require the parent loop to - * serialize every foreground `subagent` call; a resource-limited provider queues or - * rejects internally. */ start(request: SubagentStartRequest): Promise } diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 7e0aa298ad..637ddeded2 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -26,7 +26,7 @@ With `run_in_background: true`, the tool registers the parent-owned task before ## Concurrency -Foreground calls opt into concurrent scheduling because each owns an independent child run and returns only its final answer. Background starts remain exclusive because they register parent-owned task state. Providers must accept concurrent `start()` calls for independent runs; they may queue internally, enforce capacity, or return a typed failure. See the [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md). +Foreground and background calls are exclusive. Children may share the parent's workspace or external resources, and the unary scheduler classifier cannot prove that sibling delegations have disjoint effects. See the [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md). ## Model Experience diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 8455537d42..cee354bdfe 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -174,8 +174,7 @@ export function providerWording(inheritsConversation: boolean): { description: s + 'completed turns so far (it does not see the current in-flight turn), returning only its final ' + 'result. Use this when the subtask builds on this conversation\'s context — a follow-up analysis, ' + 'a review, a continuation — without consuming this conversation\'s context for the work itself. ' - + 'You receive only its final answer, not its intermediate steps. You may issue several subagent ' - + 'calls in one message to run independent tasks concurrently when their work scopes do not overlap.', + + 'You receive only its final answer, not its intermediate steps.', promptDescription: 'The task for the subagent. It already sees this conversation\'s completed turns, so build on them ' + 'freely and state only what is new.', @@ -187,8 +186,7 @@ export function providerWording(inheritsConversation: boolean): { description: s + '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.', + + 'complete, standalone prompt: it does not see this conversation.', promptDescription: 'The complete, self-contained task for the subagent. It does not share this ' + 'conversation\'s context, so include everything it needs.', @@ -254,9 +252,6 @@ export function apply(ctx: Context, config: Config): void { }, } : {}, }, - // A foreground call owns only its child run; background mode first - // registers parent-owned task state and therefore remains exclusive. - isConcurrencySafe: args => args.run_in_background !== true, async execute(args, exec): Promise { const parent = exec.agent if (!parent) { diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index a057ec6e4d..90f84bfceb 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -96,13 +96,13 @@ describe('dsh-tool-subagent', () => { expect(foreground.isError).toBe(false) }) - it('classifies foreground calls as parallel and background starts as exclusive', async () => { + it('keeps foreground and background calls exclusive', async () => { const ctx = await setup({ provider: 'mock' }) expect(ctx.tools.executionMode({ - callId: CallId('subagent-safe'), + callId: CallId('subagent-foreground'), name: 'subagent', arguments: { description: 'do work', prompt: 'Reply OK' }, - })).toEqual({ kind: 'parallel' }) + })).toEqual({ kind: 'exclusive' }) expect(ctx.tools.executionMode({ callId: CallId('subagent-background'), name: 'subagent', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 29183eff5d..feaafdac9e 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -823,12 +823,17 @@ function renderLifecycle(): string { ` Driver->>Hooks: ${mermaidCode('agent/step-result')} waterfall`, ` Driver->>Session: ${mermaidCode('assistant/message')}`, ' Driver->>Tools: group calls by executionMode', - ' loop started tool calls (bounded pool)', - ` Driver->>Session: ${mermaidCode('tool/call')} pending audit`, - ' Driver->>Tools: ordered pre / pooled dispatch / ordered post', - ' Tools-->>Session: tool-owned events when applicable', + ' loop bounded rolling pool until group drains', + ' opt capacity available for an unstarted call', + ` Driver->>Session: ${mermaidCode('tool/call')} pending audit`, + ' Driver->>Tools: ordered pre / pooled dispatch', + ' Tools-->>Session: tool-owned events when applicable', + ' end', + ' opt next model-order result is ready', + ' Driver->>Tools: ordered post', + ` Driver->>Session: ${mermaidCode('tool/result')}`, + ' end', ' end', - ` Driver->>Session: ${mermaidCode('tool/result')} in model order`, ` Driver->>Session: ${mermaidCode('step/end')}`, ` Driver->>Hooks: ${mermaidCode('agent/turn-continuation')} waterfall`, ` Driver->>Hooks: ${mermaidCode('agent/turn-stop')} serial terminal checkpoint`, From 62f6251bf98f2f7ce7dd09f97f3e3db6c4190d78 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:55:10 +0800 Subject: [PATCH 11/14] fix(agent-loop): reclassify pending tool calls --- docs/agent-lifecycle.md | 4 +- docs/architecture.md | 4 +- ...2026-07-10-parallel-tool-call-execution.md | 6 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/tool-calls.ts | 92 +++++++--------- .../core/agent-loop/tests/tool-calls.spec.ts | 100 ++++++++++++++++-- scripts/gen-doc-graphs.ts | 4 +- 7 files changed, 140 insertions(+), 72 deletions(-) diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 7de8e02bf1..4931da1f04 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -34,8 +34,8 @@ sequenceDiagram Session-->>SDK: session/event assistant/chunk* Driver->>Hooks: agent/step-result waterfall Driver->>Session: assistant/message - Driver->>Tools: group calls by executionMode - loop bounded rolling pool until group drains + Driver->>Tools: classify next call by executionMode + loop bounded rolling pool with reclassification before replenishing opt capacity available for an unstarted call Driver->>Session: tool/call pending audit Driver->>Tools: ordered pre / pooled dispatch diff --git a/docs/architecture.md b/docs/architecture.md index 82b60d3879..24eff486f5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -82,8 +82,8 @@ forever: 'assistant/chunk' agent/step-result 'assistant/message' - schedule tool calls by ctx.tools.executionMode (exclusive = barrier; - consecutive parallel-safe = one rolling-pool group, <= maxParallelToolCalls in flight): + schedule tool calls by ctx.tools.executionMode (reclassify before pool replenishment; + exclusive = barrier; parallel-safe = rolling pool, <= maxParallelToolCalls in flight): while the bounded pool has work: capacity available -> 'tool/call' -> tools/pre-execute -> monotonic guards -> tools/execute next model-order slot ready -> tools/post-execute -> 'tool/result' 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 9326fda865..1f97bf0919 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 @@ -24,7 +24,7 @@ A tagged mode, rather than a public boolean scheduler API, leaves room for a fut ## Scheduling and ordering -The loop waits for the complete assistant message, parses every call once, creates a distinct `ToolExecution` for each call, and scans them in model order. Consecutive parallel calls form one group; every exclusive call forms a singleton group and an ordering barrier. Groups execute sequentially. +The loop waits for the complete assistant message, parses every call once, creates a distinct `ToolExecution` for each call, and scans them in model order. Consecutive parallel calls form one group; every exclusive call forms a singleton group and an ordering barrier. Groups execute sequentially. Classification is lazy: the scheduler resolves the next call after each barrier and reclassifies every later call before replenishing a parallel pool. If a registry mutation makes that call exclusive, the current pool drains before the call starts as the next barrier. For example: @@ -64,7 +64,7 @@ Filesystem read relies on a narrow recorder exception: its synchronous observati ## Verification -Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, the rolling cap, distinct execution objects, middleware order, ordered results and context, and abort draining. First-party tests pin each parallel declaration. +Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, live reclassification after registry replacement, the rolling cap, distinct execution objects, middleware order, ordered results and context, and abort draining. First-party tests pin each parallel declaration. Snapshot coverage pins the visible multi-call transcript: pending calls may overlap while completed results remain model-ordered. Code Mode coverage pins its serial boundary. No provider-backed e2e is required because scheduling is deterministic loop behavior. @@ -98,4 +98,4 @@ Ordered commits may hold a fast result behind a slow earlier sibling. This prese Concurrent external calls can compete for quota or process capacity. Providers own their capacity controls; the loop cap only limits calls from one agent step. -Tool registration is a scheduling boundary. The scheduler currently plans all groups before dispatch, so an earlier registry mutation can make a later classification stale. Binding dispatch to the classified definition or reclassifying after exclusive barriers remains a named correctness gap. +Tool registration is a scheduling boundary. Registry mutations affect not-yet-started calls because the scheduler reclassifies after each barrier and before every pool replenishment. Already-started calls retain the scheduling decision under which they entered the pool. diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index d8439de885..fdb800f75b 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -53,7 +53,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. 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, 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. ### What belongs to plugins diff --git a/packages/core/agent-loop/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts index 9e779b8fe2..7cdd726d42 100644 --- a/packages/core/agent-loop/src/tool-calls.ts +++ b/packages/core/agent-loop/src/tool-calls.ts @@ -1,11 +1,12 @@ /** * 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 it via `ctx.tools.executionMode`, partitions the - * calls into ordered groups (one exclusive call, or a run of consecutive - * parallel-safe calls), and runs every group through the same rolling pool - * bounded by the agent-loop's `maxParallelToolCalls` config — an exclusive - * group is a pool of one. + * 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 @@ -23,7 +24,7 @@ import type { Context } from 'cordis' import { assertNever, type ToolCallBlock } from '@deepseek-ai/dsh-llm' import type { HookContext } from '@deepseek-ai/dsh-agent' import type { Session } from '@deepseek-ai/dsh-session' -import { TOOL_REGISTRY_SCHEDULER, type ToolExecution, type ToolExecutionInput, type ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import { TOOL_REGISTRY_SCHEDULER, type ToolExecution, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult } from '@deepseek-ai/dsh-tools' import type { ReactLoopAgent } from './agent.ts' /** One tool call after argument parsing, ready to schedule. */ @@ -89,19 +90,17 @@ export async function executeToolCalls( }, })) - // Partition into ordered groups: an exclusive call is its own group (a - // barrier), a run of consecutive parallel-safe calls is one group. Grouping - // uses executionMode so an exclusive tool between two reads splits them into - // separate ordered groups (no read/write race inside one assistant step). - const groups = groupByMode(ctx, planned) - - // Every group runs through the same rolling pool: an exclusive call is a - // singleton group (pool of one, a barrier), a parallel-safe run is one group - // bounded by the cap. `groupByMode` already classified each call, so the loop - // does not re-query `executionMode` here. const pendingContext: HookContext[] = [] - for (const group of groups) { - await runGroup(ctx, session, turn, step, group, signal, maxParallel, pendingContext) + 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. + // 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 + const group = mode === 'parallel' ? planned.slice(next) : [first] + next += await runGroup(ctx, session, turn, step, group, mode, signal, maxParallel, pendingContext) } return pendingContext } @@ -115,41 +114,15 @@ function parseArguments(raw: string): unknown { } } -/** - * Group planned calls into ordered runs: each exclusive call is a singleton - * group; consecutive parallel-safe calls coalesce into one group. `executionMode` - * is the sole classification point — the caller runs every group through the - * rolling pool without re-querying it. The read is pure and cheap. - */ -function groupByMode(ctx: Context, planned: PlannedCall[]): PlannedCall[][] { - const groups: PlannedCall[][] = [] - let run: PlannedCall[] = [] - const flush = (): void => { - if (run.length > 0) { - groups.push(run) - run = [] - } - } - for (const call of planned) { - if (ctx.tools.executionMode(call.exec).kind === 'parallel') { - run.push(call) - } else { - flush() - groups.push([call]) - } - } - flush() - return groups -} - /** * 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`, and whenever one settles starts the next unstarted call - * until the group is exhausted. Settled dispatches land in model-order slots; a - * commit cursor appends `tool/result` (and collects `additionalContext`) only - * while the next slot is ready, so the log stays model-ordered regardless of - * completion order. + * 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 `additionalContext`) 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 @@ -161,10 +134,11 @@ async function runGroup( turn: number, step: number, group: PlannedCall[], + mode: ToolExecutionMode['kind'], signal: AbortSignal, maxParallel: number, pendingContext: HookContext[], -): Promise { +): Promise { /* 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) @@ -227,6 +201,13 @@ async function runGroup( const fillPool = async (): Promise => { 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. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition + const nextCall = group[nextToStart]! + if (nextToStart > 0 && mode === 'parallel' + && ctx.tools.executionMode(nextCall.exec).kind !== 'parallel') break await startCall(nextToStart) nextToStart++ await commitReady() @@ -259,10 +240,11 @@ async function runGroup( /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ throw new Error(String(signal.reason ?? 'aborted')) } - // A defensive check the started count matches what we committed — a parallel - // group with no abort commits every started slot, and started === group.length. - /* v8 ignore next -- unreachable: a non-aborted group starts and commits all calls */ + // 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). */ diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 8ca6170b03..da409ff911 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -1,9 +1,10 @@ /** - * The per-step tool-call scheduler (`tool-calls.ts`): grouping by + * 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, interleaved `tool/call` - * audit records, ordered `tools/pre-execute`/`tools/post-execute`, - * model-ordered `additionalContext`, and abort behavior. + * `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 `additionalContext`, + * 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). @@ -63,15 +64,15 @@ function multiCall(calls: { id: string; name: string; args: object }[]): StreamC return chunks } -/** A parallel-safe tool whose calls block until the test releases them by callId. */ -function gatedParallelTool(name: string) { +/** A tool whose calls block until the test releases them by callId. */ +function gatedTool(name: string, parallel: boolean) { const gates = new Map void>() const started: string[] = [] const tool = defineTool({ name, description: `gated ${name}`, parameters: { id: { type: 'string', required: true } }, - isConcurrencySafe: () => true, + ...parallel ? { isConcurrencySafe: () => true } : {}, async execute(args) { started.push(args.id) await new Promise((resolve) => { gates.set(args.id, resolve) }) @@ -87,6 +88,16 @@ function gatedParallelTool(name: string) { } } +/** 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) +} + /** Poll until `predicate` holds, letting microtasks/timers drain between checks. */ async function until(predicate: () => boolean): Promise { for (let i = 0; i < 1000 && !predicate(); i++) await new Promise(r => setTimeout(r, 0)) @@ -142,6 +153,81 @@ describe('tool-call scheduler: grouping and barriers', () => { // 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']) }) + + it('reclassifies pending calls after an exclusive barrier replaces their tool', async () => { + const adapter = new MockAdapter([ + multiCall([ + { id: 'c1', name: 'replace', args: { id: '0' } }, + { id: 'c2', name: 'x', args: { id: '1' } }, + { id: 'c3', name: 'x', args: { id: '2' } }, + ]), + textResponse('done'), + ]) + const ctx = await harness(adapter) + const replacement = gatedExclusiveTool('x') + const disposeSafe = ctx.tools.register(defineTool({ + name: 'x', + description: 'initially safe', + parameters: { id: { type: 'string', required: true } }, + isConcurrencySafe: () => true, + async execute(args) { return [{ type: 'text', text: `old-${args.id}` }] }, + })) + ctx.tools.register(defineTool({ + name: 'replace', + description: 'replace x', + parameters: { id: { type: 'string', required: true } }, + async execute() { + disposeSafe() + ctx.tools.register(replacement.tool) + return [{ type: 'text', text: 'replaced' }] + }, + })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + agent.send([{ type: 'text', text: 'go' }]) + await until(() => replacement.started.length === 1) + await new Promise(r => setTimeout(r, 5)) + expect(replacement.started).toEqual(['1']) + replacement.release('1') + await until(() => replacement.started.length === 2) + expect(replacement.started).toEqual(['1', '2']) + replacement.release('2') + await waitForIdle(ctx, agent) + }) + + it('stops replenishing when a result observer makes the next call exclusive', async () => { + const adapter = new MockAdapter([ + multiCall([ + { id: 'c1', name: 'x', args: { id: '1' } }, + { id: 'c2', name: 'x', args: { id: '2' } }, + { id: 'c3', name: 'x', args: { id: '3' } }, + ]), + textResponse('done'), + ]) + const ctx = await harness(adapter, 2) + const initial = gatedParallelTool('x') + const replacement = gatedExclusiveTool('x') + const disposeInitial = ctx.tools.register(initial.tool) + ctx.on('tools/result', (exec) => { + if (exec.callId !== CallId('c1')) return + disposeInitial() + ctx.tools.register(replacement.tool) + }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + agent.send([{ type: 'text', text: 'go' }]) + await until(() => initial.started.length === 2) + initial.release('1') + await until(() => events(agent).some(event => + event.type === 'tool/result' && event.data.callId === CallId('c1'))) + await new Promise(r => setTimeout(r, 5)) + expect(replacement.started).toEqual([]) + initial.release('2') + await until(() => replacement.started.length === 1) + expect(replacement.started).toEqual(['3']) + replacement.release('3') + await waitForIdle(ctx, agent) + }) }) describe('tool-call scheduler: model-order results despite out-of-order settlement', () => { diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index feaafdac9e..5361a9d93d 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -822,8 +822,8 @@ function renderLifecycle(): string { ` Session-->>SDK: ${mermaidCode('session/event')} ${mermaidCode('assistant/chunk')}*`, ` Driver->>Hooks: ${mermaidCode('agent/step-result')} waterfall`, ` Driver->>Session: ${mermaidCode('assistant/message')}`, - ' Driver->>Tools: group calls by executionMode', - ' loop bounded rolling pool until group drains', + ' Driver->>Tools: classify next call by executionMode', + ' loop bounded rolling pool with reclassification before replenishing', ' opt capacity available for an unstarted call', ` Driver->>Session: ${mermaidCode('tool/call')} pending audit`, ' Driver->>Tools: ordered pre / pooled dispatch', From 21ec178841db7387e1b223200232c335a8de3bd9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:59:26 +0800 Subject: [PATCH 12/14] docs: tighten parallel tool-call prose --- docs/agent-lifecycle.md | 12 +- docs/architecture.md | 10 +- docs/config-catalog.md | 24 ++-- docs/cordis-catalog/services.md | 4 +- docs/core-data-structures/tools.md | 29 ++--- ...2026-07-10-parallel-tool-call-execution.md | 8 +- packages/core/agent-loop/README.md | 6 +- packages/core/agent-loop/src/agent.ts | 4 +- packages/core/agent-loop/src/constants.ts | 13 +- packages/core/agent-loop/src/index.ts | 8 +- packages/core/agent-loop/src/loop.ts | 7 +- packages/core/agent-loop/src/tool-calls.ts | 119 ++++++------------ .../core/agent-loop/tests/tool-calls.spec.ts | 35 +----- packages/core/tools/README.md | 6 +- packages/core/tools/src/index.ts | 69 ++++------ packages/core/tools/src/schema.ts | 19 ++- .../core/tools/tests/execution-mode.spec.ts | 22 +--- packages/examples/acp-demo/src/index.ts | 10 +- .../examples/agent-spine-demo/src/index.ts | 2 +- packages/examples/stdio-demo/src/index.ts | 7 +- packages/fs/tool-fs/README.md | 2 +- packages/fs/tool-fs/src/read.ts | 7 +- packages/fs/tool-fs/tests/integration.spec.ts | 4 +- packages/subagent/tool-subagent/README.md | 2 +- packages/web/tool-web/README.md | 2 +- packages/web/tool-web/src/fetch.ts | 3 +- packages/web/tool-web/src/search.ts | 3 +- scripts/gen-doc-graphs.ts | 12 +- website/zh-CN/api/harness/agent-loop.md | 8 +- website/zh-CN/api/harness/tools.md | 24 ++-- 30 files changed, 161 insertions(+), 320 deletions(-) diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 4931da1f04..99ca50bc11 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -34,14 +34,14 @@ sequenceDiagram Session-->>SDK: session/event assistant/chunk* Driver->>Hooks: agent/step-result waterfall Driver->>Session: assistant/message - Driver->>Tools: classify next call by executionMode - loop bounded rolling pool with reclassification before replenishing - opt capacity available for an unstarted call - Driver->>Session: tool/call pending audit - Driver->>Tools: ordered pre / pooled dispatch + Driver->>Tools: classify pending call by executionMode + loop barriers and bounded rolling pool, reclassify before start + opt call starts + Driver->>Session: tool/call + Driver->>Tools: ordered pre, concurrent execute Tools-->>Session: tool-owned events when applicable end - opt next model-order result is ready + opt next model-order result ready Driver->>Tools: ordered post Driver->>Session: tool/result end diff --git a/docs/architecture.md b/docs/architecture.md index cf73f3b7de..26f4979082 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -82,11 +82,11 @@ forever: 'assistant/chunk' agent/step-result 'assistant/message' - schedule tool calls by ctx.tools.executionMode (reclassify before pool replenishment; - exclusive = barrier; parallel-safe = rolling pool, <= maxParallelToolCalls in flight): - while the bounded pool has work: - capacity available -> 'tool/call' -> tools/pre-execute -> monotonic guards -> tools/execute - next model-order slot ready -> tools/post-execute -> 'tool/result' + schedule tool calls by ctx.tools.executionMode: + exclusive -> one-call barrier + parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start + each start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute + each model-order result -> ordered tools/post-execute -> 'tool/result' append post-tool context (model order) and steering 'step/end' agent/turn-continuation diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a1c716db6f..9f27cfb91a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -39,18 +39,14 @@ Source: [`packages/ui/acp/src/index.ts:208`](../packages/ui/acp/src/index.ts) * deployment persona (forwarded to the system-prompt plugin); `toolOrder` is * the explicit model-facing tool order (forwarded to the system-prompt plugin); * `tools` is the tool registry's config (its presentation `mode`, forwarded - * through agent-spine-demo); `maxParallelToolCalls` configures the bundled - * agent loop; `persistenceRoot` is the JSONL backend's directory. + * through agent-spine-demo); `persistenceRoot` is the JSONL backend's directory. */ export interface Config { /** Provider route for ACP-created agents. */ provider: string /** Model name for ACP-created agents (must have a registered adapter). */ model: string - /** - * Concurrent parallel-safe tool-call cap for the bundled agent loop. A - * positive integer; the loop defaults it when omitted and `1` is serial. - */ + /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ maxParallelToolCalls?: number /** Deployment persona (the system-prompt plugin's `persona` config). */ persona?: string @@ -75,7 +71,7 @@ export interface Config { Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/examples/acp-demo/src/index.ts:33`](../packages/examples/acp-demo/src/index.ts) +Source: [`packages/examples/acp-demo/src/index.ts:32`](../packages/examples/acp-demo/src/index.ts) ## `@deepseek-ai/dsh-agent-loop` @@ -85,9 +81,8 @@ Requires: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` /** 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. */ @@ -127,7 +122,7 @@ Source: [`packages/core/agent-loop/src/index.ts:334`](../packages/core/agent-loo export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ agents?: AgentLoopConfig['agents'] - /** Shared concurrent tool-call cap (see dsh-agent-loop's `Config`). */ + /** Agent-loop concurrency cap; `1` is serial. */ maxParallelToolCalls?: AgentLoopConfig['maxParallelToolCalls'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] @@ -814,10 +809,7 @@ export interface Config { provider: string /** Model name for the `main` agent (must have a registered adapter). */ model: string - /** - * Concurrent parallel-safe tool-call cap for the bundled agent loop. A - * positive integer; the loop defaults it when omitted and `1` is serial. - */ + /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ maxParallelToolCalls?: number /** Deployment persona (the system-prompt plugin's `persona` config). */ persona?: string @@ -1215,7 +1207,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:391`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:382`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-user-approval` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 571626040d..d307a04a6c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -19,7 +19,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:353`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:352`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` @@ -312,7 +312,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:447`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:438`](../../packages/core/tools/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 72e1e533fb..82b4aa4acd 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -20,28 +20,15 @@ interface ToolDefinition extends ToolSchema { */ 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. + * Pure synchronous classifier for overlap with sibling tool calls. Only + * `true` opts in; omission, exceptions, non-`true` returns, and invalid + * `defineTool` arguments are exclusive. This metadata is never model-visible. * - * 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). The check performs no I/O and receives no - * live `Agent` or mutable `ToolExecution`. - * - * Declaring `true` is a contract: during `execute` the tool body must NOT - * mutate the parent agent's session or other parent-owned async state (no - * `exec.agent.session.append(...)`, no `agent.inject(...)`); its only parent- - * step outputs are the returned content, `meta`, structured error, and - * `additionalContext` on the loop's ordered post-execute path. A synchronous, - * side-effect-only recorder whose updates are commutative or fail closed for - * concurrent same-session calls is the one exception (`fs/observed` is the - * worked example). Full contract and rationale: the parallel-tool-call RFC - * (docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md). + * Opted-in executions must not mutate parent-owned state. Shared state must + * tolerate concurrent dispatch; recorder races are permitted only when they + * commute or fail closed. See the parallel-tool-call RFC for the full contract. + * @param args - parsed arguments; `defineTool` validates before calling. + * @returns Whether this call may join a parallel group. */ isConcurrencySafe?(args: unknown): boolean /** 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 034a7b3fdc..3746e2c64c 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 @@ -16,11 +16,11 @@ Each tool may provide an optional `isConcurrencySafe(args)` classifier. It is sy The classifier is deliberately unary. Returning `true` is the tool's promise that this call may overlap with any sibling call that also returns `true`; the scheduler does not compare calls or prove that their resource accesses are compatible. -Arguments still support input-sensitive classification. A tool may classify a read-only operation as parallel and a mutating operation as exclusive. The interface cannot express relational rules such as "these writes are safe only when their paths differ," so a call whose safety depends on a sibling remains exclusive. +The unary classifier remains input-sensitive. A tool may classify a read-only operation as parallel and a mutating operation as exclusive. The interface cannot express relational rules such as "these writes are safe only when their paths differ," so a call whose safety depends on a sibling remains exclusive. `defineTool()` validates arguments before invoking a typed classifier. Invalid arguments classify as exclusive and produce the ordinary argument error only if the call executes. `ctx.tools.executionMode(exec)` resolves the live tool definition and returns the tagged `parallel` or `exclusive` mode; unknown tools fail closed to exclusive. -A tagged mode, rather than a public boolean scheduler API, leaves room for a future resource-aware mode without changing the classifier contract. +A tagged mode, rather than a public boolean scheduler API, keeps resource-aware variants representable without changing the classifier contract. ## Scheduling and ordering @@ -58,7 +58,7 @@ Any shared state touched during execution must be concurrency-safe. This include `maxParallelToolCalls` is a positive AgentLoop deployment cap shared by every agent the factory creates. It defaults to `10`; `1` preserves serial execution. Exact fields and defaults live in the generated [configuration catalog](../../../config-catalog.md). -The shipped declarations are conservative. Web search, web fetch, and filesystem read opt in. Filesystem writes and edits, bash tools, subagent delegation, workflow, user interaction, todo mutation, Code Mode, and Cordis mutation tools remain exclusive. A subagent may share its parent's workspace or external resources, and the unary classifier cannot prove that sibling delegations have disjoint effects. Bash stays exclusive until its owning package supplies a proven input-sensitive classifier. +The shipped declarations are conservative. Web search, web fetch, and filesystem read opt in. Filesystem writes and edits, bash tools, subagent delegation, workflow, user interaction, todo mutation, Code Mode, and Cordis mutation tools remain exclusive. A subagent may share its parent's workspace or external resources, and the unary classifier cannot prove that sibling delegations have disjoint effects. Bash has no proven input-sensitive classifier and remains exclusive. Filesystem read relies on a narrow recorder exception: its synchronous observation updates may settle out of order, but write and edit re-check the observed version before mutation, so stale state only produces `FS_STALE_VERSION`. @@ -80,7 +80,7 @@ Snapshot coverage pins the visible multi-call transcript: pending calls may over **Parallelize the complete tool pipeline.** This keeps the loop on the public one-call API but runs pre- and post-execute middleware concurrently. Existing guards and hook bridges may carry ordered state, so only dispatch overlaps. -**Expose staged methods or a scheduling waterfall.** Public `prepare` / `dispatch` / `finalize` methods or a `tools/execution-mode` event add extension surface before another consumer needs it. The loop uses an internal scheduler view, while `executionMode(exec)` remains the insertion point for a future policy seam. +**Expose staged methods or a scheduling waterfall.** Public `prepare` / `dispatch` / `finalize` methods or a `tools/execution-mode` event add extension surface before another consumer needs it. The loop uses an internal scheduler view, while `executionMode(exec)` leaves an insertion point for a policy seam. **Start calls while the model streams.** This may reduce latency further but changes assistant-message authority, replay, and call/result pairing. The scheduler starts only after the assistant message is complete. diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index c1cc6f333c..bc4355be3a 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -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-` 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. diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index a62959f933..faea7d7f08 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -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 diff --git a/packages/core/agent-loop/src/constants.ts b/packages/core/agent-loop/src/constants.ts index 72ba7051c2..3f5510967a 100644 --- a/packages/core/agent-loop/src/constants.ts +++ b/packages/core/agent-loop/src/constants.ts @@ -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 diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 076f94fc1f..5f5b9a9eb6 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -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 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 } diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 918966c034..c93e77e95a 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -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, diff --git a/packages/core/agent-loop/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts index 4c82e4fbe5..663321d1ff 100644 --- a/packages/core/agent-loop/src/tool-calls.ts +++ b/packages/core/agent-loop/src/tool-calls.ts @@ -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 { 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 => { 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>() const startCall = async (index: number): Promise => { - // 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 => { 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, diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 0b392f491a..333975e2a6 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -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' } }, diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 5a9f631a0b..cfe2fdf2fa 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -21,7 +21,7 @@ tools: - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)). - `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber. - `ctx.tools.execute(exec)` losslessly snapshots and freezes arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, then independently snapshots the authoritative outcome before final observation. Invalid arguments use the same result path without reaching policy or the body; around wrappers may replace only `signal`. -- `ctx.tools.executionMode(exec)` returns `parallel` only when the visible definition's `isConcurrencySafe(arguments)` classifier returns exactly `true`; unknown, hidden, undeclared, invalid, or throwing classifications are exclusive. +- `ctx.tools.executionMode(exec)` returns `parallel` only when the visible definition's `isConcurrencySafe(exec.arguments)` classifier returns exactly `true`; unknown, hidden, undeclared, invalid, or throwing classifications are exclusive. ### Injected services @@ -88,7 +88,7 @@ See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, an Optional `timeoutMs` must be positive and finite; it is policy metadata, not model-visible schema. -Optional `isConcurrencySafe(args)` receives the typed, softly validated argument shape. Returning `true` permits concurrent dispatch/body execution within a step; invalid input and all other outcomes remain exclusive. A safe tool must not mutate parent-owned async state during its body. Ordered returned content, metadata, errors, and post-execute context remain supported; synchronous recorders are safe only when races fail closed, as with filesystem observed-version tracking. +Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. Exact `true` permits concurrent dispatch/body execution; invalid input and all other outcomes remain exclusive. Opted-in bodies do not mutate parent-owned state, and shared-state races must commute or fail closed. The [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md) owns the full safety contract. ### Structured-output schema subset @@ -113,7 +113,7 @@ Under `code` or `both`, the registry exposes the reserved `run_code` transport a ### Parallel execution -The agent loop groups consecutive `parallel` calls into a bounded rolling pool and treats each `exclusive` call as an ordering barrier. Only dispatch/body overlaps; pre/post policy, durable results, and additional context retain model order. `web_search`, `web_fetch`, filesystem `read`, and `subagent` declare conservative safe cases. Mutating filesystem, todo, bash, and `run_code` calls remain exclusive; Code Mode bindings remain serial. +The agent loop groups consecutive `parallel` calls into a bounded rolling pool and treats each `exclusive` call as an ordering barrier. Only dispatch/body overlaps; policy, durable results, and context retain model order. Code Mode bindings remain serial. The [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md) owns the shipped declarations and rationale. ## Model Experience diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 11b89e5114..bf0324628a 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -132,15 +132,16 @@ export interface ToolDefinition extends ToolSchema { */ timeoutMs?: number /** - * Pure, synchronous host-only classifier for overlap with sibling tool calls. - * Only `true` opts in; omission, exceptions, and invalid `defineTool` - * arguments are treated as exclusive. + * Pure synchronous classifier for overlap with sibling tool calls. Only + * `true` opts in; omission, exceptions, non-`true` returns, and invalid + * `defineTool` arguments are exclusive. This metadata is never model-visible. * - * Opted-in executions must not mutate parent-owned state, and shared state - * they touch must be concurrency-safe. See the + * Opted-in executions must not mutate parent-owned state. Shared state must + * tolerate concurrent dispatch; recorder races are permitted only when they + * commute or fail closed. See the * [parallel-tool-call RFC](../../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md) - * for the full safety contract and recorder exception. - * @param args - Parsed tool arguments. + * for the full contract. + * @param args - parsed arguments; `defineTool` validates before calling. * @returns Whether this call may join a parallel group. */ isConcurrencySafe?(args: unknown): boolean @@ -206,12 +207,8 @@ export interface ToolExecutionInput { } /** - * How a single tool call may be scheduled relative to its siblings in one - * assistant step, as decided by {@link ToolRegistry.executionMode}. `parallel` - * calls may run concurrently within a rolling pool; an `exclusive` call runs - * alone and forms an ordering barrier. Object-tagged (rather than a bare - * boolean) so a future resource-grouping dimension can extend a variant — e.g. - * `{ kind: 'exclusive', group: 'session:...' }` — without a breaking change. + * Scheduling mode for one pending call. `parallel` may overlap with siblings; + * `exclusive` runs alone and forms an ordering barrier. */ export type ToolExecutionMode = | { kind: 'parallel' } @@ -245,9 +242,8 @@ export interface ToolRunContext extends ToolExecution { } /** - * Internal result of the scheduler-owned `tools/pre-execute` stage. Exported - * only so `dsh-agent-loop` can split ordered middleware from concurrent - * dispatch without exposing named staged service methods on `ctx.tools`. + * Scheduler-only result after ordered pre-execute and guards. A `post-result` + * still receives post-execute; a `final-result` bypasses it. * @internal */ export type ScheduledToolPreparation = @@ -256,10 +252,8 @@ export type ScheduledToolPreparation = | { kind: 'final-result'; exec: ToolRunContext; result: ToolExecutionResult } /** - * Internal result of the scheduler-owned `tools/execute` stage. A normal tool - * result still needs ordered post-execute finalization; a pipeline failure - * after/beside dispatch is already final and bypasses post-execute, matching - * {@link ToolRegistry.execute}'s public one-call semantics. + * Scheduler-only dispatch result. A `post-result` still receives post-execute; + * a `final-result` already matches {@link ToolRegistry.execute} failure semantics. * @internal */ export type ScheduledToolDispatch = @@ -267,10 +261,9 @@ export type ScheduledToolDispatch = | { kind: 'final-result'; result: ToolExecutionResult } /** - * Internal scheduler view of the registry pipeline. `dsh-agent-loop` uses this - * symbol-keyed entry point to keep `tools/pre-execute` and `tools/post-execute` - * ordered while overlapping only `tools/execute` dispatch/body. Ordinary - * callers use {@link ToolRegistry.execute}; this symbol is not a plugin seam. + * Symbol-keyed scheduler view that keeps pre/post policy ordered while + * overlapping dispatch. Ordinary callers use {@link ToolRegistry.execute}; + * this is not a plugin seam. * @internal */ export interface ToolRegistryScheduler { @@ -285,9 +278,7 @@ export interface ToolRegistryScheduler { } /** - * Symbol-keyed internal scheduler entry point on {@link ToolRegistry}. The - * generated service catalog deliberately skips computed members, so this does - * not create a named public staged API. + * Scheduler entry point omitted from the generated named service API. * @internal */ export const TOOL_REGISTRY_SCHEDULER: unique symbol = Symbol('@deepseek-ai/dsh-tools.scheduler') @@ -762,14 +753,11 @@ export class ToolRegistry extends Service { } /** - * Classify how one pending call may be scheduled relative to its siblings in - * the same assistant step. Looks up the tool through the caller's visible - * scoped view and calls its `isConcurrencySafe(exec.arguments)` classifier. - * Only an explicit `true` yields `{ kind: 'parallel' }`; unknown, - * restricted-away, undeclared, falsey, or throwing checks fail closed to - * `{ kind: 'exclusive' }`. - * @param exec - the call to classify (name, parsed arguments, optional agent scope). - * @returns the conservative scheduling mode for this call. + * Classify a pending call through the caller's visible tool definition. Only + * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or + * throwing classifiers are exclusive. + * @param exec - call name, parsed arguments, and optional agent scope. + * @returns the fail-closed scheduling mode. */ executionMode(exec: ToolExecutionInput): ToolExecutionMode { const tool = this.get(exec.name, exec.agent) @@ -787,7 +775,6 @@ export class ToolRegistry extends Service { * notification. Tool and listener failures resolve as materialized error * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is * the same lossless, frozen snapshot final observers receive. - * Scheduler staging preserves these semantics when dispatches overlap. * @param exec - the typed same-process call input. The registry assigns its * correlation token before policy begins. * @returns the materialized final result. @@ -796,7 +783,6 @@ export class ToolRegistry extends Service { return this.prepareExecution(exec, prepared => this.completeScheduledExecution(prepared)) } - /** Complete every remaining stage for the public one-call execution path. */ private async completeScheduledExecution(prepared: ScheduledToolPreparation): Promise { switch (prepared.kind) { case 'dispatch': { @@ -815,7 +801,6 @@ export class ToolRegistry extends Service { } } - /** Materialize caller input into the immutable identity object used by the pipeline. */ private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: ToolRunContext } { const deferredContexts: HookContext[] = [] const token = createExecutionToken() @@ -859,7 +844,6 @@ export class ToolRegistry extends Service { return this.prepareExecution(input, prepared => prepared) } - /** Run preparation and hand its outcome directly to the selected continuation. */ private async prepareExecution( input: ToolExecutionInput, next: (prepared: ScheduledToolPreparation) => T | PromiseLike, @@ -894,9 +878,8 @@ export class ToolRegistry extends Service { } /** - * Run only the around-dispatch/body stage. Tool-body and unknown-tool failures - * are normalized results that still go through post-execute; waterfall or - * registry invariant failures become final results, matching `execute()`. + * Run around-dispatch and the tool body. Tool and unknown-tool failures still + * receive post-execute; pipeline failures are already final. * @param exec - the prepared execution. * @returns whether the result still needs post-execute. * @internal @@ -970,7 +953,7 @@ export class ToolRegistry extends Service { return finalResult } - /** Notify final-result observers without giving them a mutation/error channel into the outcome. */ + /** Notify observers without exposing a mutation or error channel into the outcome. */ private notifyResult(exec: ToolExecution, result: ToolExecutionResult): void { // Freeze the remaining mutable signal slot before observers receive the // shared WeakMap-keyable execution object. diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index 7f5e6f7c30..c61c819618 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -284,13 +284,11 @@ export interface DefineToolOptions { */ readonly timeoutMs?: number /** - * Optional synchronous concurrency-safety classifier (see - * {@link ToolDefinition.isConcurrencySafe}). `args` is the typed, schema- - * validated shape — zero casts. Validated SOFTLY, mirroring the presenters: - * on an arg mismatch the produced classifier returns `false` (the conservative - * exclusive default) instead of the hard {@link ToolArgsError} the execute path - * raises, since replay/scheduling may feed older-schema args. Host-only — never - * sent to the model. + * Optional pure synchronous classifier for sibling overlap. It receives typed + * arguments after soft validation; invalid input returns `false` without + * invoking it. See {@link ToolDefinition.isConcurrencySafe}. + * @param args - typed validated arguments. + * @returns whether this call may join a parallel group. */ isConcurrencySafe?(args: InferArgs): boolean /** @@ -325,7 +323,7 @@ export interface DefineToolOptions { * @param options - the tool's name, description, typed parameter schema, * execute body, and optional presenters. * @returns a registry-ready definition with strict execution validation and - * soft presenter and concurrency-classifier validation for replay compatibility. + * soft presenter and classifier validation for replay compatibility. */ export function defineTool(options: DefineToolOptions): ToolDefinition { // Object-literal execute methods don't use `this`; the reference is safe. @@ -371,10 +369,7 @@ export function defineTool(options: DefineToolOptions): return userPresentResult(args as InferArgs, result) } } - // Concurrency classification is host-only scheduler metadata (never sent to - // the model) and, like the presenters, may run against replay/scheduling args - // from an older schema — so it validates SOFTLY: an arg mismatch returns - // `false` (conservative exclusive default), never the hard ToolArgsError. + // Invalid arguments fail closed without invoking the typed classifier. if (userIsConcurrencySafe) { tool.isConcurrencySafe = (args: unknown): boolean => { if (validateArgs(options.parameters, args).length > 0) return false diff --git a/packages/core/tools/tests/execution-mode.spec.ts b/packages/core/tools/tests/execution-mode.spec.ts index ca33baa143..9a12f33a51 100644 --- a/packages/core/tools/tests/execution-mode.spec.ts +++ b/packages/core/tools/tests/execution-mode.spec.ts @@ -1,9 +1,4 @@ -/** - * Per-call concurrency classification: `ToolDefinition.isConcurrencySafe`, - * `defineTool()`'s soft-validated forwarding of it, and the registry's - * `executionMode(exec)` decision. Also proves the classifier never leaks into - * the model-facing `schemas()` projection. - */ +/** Covers fail-closed per-call classification and model-schema isolation. */ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from 'cordis' @@ -28,7 +23,7 @@ function exec(name: string, args: unknown): ToolExecutionInput { } describe('ToolRegistry.executionMode', () => { - it('returns parallel only when the registered tool declares isConcurrencySafe → true', async () => { + it('returns parallel only for an explicit true classifier', async () => { const ctx = await setup() ctx.tools.register(defineTool({ name: 'safe', @@ -58,7 +53,6 @@ describe('ToolRegistry.executionMode', () => { it('returns exclusive when the classifier returns false for these args', async () => { const ctx = await setup() - // Input-sensitive: safe to read, unsafe to write — the same tool differs by args. ctx.tools.register(defineTool({ name: 'rw', description: 'read or write', @@ -70,11 +64,8 @@ describe('ToolRegistry.executionMode', () => { expect(ctx.tools.executionMode(exec('rw', { mode: 'write' }))).toEqual({ kind: 'exclusive' }) }) - it('a defineTool classifier soft-fails to exclusive on invalid args (no ToolArgsError)', async () => { + it('classifies invalid defineTool arguments as exclusive without throwing', async () => { const ctx = await setup() - // The typed classifier would read args.mode, but the required arg is missing: - // soft validation returns false (exclusive) rather than throwing, matching the - // presenter pattern. Executing the same bad args WOULD raise ToolArgsError. ctx.tools.register(defineTool({ name: 'needs-mode', description: 'requires mode', @@ -85,9 +76,8 @@ describe('ToolRegistry.executionMode', () => { expect(ctx.tools.executionMode(exec('needs-mode', {}))).toEqual({ kind: 'exclusive' }) }) - it('a thrown classifier fails closed to exclusive (raw definition)', async () => { + it('treats a throwing raw classifier as exclusive', async () => { const ctx = await setup() - // A hand-rolled ToolDefinition (not via defineTool) whose check throws. const raw: ToolDefinition = { name: 'thrower', description: 'classifier throws', @@ -99,7 +89,7 @@ describe('ToolRegistry.executionMode', () => { expect(ctx.tools.executionMode(exec('thrower', {}))).toEqual({ kind: 'exclusive' }) }) - it('a truthy non-boolean classifier result fails closed to exclusive (raw definition)', async () => { + it('treats a truthy non-boolean raw result as exclusive', async () => { const ctx = await setup() const raw = { name: 'truthy', @@ -112,7 +102,7 @@ describe('ToolRegistry.executionMode', () => { expect(ctx.tools.executionMode(exec('truthy', {}))).toEqual({ kind: 'exclusive' }) }) - it('a raw definition (no defineTool) receives the raw parsed value', async () => { + it('passes parsed arguments directly to a raw definition', async () => { const ctx = await setup() let seen: unknown ctx.tools.register({ diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index 0c129ee01c..caa6ffd582 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -27,18 +27,14 @@ export const name = 'acp-demo' * deployment persona (forwarded to the system-prompt plugin); `toolOrder` is * the explicit model-facing tool order (forwarded to the system-prompt plugin); * `tools` is the tool registry's config (its presentation `mode`, forwarded - * through agent-spine-demo); `maxParallelToolCalls` configures the bundled - * agent loop; `persistenceRoot` is the JSONL backend's directory. + * through agent-spine-demo); `persistenceRoot` is the JSONL backend's directory. */ export interface Config { /** Provider route for ACP-created agents. */ provider: string /** Model name for ACP-created agents (must have a registered adapter). */ model: string - /** - * Concurrent parallel-safe tool-call cap for the bundled agent loop. A - * positive integer; the loop defaults it when omitted and `1` is serial. - */ + /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ maxParallelToolCalls?: number /** Deployment persona (the system-prompt plugin's `persona` config). */ persona?: string @@ -66,8 +62,6 @@ export interface Config { export const Config: z = z.object({ provider: z.string().required(), model: z.string().required(), - // A positive integer; a bad value (0, negative, fractional) fails config - // validation here rather than being silently dropped from cordis.yml. maxParallelToolCalls: z.number().step(1).min(1), persona: z.string(), // The array default is forced to undefined: ABSENT means "lexicographic diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index de85b91d16..a4965ca457 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -57,7 +57,7 @@ export interface SkillConfig { export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ agents?: AgentLoopConfig['agents'] - /** Shared concurrent tool-call cap (see dsh-agent-loop's `Config`). */ + /** Agent-loop concurrency cap; `1` is serial. */ maxParallelToolCalls?: AgentLoopConfig['maxParallelToolCalls'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] diff --git a/packages/examples/stdio-demo/src/index.ts b/packages/examples/stdio-demo/src/index.ts index f6373a3b47..91c377e9f7 100644 --- a/packages/examples/stdio-demo/src/index.ts +++ b/packages/examples/stdio-demo/src/index.ts @@ -39,10 +39,7 @@ export interface Config { provider: string /** Model name for the `main` agent (must have a registered adapter). */ model: string - /** - * Concurrent parallel-safe tool-call cap for the bundled agent loop. A - * positive integer; the loop defaults it when omitted and `1` is serial. - */ + /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ maxParallelToolCalls?: number /** Deployment persona (the system-prompt plugin's `persona` config). */ persona?: string @@ -75,8 +72,6 @@ export interface Config { export const Config: z = z.object({ provider: z.string().required(), model: z.string().required(), - // A positive integer; a bad value (0, negative, fractional) fails config - // validation here rather than being silently dropped from cordis.yml. maxParallelToolCalls: z.number().step(1).min(1), persona: z.string(), // The array default is forced to undefined: ABSENT means "lexicographic diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index f729421236..2f116a2b71 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -46,7 +46,7 @@ The tool passes `exec` (the tool-execution context) as the opaque `actor` on eve `fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event. -This is why `read` opts into concurrent scheduling while `write` and `edit` remain exclusive. Concurrent reads may race only in the synchronous version recorder; a later write or edit re-checks that version under its per-target lock, so stale state produces `FS_STALE_VERSION` rather than an unsafe mutation. See the [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md). +`read` opts into concurrent scheduling because its only mutation is the synchronous version recorder. Recorder races fail closed when a later `write` or `edit` re-checks the version under its target lock; both mutation tools remain exclusive. See the [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md). The package root exports only the Cordis plugin contract (`name`, `inject`, `Config`, and `apply`). Read rendering (line windowing + output formatting) lives in `src/read-render.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them. diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index 9110ea46a2..a19b073514 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -84,12 +84,7 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' }, limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${caps.limit}.` }, }, - // Read-only. Its one side effect is the synchronous `fs/observed` version - // recorder (a WeakMap write; see below and the fs-policy plugin): concurrent - // same-target reads race last-writer-wins on that record, which is safe because - // it is NOT the safety boundary — write/edit stay exclusive barriers and - // re-check the version in-lock, so a stale observation only makes a later edit - // fail closed with FS_STALE_VERSION. + // Observation races fail closed because guarded mutations re-check the version in-lock. isConcurrencySafe: () => true, async execute(args, exec): Promise { const input = parseReadArgs(args, caps.limit) diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index 6412ed9083..6a9e6f3568 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -398,9 +398,7 @@ describe('signal, concurrency, and the fs/observed contract', () => { expect(secondInfo.version).not.toBe(firstInfo.version) expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false) - // Simulate an older concurrent read finishing last and overwriting the - // observed-state WeakMap with the stale version it saw before the external - // file change. The provider's in-lock CAS is still the safety boundary. + // Reproduce an older concurrent read winning the observation race. ctx.emit('fs/observed', target, firstInfo.version, { agent: { session } }) const edit = await callOwned('edit', { diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 637ddeded2..825e252923 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -26,7 +26,7 @@ With `run_in_background: true`, the tool registers the parent-owned task before ## Concurrency -Foreground and background calls are exclusive. Children may share the parent's workspace or external resources, and the unary scheduler classifier cannot prove that sibling delegations have disjoint effects. See the [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md). +Foreground and background calls are exclusive. Children may share the parent's workspace or external resources, and a unary classifier cannot prove that sibling delegations have disjoint effects. See the [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md). ## Model Experience diff --git a/packages/web/tool-web/README.md b/packages/web/tool-web/README.md index 8b80f8a4f5..ecc3d3e218 100644 --- a/packages/web/tool-web/README.md +++ b/packages/web/tool-web/README.md @@ -11,7 +11,7 @@ Each tool is registered independently; a product that wants only one disables th | `web_search` | `query` (string) | Discovery. Returns an optional answer plus source URLs. `max_results` is **not** model-facing — the tool sets the bound (the `searchMaxResults` config, default 8) and passes it to the seam. | | `web_fetch` | `url` (string) | Retrieves a specific URL. HTML bodies are rendered to markdown-ish text; text bodies pass through. A non-2xx status is reported, not an error. The tool-call timeout is deployment policy (`dsh-timeout-policy`), not a model argument. | -Both tools declare `isConcurrencySafe: () => true` — they are read-only (fetch a provider/URL, return content, mutate no parent-agent state), so the agent loop may run sibling web calls in parallel. +Both tools opt into concurrent scheduling because provider reads return content without mutating parent-agent state. ## Config diff --git a/packages/web/tool-web/src/fetch.ts b/packages/web/tool-web/src/fetch.ts index b6798aaf9d..83358251fb 100644 --- a/packages/web/tool-web/src/fetch.ts +++ b/packages/web/tool-web/src/fetch.ts @@ -92,8 +92,7 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number): void { url: { type: 'string', required: true, description: 'The HTTP(S) URL to fetch.' }, }, timeoutMs, - // Read-only: fetching a URL returns content and mutates no parent-agent - // state — safe to run concurrently with sibling calls. + // Provider reads do not mutate parent-agent state. isConcurrencySafe: () => true, async execute(args, exec): Promise { const input = parseFetchArgs(args) diff --git a/packages/web/tool-web/src/search.ts b/packages/web/tool-web/src/search.ts index 7faa1a1f30..af8753720d 100644 --- a/packages/web/tool-web/src/search.ts +++ b/packages/web/tool-web/src/search.ts @@ -109,8 +109,7 @@ export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs: query: { type: 'string', required: true, description: 'The search query.' }, }, timeoutMs, - // Read-only: a search hits the provider and returns content, mutating no - // parent-agent state — safe to run concurrently with sibling calls. + // Provider reads do not mutate parent-agent state. isConcurrencySafe: () => true, async execute(args, exec): Promise { const input = parseSearchArgs(args) diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 5ea591a992..56205abb54 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -839,14 +839,14 @@ function renderLifecycle(): string { ` Session-->>SDK: ${mermaidCode('session/event')} ${mermaidCode('assistant/chunk')}*`, ` Driver->>Hooks: ${mermaidCode('agent/step-result')} waterfall`, ` Driver->>Session: ${mermaidCode('assistant/message')}`, - ' Driver->>Tools: classify next call by executionMode', - ' loop bounded rolling pool with reclassification before replenishing', - ' opt capacity available for an unstarted call', - ` Driver->>Session: ${mermaidCode('tool/call')} pending audit`, - ' Driver->>Tools: ordered pre / pooled dispatch', + ' Driver->>Tools: classify pending call by executionMode', + ' loop barriers and bounded rolling pool, reclassify before start', + ' opt call starts', + ` Driver->>Session: ${mermaidCode('tool/call')}`, + ' Driver->>Tools: ordered pre, concurrent execute', ' Tools-->>Session: tool-owned events when applicable', ' end', - ' opt next model-order result is ready', + ' opt next model-order result ready', ' Driver->>Tools: ordered post', ` Driver->>Session: ${mermaidCode('tool/result')}`, ' end', diff --git a/website/zh-CN/api/harness/agent-loop.md b/website/zh-CN/api/harness/agent-loop.md index fc1ea16320..413cc4f7c8 100644 --- a/website/zh-CN/api/harness/agent-loop.md +++ b/website/zh-CN/api/harness/agent-loop.md @@ -6,7 +6,7 @@ Concrete ReactLoopAgent factory and driver service. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L353) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L352) ### ctx.agentLoop.create(id, options?, meta?) @@ -22,7 +22,7 @@ Create an agent on a fresh per-run session, owned by the accessing fiber. Constr **Returns** the published running agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L414) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L412) ### ctx.agentLoop.createAgent(ownerCtx, options) @@ -37,7 +37,7 @@ Create an owned agent on a caller-supplied session id. **Returns** the published handle. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L437) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L435) ### ctx.agentLoop.resume(ownerCtx, options) @@ -52,4 +52,4 @@ Resume an owned agent from the configured persistence service. **Returns** the published handle. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L469) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L467) diff --git a/website/zh-CN/api/harness/tools.md b/website/zh-CN/api/harness/tools.md index ef3e182ca4..6ed4ce024d 100644 --- a/website/zh-CN/api/harness/tools.md +++ b/website/zh-CN/api/harness/tools.md @@ -6,7 +6,7 @@ Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L447) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L438) ### ctx.tools.register(definition) @@ -20,7 +20,7 @@ Register globally or in the calling agent scope. Scoped tools shadow globals; du **Returns** the exact disposer that unregisters the tool. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L547) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L538) ### ctx.tools.restrict(filter) @@ -34,7 +34,7 @@ Restrict global tools for the calling agent scope. Empty filters, unknown names, **Returns** the exact disposer that lifts this restriction. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L587) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L578) ### ctx.tools.guard(guard) @@ -48,7 +48,7 @@ Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A **Returns** the exact disposer that unregisters the guard. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L638) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L629) ### ctx.tools.get(name, scope?) @@ -63,7 +63,7 @@ Look up a tool as one scope sees it (scoped shadows global; a restricted-away gl **Returns** the definition the scope resolves, or undefined when none is visible. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L740) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L731) ### ctx.tools.schemas(scope?) @@ -77,7 +77,7 @@ Project visible definitions onto the allowlisted model-facing schema fields, exc **Returns** one deep-cloned schema per visible tool. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L750) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L741) ### ctx.tools.executionMode(exec) @@ -85,13 +85,13 @@ Project visible definitions onto the allowlisted model-facing schema fields, exc executionMode(exec: ToolExecutionInput): ToolExecutionMode ``` -Classify how one pending call may be scheduled relative to its siblings in the same assistant step. Looks up the tool through the caller's visible scoped view and calls its `isConcurrencySafe(exec.arguments)` classifier. Only an explicit `true` yields `{ kind: 'parallel' }`; unknown, restricted-away, undeclared, falsey, or throwing checks fail closed to `{ kind: 'exclusive' }`. +Classify a pending call through the caller's visible tool definition. Only an exact `true` is parallel; unknown, hidden, undeclared, invalid, or throwing classifiers are exclusive. -- `exec` — the call to classify (name, parsed arguments, optional agent scope). +- `exec` — call name, parsed arguments, and optional agent scope. -**Returns** the conservative scheduling mode for this call. +**Returns** the fail-closed scheduling mode. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L774) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L762) ### ctx.tools.execute(exec) @@ -99,10 +99,10 @@ Classify how one pending call may be scheduled relative to its siblings in the s async execute(exec: ToolExecutionInput): Promise ``` -Execute through pre-policy, guards, around-dispatch, post-policy, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive. Scheduler staging preserves these semantics when dispatches overlap. +Execute through pre-policy, guards, around-dispatch, post-policy, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive. - `exec` — the typed same-process call input. The registry assigns its correlation token before policy begins. **Returns** the materialized final result. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L795) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L782) From 2b4641799e237f751512309b0523c39448dc3be0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:11:51 +0800 Subject: [PATCH 13/14] docs: expand session persistence event catalog --- docs/persistence-catalog.md | 306 ++++++++++++++---- .../2026-07-04-persistence-log-catalog.md | 12 +- .../tests/gen-persistence-catalog.spec.ts | 80 ++++- scripts/gen-persistence-catalog.ts | 119 ++++++- 4 files changed, 434 insertions(+), 83 deletions(-) diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 5484d0efe2..3bc15253a5 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -1,13 +1,85 @@ -# Persistence Log Event Catalog +# Session Persistence Event Catalog -Every event type that can appear in a session's durable event log: each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with the payload it carries, its surface badge, and the declaration it comes from. It complements [session.md](core-data-structures/session.md) (the `SessionEvent` envelope, surface list, and `deriveMessages()` projection), [persistence.md](core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit). +Every event type that can appear in a session's durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](core-data-structures/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit). -This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Payload blocks use a `ts persistence-catalog` fence (skipped by doc-typecheck, since a bare payload fragment is not standalone-compilable). Type names in a payload link to the page that documents them. See [the persistence-log-catalog RFC](rfc/implemented/process/2026-07-04-persistence-log-catalog.md). +This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. See [the persistence-log-catalog RFC](rfc/implemented/process/2026-07-04-persistence-log-catalog.md). -The on-disk envelope around every payload is `SessionEvent` — `type`, monotonic `seq`, epoch-ms `time`, the `data` documented here, plus `surfaceOp`/`sourceEventSeqs` on **surface** events only ([envelope](core-data-structures/session.md#sessioneventt--one-log-entry)). **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction. +The envelope declarations below compose each event's `type`, monotonic `seq`, epoch-ms `time`, `data`, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction. + +## Event envelope + +```ts persistence-catalog +/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */ +export type SessionEventType = keyof SessionEventMap + +/** + * The subset of {@link SessionEventType} values whose events produce LLM + * messages and are eligible to appear on the ordered surface. Only these + * event types may carry {@link SurfaceOp} and {@link SessionEvent.sourceEventSeqs}. + */ +export type SurfaceEventType = + | 'user/message' + | 'assistant/message' + | 'tool/result' + | 'context/message' + | 'steering/message' + +/** + * How a session event entered the ordered surface. Only valid on + * {@link SurfaceEventType} events. + * + * - `'append'`: added to the tail — normal path for user/assistant/tool/context + * messages. + * - `{ op: 'replace', start, end }`: replaces surface nodes from `start` + * (inclusive) through `end` (inclusive) with this node. Both must exist as + * surface nodes in the current surface. `start === end` replaces a single + * node. The node's {@link SessionEvent.sourceEventSeqs} must include every + * shadowed surface node. Used by compaction and possible other manipulations. + */ +export type SurfaceOp = + | 'append' + | { op: 'replace'; start: number; end: number } + +/** + * One immutable entry in the session log. + * + * A proper discriminated union over `type` (not independent `type`/`data` + * unions), so `switch (event.type)` narrows `event.data` without casts. + * + * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: + * they only exist on {@link SurfaceEventType} variants (`user/message`, + * `assistant/message`, `tool/result`, `context/message`, `steering/message`). + * Non-surface events (boundary markers, chunks, usage, errors) never carry + * surface metadata — the compiler enforces this at `Session.append()` + * call sites. + */ +export type SessionEvent = { + [K in SessionEventType]: { + type: K + /** Monotonic sequence number within the session. */ + seq: number + /** Unix epoch milliseconds. */ + time: number + data: SessionEventMap[K] + } & (K extends SurfaceEventType ? { + /** + * Seq numbers of events that are provenance sources of this event + * (e.g. the `assistant/chunk` seqs that built an `assistant/message`, + * or the surface nodes shadowed by a compaction replace node). An + * `assistant/message` may carry a present empty array for a known empty + * provider stream; omission means unrecorded provenance. + */ + sourceEventSeqs?: number[] + /** How this event entered the surface; absent for non-surface events. */ + surfaceOp?: SurfaceOp + } : object) +}[T] +``` + +Sources: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:324`](../packages/core/session/src/types.ts) ## Events @@ -15,10 +87,21 @@ The on-disk envelope around every payload is `SessionEvent` — `type`, monotoni #### `approval/asked` — log-only -An approval question was put to the answerer chain — log-only audit (like `hook/*`; NOT a surface event, carries no `surfaceOp`). `id` pairs it with the `approval/decided` that always follows; `toolName` is the tool the question is about, `callId` the exact tool call when the asker had one, `reason` the asker's human-readable explanation (e.g. a hook's permission-decision reason). - ```ts persistence-catalog -'approval/asked': { id: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string } +/** + * An approval question was put to the answerer chain — log-only audit + * (like `hook/*`; NOT a surface event, carries no `surfaceOp`). `id` pairs + * it with the `approval/decided` that always follows; `toolName` is the + * tool the question is about, `callId` the exact tool call when the asker + * had one, `reason` the asker's human-readable explanation (e.g. a hook's + * permission-decision reason). + */ +'approval/asked': { + id: ApprovalRequestId + toolName: string + callId?: CallId + reason?: string +} ``` Types: [CallId](core-data-structures/core.md) @@ -27,19 +110,31 @@ Source: [`packages/ui/user-approval/src/index.ts:45`](../packages/ui/user-approv #### `approval/decided` — log-only -The outcome of a prior `approval/asked` (same `id`) — log-only audit. Exactly one per ask, appended when the outcome is known: a decision, a cancellation, or the fail-closed `'unavailable'`. - ```ts persistence-catalog -'approval/decided': { id: ApprovalRequestId; outcome: ApprovalOutcome } +/** + * The outcome of a prior `approval/asked` (same `id`) — log-only audit. + * Exactly one per ask, appended when the outcome is known: a decision, a + * cancellation, or the fail-closed `'unavailable'`. + */ +'approval/decided': { + id: ApprovalRequestId + outcome: ApprovalOutcome +} ``` Source: [`packages/ui/user-approval/src/index.ts:56`](../packages/ui/user-approval/src/index.ts) #### `approval/policy` — log-only -The session's approval policy was switched — log-only, durable, replayable, never in the model transcript (the model learns the policy from the prompt section and the narrator's notices). The LAST such event is the session's override (effectiveApprovalPolicy); who asked for it is derivable from position (an event after the log's last `request/header` was a runtime switch by the user). - ```ts persistence-catalog +/** + * The session's approval policy was switched — log-only, durable, + * replayable, never in the model transcript (the model learns the policy + * from the prompt section and the narrator's notices). The LAST such + * event is the session's override ({@link effectiveApprovalPolicy}); + * who asked for it is derivable from position (an event after the log's + * last `request/header` was a runtime switch by the user). + */ 'approval/policy': { policy: ApprovalPolicy } ``` @@ -49,9 +144,8 @@ Source: [`packages/ui/user-approval/src/index.ts:68`](../packages/ui/user-approv #### `assistant/chunk` — log-only -Raw stream chunk — token-level replay fidelity. - ```ts persistence-catalog +/** Raw stream chunk — token-level replay fidelity. */ 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk } ``` @@ -61,9 +155,13 @@ Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/ #### `assistant/message` — surface -Assembled assistant message for one step (derived history uses this). Carries the step's `usage` when the adapter reported token accounting, so the model output and its accounting travel together (there is no separate usage record). `usage` is absent when the adapter reported none. - ```ts persistence-catalog +/** + * Assembled assistant message for one step (derived history uses this). + * Carries the step's `usage` when the adapter reported token accounting, so + * the model output and its accounting travel together (there is no separate + * usage record). `usage` is absent when the adapter reported none. + */ 'assistant/message': { turn: number; step: number; content: ContentBlock[]; provenance: AssistantProvenance; usage?: TokenUsage } ``` @@ -75,9 +173,12 @@ Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/ #### `bash/sandbox-mode` — log-only -Durable log-only sandbox-mode override; never a surface event or model message. Execution and ACP option reporting fold the latest event through effectiveSandboxMode without adding a prompt notice. - ```ts persistence-catalog +/** + * Durable log-only sandbox-mode override; never a surface event or model + * message. Execution and ACP option reporting fold the latest event through + * {@link effectiveSandboxMode} without adding a prompt notice. + */ 'bash/sandbox-mode': { mode: SandboxMode } ``` @@ -87,9 +188,8 @@ Source: [`packages/bash/bash/src/session-mode.ts:20`](../packages/bash/bash/src/ #### `compact/end` — log-only -Marks the end of a compaction — log-only, releases the lock. `error` set if summarization failed. - ```ts persistence-catalog +/** Marks the end of a compaction — log-only, releases the lock. `error` set if summarization failed. */ 'compact/end': { turn: number; error?: string } ``` @@ -97,9 +197,8 @@ Source: [`packages/compact/compact/src/types.ts:40`](../packages/compact/compact #### `compact/start` — log-only -Marks the start of a compaction — log-only, holds the lock until `compact/end`. - ```ts persistence-catalog +/** Marks the start of a compaction — log-only, holds the lock until `compact/end`. */ 'compact/start': { turn: number } ``` @@ -107,10 +206,30 @@ Source: [`packages/compact/compact/src/types.ts:15`](../packages/compact/compact #### `compact/summary` — log-only -Provenance record of a completed summarization — log-only, no surfaceOp. The summary content is in `data.summary`; the actual surface replacement is performed by a subsequent `user/message` event that shadows the compacted range. - ```ts persistence-catalog -'compact/summary': { summary: ContentBlock[]; shadowedRange: { start: number; end: number }; shadowedSeqs: number[]; shadowedTokenCount: number; provider: string; model: string; maxTokens?: number } +/** + * Provenance record of a completed summarization — log-only, no surfaceOp. + * The summary content is in `data.summary`; the actual surface replacement + * is performed by a subsequent `user/message` event that shadows the + * compacted range. + */ +'compact/summary': { + summary: ContentBlock[] + shadowedRange: { start: number; end: number } + shadowedSeqs: number[] + shadowedTokenCount: number + /** The provider route that wrote the summary. */ + provider: string + /** + * The model that wrote the summary — the summarize call's envelope, + * reported by the backend that made the call, logged so the one-shot + * request is reconstructable from log + code and "which model wrote + * this summary" has a durable answer (the reconstructability RFC). + */ + model: string + /** The generation cap the summarize call sent, when one applied. */ + maxTokens?: number +} ``` Types: [ContentBlock](core-data-structures/core.md) @@ -121,10 +240,20 @@ Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact #### `context/message` — surface -In-session context injection (file-change notices, subdir AGENTS.md, skill content, cron notifications, …). Rendered into the derived history as synthetic context — NOT a user prompt. `envelope: 'raw'` lets a caller own the complete model-facing frame; `meta` is durable JSON state omitted from the model projection. - ```ts persistence-catalog -'context/message': { content: ContentBlock[]; source: MessageSource; envelope?: ContextEnvelope; meta?: JsonValue } +/** + * In-session context injection (file-change notices, subdir AGENTS.md, + * skill content, cron notifications, …). Rendered into the derived history + * as synthetic context — NOT a user prompt. `envelope: 'raw'` lets a caller + * own the complete model-facing frame; `meta` is durable JSON state omitted + * from the model projection. + */ +'context/message': { + content: ContentBlock[] + source: MessageSource + envelope?: ContextEnvelope + meta?: JsonValue +} ``` Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) @@ -135,20 +264,44 @@ Source: [`packages/core/session/src/types.ts:212`](../packages/core/session/src/ #### `hook/invoked` — log-only -A hook command was invoked at a hook point — log-only provenance (like `compact/*`; NOT a SurfaceEventType, carries no `surfaceOp`). `dialect` is the bridge that ran it (`claude`/`codex`), `point` the hook point (`PreToolUse`, `Stop`, …), `matcher` the matcher-group pattern that selected it (absent for match-all), `handlerId` a stable id for the command (so an invoked/result pair correlates). `turn` is the open turn the invocation lives inside. - ```ts persistence-catalog -'hook/invoked': { turn: number; point: string; dialect: HookDialect; matcher?: string; handlerId: string } +/** + * A hook command was invoked at a hook point — log-only provenance (like + * `compact/*`; NOT a {@link SurfaceEventType}, carries no `surfaceOp`). + * `dialect` is the bridge that ran it (`claude`/`codex`), `point` + * the hook point (`PreToolUse`, `Stop`, …), `matcher` the matcher-group + * pattern that selected it (absent for match-all), `handlerId` a stable id + * for the command (so an invoked/result pair correlates). `turn` is the open + * turn the invocation lives inside. + */ +'hook/invoked': { + turn: number + point: string + dialect: HookDialect + matcher?: string + handlerId: string +} ``` Source: [`packages/hooks/hook-protocol/src/types.ts:19`](../packages/hooks/hook-protocol/src/types.ts) #### `hook/result` — log-only -Log-only outcome paired to `hook/invoked` by `handlerId`. Decision is the parsed permission result, `stop` for `continue:false`, or `pass`; exit code may be absent, stderr is bounded, and duration is wall-clock runtime. - ```ts persistence-catalog -'hook/result': { turn: number; point: string; handlerId: string; decision: string; exitCode?: number; stderrSummary?: string; durationMs: number } +/** + * Log-only outcome paired to `hook/invoked` by `handlerId`. Decision is the + * parsed permission result, `stop` for `continue:false`, or `pass`; exit code + * may be absent, stderr is bounded, and duration is wall-clock runtime. + */ +'hook/result': { + turn: number + point: string + handlerId: string + decision: string + exitCode?: number + stderrSummary?: string + durationMs: number +} ``` Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook-protocol/src/types.ts) @@ -157,9 +310,13 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook- #### `permission/preset` — log-only -Records the selected preset as durable, log-only user intent. The knob events follow in the same turn and control execution; this event stays out of the model transcript and lets effectivePermissionPreset preserve a selection when bundles match. - ```ts persistence-catalog +/** + * Records the selected preset as durable, log-only user intent. The knob + * events follow in the same turn and control execution; this event stays + * out of the model transcript and lets {@link effectivePermissionPreset} + * preserve a selection when bundles match. + */ 'permission/preset': { preset: string } ``` @@ -169,9 +326,11 @@ Source: [`packages/ui/permission/src/index.ts:33`](../packages/ui/permission/src #### `prompt/blocked` — log-only -Durable record of a prompt veto and its reason. It is log-only: the blocked prompt never enters the model-visible surface, including in a mixed batch. - ```ts persistence-catalog +/** + * Durable record of a prompt veto and its reason. It is log-only: the blocked + * prompt never enters the model-visible surface, including in a mixed batch. + */ 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } ``` @@ -183,9 +342,11 @@ Source: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/ #### `request/header` — log-only -Full header for the next request, appended inside its step before dispatch. It is log-only; the latest snapshot reconstructs the request header. - ```ts persistence-catalog +/** + * Full header for the next request, appended inside its step before dispatch. + * It is log-only; the latest snapshot reconstructs the request header. + */ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` @@ -195,9 +356,8 @@ Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/ #### `steering/message` — surface -Steering content injected between steps of a running turn. - ```ts persistence-catalog +/** Steering content injected between steps of a running turn. */ 'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } ``` @@ -209,9 +369,8 @@ Source: [`packages/core/session/src/types.ts:244`](../packages/core/session/src/ #### `step/end` — log-only -Closes step `step` of turn `turn`. - ```ts persistence-catalog +/** Closes step `step` of turn `turn`. */ 'step/end': { turn: number; step: number } ``` @@ -219,9 +378,8 @@ Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/ #### `step/start` — log-only -Opens step `step` of turn `turn` — one model call plus the tool executions it requested. - ```ts persistence-catalog +/** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */ 'step/start': { turn: number; step: number } ``` @@ -231,9 +389,8 @@ Source: [`packages/core/session/src/types.ts:195`](../packages/core/session/src/ #### `todo/write` — log-only -Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. - ```ts persistence-catalog +/** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */ 'todo/write': { todos: TodoItem[] } ``` @@ -245,9 +402,12 @@ Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/ #### `tool/call` — log-only -The model requested one tool invocation: `name` with the raw `arguments` JSON string exactly as the model produced it (unparsed). `callId` pairs the call with its `tool/result`. - ```ts persistence-catalog +/** + * The model requested one tool invocation: `name` with the raw `arguments` + * JSON string exactly as the model produced it (unparsed). `callId` pairs the + * call with its `tool/result`. + */ 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } ``` @@ -257,9 +417,22 @@ Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/ #### `tool/code-dispatch` — log-only -One bridged sub-dispatch from a `run_code` program: the parent `run_code` call id, the deterministic sub-call id (`:code:`), the tool `name` with its JSON-normalized `arguments` — the exact value dispatched, normalized BEFORE dispatch, so this append can never fail on payload shape — whether the sub-call errored, and a bounded `resultSummary` of its model-facing text. Before bounding, occurrences of a non-root session workspace path are normalized to `.` so host-specific absolute path lengths cannot change the summary. Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter model context; persistence and UIs get every call. Appended inside the parent `run_code`'s execution (the bridge drains its queue before returning), so the turn-enclosure invariant holds by construction. - ```ts persistence-catalog +/** + * One bridged sub-dispatch from a `run_code` program: the parent + * `run_code` call id, the deterministic sub-call id + * (`:code:`), the tool `name` with its JSON-normalized + * `arguments` — the exact value dispatched, normalized BEFORE dispatch, + * so this append can never fail on payload shape — whether the sub-call + * errored, and a bounded `resultSummary` of its model-facing text. Before + * bounding, occurrences of a non-root session workspace path are + * normalized to `.` so host-specific absolute path lengths cannot change + * the summary. + * Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter + * model context; persistence and UIs get every call. Appended inside the + * parent `run_code`'s execution (the bridge drains its queue before + * returning), so the turn-enclosure invariant holds by construction. + */ 'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; resultSummary: string } ``` @@ -269,9 +442,16 @@ Source: [`packages/core/tools/src/code-mode.ts:34`](../packages/core/tools/src/c #### `tool/result` — surface -A completed tool call's model-facing result, plus an optional tool-private `meta` presentation payload. `meta` is opaque to the core (`unknown` — the producing tool owns its shape and reads it back in `presentResult`) but MUST be JSON-serializable: `Session.append` runtime-validates all event data with `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the durable log reproduces the identical card on replay. Absent unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here). - ```ts persistence-catalog +/** + * A completed tool call's model-facing result, plus an optional tool-private + * `meta` presentation payload. `meta` is opaque to the core (`unknown` — the + * producing tool owns its shape and reads it back in `presentResult`) but MUST + * be JSON-serializable: `Session.append` runtime-validates all event data with + * `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the + * durable log reproduces the identical card on replay. Absent unless the tool + * attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here). + */ 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown } ``` @@ -283,9 +463,12 @@ Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/ #### `turn/end` — log-only -Closes turn `turn` with the TurnEndReason that ended it. The loop fires the awaited `session/flush` checkpoint at every turn end, so the turn boundary is also the durable-commit boundary. - ```ts persistence-catalog +/** + * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop + * fires the awaited `session/flush` checkpoint at every turn end, so the turn + * boundary is also the durable-commit boundary. + */ 'turn/end': { turn: number; reason: TurnEndReason } ``` @@ -295,9 +478,13 @@ Source: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/ #### `turn/start` — log-only -Opens turn `turn`. `trigger` records what started it — a drained message batch or an idle-time injection. The turn is the durability/replay boundary: every event sits between a `turn/start` and its matching `turn/end` (the turn-enclosure invariant). - ```ts persistence-catalog +/** + * Opens turn `turn`. `trigger` records what started it — a drained message + * batch or an idle-time injection. The turn is the durability/replay + * boundary: every event sits between a `turn/start` and its matching + * `turn/end` (the turn-enclosure invariant). + */ 'turn/start': { turn: number; trigger: TurnTrigger } ``` @@ -309,9 +496,8 @@ Source: [`packages/core/session/src/types.ts:187`](../packages/core/session/src/ #### `user/message` — surface -A user-visible prompt (queued message drained at turn start). - ```ts persistence-catalog +/** A user-visible prompt (queued message drained at turn start). */ 'user/message': { content: ContentBlock[]; source: MessageSource } ``` diff --git a/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md index 0de6255322..70a6d60c1a 100644 --- a/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md +++ b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md @@ -4,19 +4,19 @@ Status: implemented ## Problem -`SessionEventMap` is the on-disk vocabulary, but its declarations are split across the owning session package and declaration merges. The generated persistence catalog is the single reference for every event and payload; hand-maintained tables drift and are removed. These records are not Cordis events—observers receive them through the single `session/event` bus event—so the Cordis catalog cannot cover them. The generator discovers all declarations and the doc-sync freshness gate rejects omissions or stale output. +`SessionEventMap` is the on-disk vocabulary, but its declarations are split across the owning session package and declaration merges. The generated persistence catalog is the single reference for every event, its complete payload declaration and source JSDoc, and the shared `SessionEvent` envelope; hand-maintained tables drift and are removed. These records are not Cordis events—observers receive them through the single `session/event` bus event—so the Cordis catalog cannot cover them. The generator discovers all declarations and the doc-sync freshness gate rejects omissions or stale output. ## Decision Generate `docs/persistence-catalog.md` from source, with a freshness gate, as the fourth reference surface: the *records* a persisted session log can contain, complementing the cordis catalog (wiring), core-data-structures (vocabulary), and the tool catalog (tools). -`gen-persistence-catalog.ts` scans every owning and declaration-merged `SessionEventMap` with the TypeScript AST. It renders source JSDoc, payload type, derived surface badge, reference links, and source location. The doc-sync freshness check rejects a vocabulary change whose catalog was not regenerated. +`gen-persistence-catalog.ts` scans every owning and declaration-merged `SessionEventMap` with the TypeScript AST. It renders each member from its leading JSDoc through the complete payload type, retaining nested property comments and removing only its containing indentation, and also pastes the owning `SessionEventType`, `SurfaceEventType`, `SurfaceOp`, and `SessionEvent` declarations that compose the persisted envelope. Derived surface badges, reference links, and source locations remain outside the declaration blocks. The doc-sync freshness check rejects a vocabulary or envelope change whose catalog was not regenerated. Specific choices: -- **JSDoc completeness, enforced.** Every member must carry description prose — the JSDoc becomes the catalog entry, the same forcing function the cordis catalog applies to bus events. An `@mode` tag on a member is a hard error: dispatch modes belong to cordis bus events, and a log event has none — the tag would misread as "this fires on the bus with mode X". Violations aggregate into one error listing every offender. +- **JSDoc completeness, enforced.** Every member and rendered envelope type must carry description prose, and the full source JSDoc stays attached to its declaration in the catalog. An `@mode` tag is a hard error: dispatch modes belong to cordis bus events, and persisted records have none. Violations aggregate into one error listing every offender. - **The surface badge is derived, not hand-listed.** `SurfaceEventType` — the subset that produces LLM messages and may carry `surfaceOp` — is parsed from its union declaration in the owning package; a union member naming no declared event is a hard error (a stale union member would otherwise silently badge nothing). Everything else renders **log-only**. -- **A dedicated fence.** Payload blocks use a ` ```ts persistence-catalog ` info string that `doc-typecheck` recognizes and skips, excluded from the opt-out ratio — the same treatment as `ts cordis-catalog` (a bare payload fragment is not standalone-compilable). +- **A dedicated fence.** Declaration blocks use a ` ```ts persistence-catalog ` info string that `doc-typecheck` recognizes and skips, excluded from the opt-out ratio — the same treatment as `ts cordis-catalog` (the declarations reference types from their owning modules and are not standalone-compilable). - **Repo scope.** The catalog enumerates the packages in this repo, matching the siblings' packages-only scope; a downstream plugin can merge further event types, which are outside the catalog by construction. The walk defends its own assumptions with hard errors: the owning top-level `interface SessionEventMap` must be the single exported declaration in `@deepseek-ai/dsh-session` (an unrelated, local, or duplicate same-named interface cannot be catalogued as the on-disk vocabulary), no declaration may carry `extends` (inherited keys would join `keyof SessionEventMap` without a catalog row), every member must be a property signature with an explicit payload type (a method-form member would join `keyof` yet slip past a silent walk), and a duplicate member across declarations fails. This supersedes the hand-copies: the session.md `hook/*` table, the compact README's event table, the hook-protocol README's payload bullets, and the session README's name-list now link the catalog instead of restating payloads (the surrounding semantics prose stays where it was). The two stray `@mode emit` tags on the hook-protocol merge members are removed — the new gate rejects them as the category error they were. @@ -28,7 +28,7 @@ This supersedes the hand-copies: the session.md `hook/*` table, the compact READ ## Consequences -- The catalog cannot drift: a vocabulary change the committed file doesn't reflect fails `verify-persistence-catalog` in the pre-push hook and CI, and a new merged event with no JSDoc fails the generator outright — a plugin can no longer add an undocumented on-disk record type. -- Event prose has a single home, the JSDoc at the declaration; thin JSDoc yields a thin catalog entry, pressuring authors to document at the source. +- The catalog cannot drift: a vocabulary or envelope change the committed file doesn't reflect fails `verify-persistence-catalog` in the pre-push hook and CI, and a new merged event with no JSDoc fails the generator outright — a plugin can no longer add an undocumented on-disk record type. +- Event prose has a single home, the JSDoc at the declaration; the catalog preserves that JSDoc and any nested field comments without flattening or paraphrasing them. - The `SurfaceEventType` union is now structurally load-bearing for docs: renaming an event without updating the union (or vice versa) fails the generator, not just the compiler. - The badge derivation assumes the union stays a closed set of string literals with exactly one owner; a refactor away from that shape must update the generator in the same change. diff --git a/packages/core/session/tests/gen-persistence-catalog.spec.ts b/packages/core/session/tests/gen-persistence-catalog.spec.ts index cca9cbfa61..9f18e75bbe 100644 --- a/packages/core/session/tests/gen-persistence-catalog.spec.ts +++ b/packages/core/session/tests/gen-persistence-catalog.spec.ts @@ -9,6 +9,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { annotateSurface, + collectEventEnvelopeTypes, collectLogEvents, collectSurfaceEventTypes, render, @@ -56,6 +57,7 @@ describe('gen-persistence-catalog collectLogEvents', () => { scope: 'fix', doc: 'A thing was recorded.', payload: '{ turn: number }', + declaration: '/** A thing was recorded. */\n\'fix/happened\': { turn: number }', source: 'packages/core/fix/src/types.ts:3', }) }) @@ -102,10 +104,13 @@ describe('gen-persistence-catalog collectLogEvents', () => { it('collapses a newline-separated multi-line payload to a valid one-line fragment', () => { const events = collectLogEvents(make({ 'packages/group/fix/src/types.ts': merge( - ' /** Wide payload. */\n \'fix/wide\': {\n alpha: string[]\n range: { start: number; end: number }\n count: number\n }', + ' /** Wide payload. */\n \'fix/wide\': {\n /** Alpha values. */\n alpha: string[]\n range: { start: number; end: number }\n count: number\n }', ), })) expect(events[0]?.payload).toBe('{ alpha: string[]; range: { start: number; end: number }; count: number }') + expect(events[0]?.declaration).toBe( + '/** Wide payload. */\n\'fix/wide\': {\n /** Alpha values. */\n alpha: string[]\n range: { start: number; end: number }\n count: number\n}', + ) }) it('hard-errors on a member with no description prose', () => { @@ -158,6 +163,59 @@ describe('gen-persistence-catalog collectLogEvents', () => { }) }) +describe('gen-persistence-catalog collectEventEnvelopeTypes', () => { + const declarations = `/** Event keys. */ +export type SessionEventType = keyof SessionEventMap +/** Surface-producing event keys. */ +export type SurfaceEventType = 'fix/message' +/** Surface placement. */ +export type SurfaceOp = 'append' +/** One persisted event. */ +export type SessionEvent = { type: T } +` + + it('extracts the envelope declarations with their complete JSDoc in canonical order', () => { + const entries = collectEventEnvelopeTypes(make({ + 'packages/core/fix/package.json': OWNER_MANIFEST, + 'packages/core/fix/src/types.ts': declarations, + })) + expect(entries.map(entry => entry.name)).toEqual([ + 'SessionEventType', + 'SurfaceEventType', + 'SurfaceOp', + 'SessionEvent', + ]) + expect(entries[3]).toMatchObject({ + declaration: '/** One persisted event. */\nexport type SessionEvent = { type: T }', + source: 'packages/core/fix/src/types.ts:8', + }) + }) + + it('hard-errors when an envelope declaration is missing', () => { + expect(() => collectEventEnvelopeTypes(make({ + 'packages/core/fix/package.json': OWNER_MANIFEST, + 'packages/core/fix/src/types.ts': declarations.replace('/** Surface placement. */\nexport type SurfaceOp = \'append\'\n', ''), + }))).toThrow(/missing event-envelope declaration\(s\): SurfaceOp/) + }) + + it('hard-errors on duplicate, unexported, undocumented, or mistagged envelope declarations', () => { + const violations = new RegExp([ + '4 JSDoc completeness violation\\(s\\)', + '[\\s\\S]*not exported', + '[\\s\\S]*@mode tag', + '[\\s\\S]*SurfaceOp.*no description prose', + '[\\s\\S]*SessionEvent.*already declared', + ].join('')) + expect(() => collectEventEnvelopeTypes(make({ + 'packages/core/fix/package.json': OWNER_MANIFEST, + 'packages/core/fix/src/types.ts': declarations + .replace('/** Event keys. */\nexport type SessionEventType', '/** Event keys.\n * @mode emit\n */\ntype SessionEventType') + .replace('/** Surface placement. */\n', '') + + '/** Duplicate event. */\nexport type SessionEvent = { type: never }\n', + }))).toThrow(violations) + }) +}) + describe('gen-persistence-catalog collectSurfaceEventTypes', () => { it('parses the literal union', () => { const types = collectSurfaceEventTypes(make({ @@ -192,9 +250,21 @@ describe('gen-persistence-catalog annotateSurface + render', () => { scope: name.split('/')[0] ?? name, payload: '{ turn: number }', doc: `Records ${name}.`, + declaration: `/** Records ${name}. */\n'${name}': { turn: number }`, source: 'packages/core/fix/src/types.ts:3', }) + const envelopeTypes = [ + 'SessionEventType', + 'SurfaceEventType', + 'SurfaceOp', + 'SessionEvent', + ].map(name => ({ + name: name as 'SessionEventType' | 'SurfaceEventType' | 'SurfaceOp' | 'SessionEvent', + declaration: `/** ${name}. */\nexport type ${name} = never`, + source: 'packages/core/fix/src/types.ts:1', + })) + it('badges union members surface and everything else log-only', () => { const annotated = annotateSurface([entry('fix/message'), entry('fix/marker')], ['fix/message']) expect(annotated.map(e => [e.name, e.surface])).toEqual([['fix/message', true], ['fix/marker', false]]) @@ -205,11 +275,13 @@ describe('gen-persistence-catalog annotateSurface + render', () => { .toThrow(/'fix\/ghost' name no declared log event/) }) - it('renders badges, payload fences, and the generated-file header', () => { - const out = render(annotateSurface([entry('fix/message'), entry('fix/marker')], ['fix/message'])) + it('renders badges, declaration fences, and the generated-file header', () => { + const out = render(annotateSurface([entry('fix/message'), entry('fix/marker')], ['fix/message']), envelopeTypes) expect(out).toContain('Generated by scripts/gen-persistence-catalog.ts') + expect(out).toContain('# Session Persistence Event Catalog') + expect(out).toContain('```ts persistence-catalog\n/** SessionEventType. */\nexport type SessionEventType = never') expect(out).toContain('#### `fix/message` — surface') expect(out).toContain('#### `fix/marker` — log-only') - expect(out).toContain('```ts persistence-catalog\n\'fix/marker\': { turn: number }\n```') + expect(out).toContain('```ts persistence-catalog\n/** Records fix/marker. */\n\'fix/marker\': { turn: number }\n```') }) }) diff --git a/scripts/gen-persistence-catalog.ts b/scripts/gen-persistence-catalog.ts index 9469081607..8762b8529e 100644 --- a/scripts/gen-persistence-catalog.ts +++ b/scripts/gen-persistence-catalog.ts @@ -1,7 +1,7 @@ /** * Generate `docs/persistence-catalog.md` from every `SessionEventMap` merge and - * the owning `SurfaceEventType` union. This is the durable-record vocabulary, - * not the live Cordis bus. Event declarations must be unique, explicitly typed, + * the owning event-envelope types. This is the durable-record vocabulary, not + * the live Cordis bus. Event declarations must be unique, explicitly typed, * documented, inheritance-free, and free of Cordis-only `@mode` tags; every * surface-union member must resolve to one. `--check` verifies the artifact. */ @@ -14,13 +14,23 @@ import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts' const root = resolve(import.meta.dirname, '..') const OUT = 'docs/persistence-catalog.md' -/** The fenced-block info string for generated payload blocks (skipped by - * doc-typecheck, since a bare payload fragment is not standalone-compilable). */ +/** The fenced-block info string for generated declaration blocks (skipped by + * doc-typecheck, since their imported types are not standalone-compilable). */ const FENCE = 'ts persistence-catalog' /** The package whose module id plugin merges augment (`declare module '…'`). */ const SESSION_MODULE = '@deepseek-ai/dsh-session' +/** Event-envelope declarations rendered before the per-event vocabulary. */ +const EVENT_ENVELOPE_TYPE_NAMES = [ + 'SessionEventType', + 'SurfaceEventType', + 'SurfaceOp', + 'SessionEvent', +] as const + +type EventEnvelopeTypeName = typeof EVENT_ENVELOPE_TYPE_NAMES[number] + /** Primary core-data-structures page for linked payload types. */ const LINK_MAP: Record = { CallId: 'core.md', @@ -41,6 +51,8 @@ export interface LogEventEntry { scope: string /** Payload type text (the member's type annotation, whitespace-collapsed). */ payload: string + /** Source member declaration and complete JSDoc, dedented from its container. */ + declaration: string /** Description prose (the member's JSDoc), one line per paragraph. */ doc: string /** Source pointer `packages/…/file.ts:line` of the declaration. */ @@ -53,6 +65,16 @@ export interface AnnotatedLogEventEntry extends LogEventEntry { surface: boolean } +/** One owning event-envelope declaration pasted into the generated catalog. */ +export interface EventEnvelopeTypeEntry { + /** Exported declaration name. */ + name: EventEnvelopeTypeName + /** Verbatim type declaration, including its complete leading JSDoc. */ + declaration: string + /** Source pointer `packages/…/file.ts:line` of the declaration. */ + source: string +} + const printer = ts.createPrinter({ removeComments: true }) /** @@ -67,6 +89,24 @@ function payloadText(type: ts.TypeNode, sf: ts.SourceFile): string { .trim() } +/** + * Copy a declaration from its leading JSDoc through its closing token while + * removing only the indentation imposed by its containing interface/module. + */ +function declarationText(text: string, sf: ts.SourceFile, node: ts.Node): string { + const raw = rawJsDoc(text, node) + const nodeStart = node.getStart(sf) + const start = raw ? text.lastIndexOf(raw, nodeStart) : nodeStart + const { line } = sf.getLineAndCharacterOfPosition(start) + const lineStart = sf.getPositionOfLineAndCharacter(line, 0) + const indent = text.slice(lineStart, start) + return text.slice(lineStart, node.end) + .split('\n') + .map(lineText => lineText.startsWith(indent) ? lineText.slice(indent.length) : lineText) + .join('\n') + .trimEnd() +} + /** * Every `interface SessionEventMap` declaration in a source file: the owning * top-level declaration (in `@deepseek-ai/dsh-session`) and any declaration @@ -177,7 +217,8 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] { if (!doc) { violations.push(`${where} has no description prose. Say what the event records and what its payload means — the JSDoc becomes the catalog entry.`) } - entries.push({ name, scope: name.split('/')[0] ?? name, payload, doc, source: src }) + const declaration = declarationText(text, sf, member) + entries.push({ name, scope: name.split('/')[0] ?? name, payload, declaration, doc, source: src }) } } } @@ -185,6 +226,51 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] { return entries } +/** + * Collect the exported declarations that compose the persisted event envelope, + * preserving their source JSDoc and declaration text. + */ +export function collectEventEnvelopeTypes(scanRoot: string = root): EventEnvelopeTypeEntry[] { + const found = new Map() + const violations: string[] = [] + const wanted = new Set(EVENT_ENVELOPE_TYPE_NAMES) + for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) { + const abs = resolve(scanRoot, rel) + const text = readFileSync(abs, 'utf8') + if (!EVENT_ENVELOPE_TYPE_NAMES.some(name => text.includes(name))) continue + if (packageNameFor(rel, scanRoot) !== SESSION_MODULE) continue + const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true) + for (const stmt of sf.statements) { + if (!ts.isTypeAliasDeclaration(stmt) || !wanted.has(stmt.name.text)) continue + const name = stmt.name.text as EventEnvelopeTypeName + const src = pointer(rel, sf, stmt) + const where = `event-envelope type '${name}' (${src})` + const prior = found.get(name) + if (prior) { + violations.push(`${where} is already declared at ${prior.source}; the persisted envelope type has exactly one owner.`) + continue + } + if (!(stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)) { + violations.push(`${where} is not exported.`) + } + const { doc, hasMode } = parseJsDoc(rawJsDoc(text, stmt)) + if (hasMode) violations.push(`${where} carries an @mode tag, but a persisted type has no dispatch mode.`) + if (!doc) violations.push(`${where} has no description prose. The full JSDoc is part of the generated catalog.`) + found.set(name, { name, declaration: declarationText(text, sf, stmt), source: src }) + } + } + const missing = EVENT_ENVELOPE_TYPE_NAMES.filter(name => !found.has(name)) + if (missing.length > 0) { + violations.push(`missing event-envelope declaration(s): ${missing.join(', ')}.`) + } + reportViolations('gen-persistence-catalog', violations) + return EVENT_ENVELOPE_TYPE_NAMES.map((name) => { + const entry = found.get(name) + if (!entry) throw new Error(`gen-persistence-catalog: missing checked event-envelope declaration '${name}'.`) + return entry + }) +} + /** * Parse the `SurfaceEventType` union — the surface-eligible subset of event * types — from source. Hard-errors when the alias is missing, declared more @@ -246,8 +332,7 @@ function typeLinks(payload: string): string { /** Render one log event entry. */ function renderEvent(e: AnnotatedLogEventEntry): string[] { const out = [`#### \`${e.name}\` — ${e.surface ? 'surface' : 'log-only'}`, ''] - if (e.doc) out.push(e.doc, '') - out.push('```' + FENCE, `'${e.name}': ${e.payload}`, '```', '') + out.push('```' + FENCE, e.declaration, '```', '') const links = typeLinks(e.payload) if (links) out.push(links, '') out.push(`Source: [\`${e.source}\`](../${e.source.split(':')[0]})`, '') @@ -255,18 +340,26 @@ function renderEvent(e: AnnotatedLogEventEntry): string[] { } /** Render the full catalog (pure, deterministic given the collected inputs). */ -export function render(events: AnnotatedLogEventEntry[]): string { +export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnvelopeTypeEntry[]): string { const lines: string[] = [ '', '', - '# Persistence Log Event Catalog', + '# Session Persistence Event Catalog', '', - 'Every event type that can appear in a session\'s durable event log: each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with the payload it carries, its surface badge, and the declaration it comes from. It complements [session.md](core-data-structures/session.md) (the `SessionEvent` envelope, surface list, and `deriveMessages()` projection), [persistence.md](core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).', + 'Every event type that can appear in a session\'s durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](core-data-structures/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).', '', - 'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Payload blocks use a `ts persistence-catalog` fence (skipped by doc-typecheck, since a bare payload fragment is not standalone-compilable). Type names in a payload link to the page that documents them. See [the persistence-log-catalog RFC](rfc/implemented/process/2026-07-04-persistence-log-catalog.md).', + 'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. See [the persistence-log-catalog RFC](rfc/implemented/process/2026-07-04-persistence-log-catalog.md).', '', - 'The on-disk envelope around every payload is `SessionEvent` — `type`, monotonic `seq`, epoch-ms `time`, the `data` documented here, plus `surfaceOp`/`sourceEventSeqs` on **surface** events only ([envelope](core-data-structures/session.md#sessioneventt--one-log-entry)). **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.', + 'The envelope declarations below compose each event\'s `type`, monotonic `seq`, epoch-ms `time`, `data`, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.', + '', + '## Event envelope', + '', + '```' + FENCE, + envelopeTypes.map(entry => entry.declaration).join('\n\n'), + '```', + '', + `Sources: ${envelopeTypes.map(entry => `[\`${entry.source}\`](../${entry.source.split(':')[0]})`).join(' · ')}`, '', '## Events', '', @@ -285,7 +378,7 @@ export function render(events: AnnotatedLogEventEntry[]): string { * is stale. Guarded behind an entry-point check so importing this module for * tests neither regenerates the committed file nor calls process.exit. */ function main(): void { - const content = render(annotateSurface(collectLogEvents(), collectSurfaceEventTypes())) + const content = render(annotateSurface(collectLogEvents(), collectSurfaceEventTypes()), collectEventEnvelopeTypes()) if (process.argv.includes('--check')) { let committed: string | null = null try { From 78f68dbbb92cb3b361b85f0b819caf021c0c647b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:35:58 +0800 Subject: [PATCH 14/14] docs(agent-loop): track scheduler drain follow-up --- packages/core/agent-loop/src/tool-calls.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/agent-loop/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts index a8b7186223..3f3581c70a 100644 --- a/packages/core/agent-loop/src/tool-calls.ts +++ b/packages/core/agent-loop/src/tool-calls.ts @@ -176,6 +176,8 @@ async function runGroup( } // Ordered pre-execute may await; only dispatch/body overlaps. + // TODO: Drain every started call before rethrowing a scheduler error; tool + // bodies must not outlive the failed turn. await fillPool() while (inFlight.size > 0) { const settledIndex = await Promise.race(inFlight.values())