From 7ea1bf119f76455bdb084a380a9c5738877244a8 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 13 Jul 2026 11:02:21 +0800 Subject: [PATCH 01/33] 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/33] 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/33] 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/33] 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 e481288a3a5fdf05f4bcc491dc120820975710d3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:04:11 +0800 Subject: [PATCH 05/33] refactor: derive snapshot session fixtures from disk --- .../2026-07-08-shared-acp-snapshot-package.md | 2 +- .../2026-06-20-discover-package-inventory.md | 5 +- examples/acp-agent/tests/acp.snapshot.ts | 10 +-- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/src/suite.ts | 68 +++++++++++-------- .../support/acp-snapshot/tests/suite.spec.ts | 44 +++++++++--- 6 files changed, 85 insertions(+), 46 deletions(-) diff --git a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md index c622a7f1be..eca3661b33 100644 --- a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md +++ b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md @@ -16,7 +16,7 @@ The machinery lives in [`packages/support/acp-snapshot`](../../../../packages/su **`src/normalize.ts`** — the pure normalizers, hook-free by policy: when a future event carries a new volatile field (an approval duration, say), the shared normalizer learns it in the same change, keeping one home for what "normalized" means rather than per-suite scrub extensions. -**`src/suite.ts`** — the `Scenario` type and `defineAcpSnapshotSuite(options)`, registering the per-scenario compares, record/refresh fixture write-back, the header pin with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL a `scrubSystemPrompts` fixed point, non-pinning fixtures also `scrubRequestHeaders` fixed points). The pinned-header contract ([pinned-header RFC](2026-07-06-pin-request-header-content-in-one-scenario.md)) is per-suite: each header class flags exactly one `pinsHeader` scenario, whose `system-prompt.golden.md` and JSONL tool list split the composed header into reviewable artifacts; the uniformity guard compares both against every live header in that class. A pinning scenario declares any legitimate changed-header count, and its Markdown artifact records every full changed prompt. The pure helpers (`childFixturePaths`, `fixtureContext`, `normalizedHeaders`, `normalizedSystemPrompts`, `formatSystemPromptSnapshot`, `headerChangeCount`) are exported from the module for direct unit coverage. +**`src/suite.ts`** — the `Scenario` type and `defineAcpSnapshotSuite(options)`, registering the per-scenario compares, record/refresh fixture write-back, the header pin with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL a `scrubSystemPrompts` fixed point, non-pinning fixtures also `scrubRequestHeaders` fixed points). A scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are its ordered primary/child inventory, so the scenario table declares policy without duplicating a child count. The pinned-header contract ([pinned-header RFC](2026-07-06-pin-request-header-content-in-one-scenario.md)) is per-suite: each header class flags exactly one `pinsHeader` scenario, whose `system-prompt.golden.md` and JSONL tool list split the composed header into reviewable artifacts; the uniformity guard compares both against every live header in that class. A pinning scenario declares any legitimate changed-header count, and its Markdown artifact records every full changed prompt. The pure helpers (`sessionFixtureNames`, `fixtureContext`, `normalizedHeaders`, `normalizedSystemPrompts`, `formatSystemPromptSnapshot`, `headerChangeCount`) are exported from the module for direct unit coverage. ## Alternatives considered diff --git a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md index 3587393efe..a091203da9 100644 --- a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md +++ b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md @@ -4,7 +4,7 @@ Status: proposed ## Problem -Package and gate inventories are repeated by hand. The [package cookbook](../../../cookbook/adding-a-package.md) tells authors to update several files. The [package README](../../../../packages/README.md) carries a hand-written dependency graph. [CI](../../../../.github/workflows/ci.yml) and [development docs](../../../development.md) can drift from the actual `doc-sync` subcommands when new gates are added. `tsconfig.build.json` and the root `tsconfig.json` each hand-list every package as explicit project `references` — two identical sets that grow in lockstep, so a single generator can emit both — and `tsconfig.base.json`'s paths map hand-lists the per-group glob fan-out. `knip.json` restates a per-package `entry` stanza for each package that gains an `*.e2e.ts` suite — byte-identical overrides that exist only because the shared `packages/*/*` stanza omits the e2e glob (an entry glob matching no files is inert, so the default stanza could carry it for every package). The ACP snapshot suite's scenario table (`examples/acp-agent/tests/acp.snapshot.ts`) hand-maintains a `childSessions` count per scenario that duplicates the number of `session..jsonl` fixture siblings on disk. These lists are small today, but every new package or scenario class creates another manual synchronization point. +Package and gate inventories are repeated by hand. The [package cookbook](../../../cookbook/adding-a-package.md) tells authors to update several files. The [package README](../../../../packages/README.md) carries a hand-written dependency graph. [CI](../../../../.github/workflows/ci.yml) and [development docs](../../../development.md) can drift from the actual `doc-sync` subcommands when new gates are added. `tsconfig.build.json` and the root `tsconfig.json` each hand-list every package as explicit project `references` — two identical sets that grow in lockstep, so a single generator can emit both — and `tsconfig.base.json`'s paths map hand-lists the per-group glob fan-out. `knip.json` restates a per-package `entry` stanza for each package that gains an `*.e2e.ts` suite — byte-identical overrides that exist only because the shared `packages/*/*` stanza omits the e2e glob (an entry glob matching no files is inert, so the default stanza could carry it for every package). These lists are small today, but every new package creates another manual synchronization point. The [package hierarchy](../../implemented/architecture/2026-06-20-package-hierarchy.md) already removed several of these by hand: `scripts/publint-all.ts` now derives its list from the `packages//` layout, and the two `tsconfig` `paths` maps collapsed to one `@deepseek-ai/dsh-*` wildcard. What remains is the inventory that cannot be globbed away — chiefly `tsconfig.build.json`'s project `references`, which TypeScript requires as an explicit array (no wildcard form). @@ -16,7 +16,7 @@ Make the remaining package/gate inventories discoverable. A single canonical sou The hierarchy does not need to encode every fact about a package, but it should encode the broad maintenance policy: core/product packages, integrations, capability seams, and support/test/example packages should not all require a hand-maintained exception list before scripts can tell them apart. -Two of the cataloged items need no generator at all: folding the e2e entry glob into knip's default stanza deletes the per-package restatements outright, and `childSessions` can be discovered from each scenario's fixture directory, leaving the scenario table to declare only policy (`recorded`, `hasModelTurn`, `comparesLog`) — and even those track fixture-derivable facts today (`comparesLog` ⟺ the committed log has entries beyond its header line; `recorded` ⟺ `hasModelTurn` with no `replay.override.json` sibling), so each new scenario class keeps adding knobs the fixture directory already answers. +One cataloged item needs no generator at all: folding the e2e entry glob into knip's default stanza deletes the per-package restatements outright. ## Acceptance criteria @@ -25,7 +25,6 @@ Two of the cataloged items need no generator at all: folding the e2e entry glob - Docs describe the source of truth rather than repeating generated inventories. - CI invokes the aggregate commands and lets those commands own their sub-gate lists. - `knip.json` carries a per-package override only where it encodes real information (an extra entry file, an ignored dependency), never a restatement of the default stanza. -- Snapshot scenarios declare policy, not facts discoverable from their fixture directories. ## Risks diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index eaa4e0381a..b1a43b83db 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -67,14 +67,14 @@ const SCENARIOS: Scenario[] = [ // tiers (gentle at 3, detailed at 5) as context/message in transcript and log. { name: 'repeat-tool-guard', hasModelTurn: true, recorded: false }, { name: 'cancel', hasModelTurn: true, recorded: false, overridden: true }, - { name: 'subagent-spawn', hasModelTurn: true, recorded: true, childSessions: 1 }, - { name: 'subagent-multi', hasModelTurn: true, recorded: true, childSessions: 2 }, - { name: 'subagent-fork', hasModelTurn: true, recorded: true, childSessions: 1 }, - { name: 'subagent-mixed', hasModelTurn: true, recorded: true, childSessions: 2 }, + { name: 'subagent-spawn', hasModelTurn: true, recorded: true }, + { name: 'subagent-multi', hasModelTurn: true, recorded: true }, + { name: 'subagent-fork', hasModelTurn: true, recorded: true }, + { name: 'subagent-mixed', hasModelTurn: true, recorded: true }, // The workflow tool: the model writes a one-child orchestration script; the // child runs as a spawn subagent under the worker-thread engine (its session is the // child fixture), and the tool result carries the script's return value. - { name: 'workflow-run', hasModelTurn: true, recorded: true, childSessions: 1 }, + { name: 'workflow-run', hasModelTurn: true, recorded: true }, // Hook matrix — one scenario per hook point × its headline Decision outcome, // across BOTH bridges (Claude `hooks.json`, Codex `codex-hooks.json`, seeded in // workspace/). The block scenarios need no model call: a UserPromptSubmit hook diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 13dd1ab247..ac9c0ceee3 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -6,7 +6,7 @@ Three layers, importable separately: - **`runScenario` (harness)** — boots the real agent bin as a subprocess via tsx (unbuilt, Loader path), drives it over ACP JSON-RPC stdio from a deterministic `input.json` script, tees raw stdout for the golden + purity check, and harvests every persisted session JSONL (parent + subagent children, primary-first) after a graceful stdin-EOF shutdown. Parameterized by `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath` — absolute paths; the subprocess cwd is a temp dir outside the repo). - **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept), `scrubSystemPrompts` (prompt text → `{{system}}` in every JSONL), and `scrubRequestHeaders` (the remaining header bulk → `{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header RFC](../../../docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario golden + re-persisted-log compares, record/refresh fixture write-back, the per-header-class pin (`system-prompt.golden.md` plus the JSONL's full tool schemas) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt-scrubbed, non-pinning fixtures fully header-scrubbed). Must be called at vitest collection time. +- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario golden + re-persisted-log compares, record/refresh fixture write-back, the per-header-class pin (`system-prompt.golden.md` plus the JSONL's full tool schemas) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt-scrubbed, non-pinning fixtures fully header-scrubbed). Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. A consuming `*.snapshot.ts` is the scenario table plus one factory call: diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 523092bf09..6311a15031 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -80,14 +80,6 @@ export interface Scenario { * false (replay derives from the fixture's `assistant/chunk` events). */ overridden?: boolean - /** - * How many SUBAGENT child sessions this scenario records beyond the top-level - * one (0 for a single-session scenario). Each child rides in a sibling fixture - * `session..jsonl` (1-based); replay forwards them to `dsh-llm-replay` so - * each child session replays from its own script, and record mode writes the - * harvested child logs back to those files. Defaults to 0. - */ - childSessions?: number /** * Whether THIS scenario pins its header class's model-facing request-header * content. Its actual composed prompt is maintained as a readable @@ -150,14 +142,40 @@ export interface SnapshotSuiteOptions { } /** - * The sibling child-fixture paths for a scenario (`session.1.jsonl` …). + * Validate and order a scenario directory's session-fixture filenames. * - * @param dir The scenario's snapshots directory (`/`). - * @param childSessions How many subagent child sessions the scenario records. - * @returns One path per child, 1-based, in fixture order. + * The primary fixture is always `session.jsonl`; child sessions are discovered + * from contiguous `session.1.jsonl` … filenames. The directory is the source of + * truth, so scenario tables do not duplicate a child count that can drift from + * the files. A session-like JSONL with any other suffix fails loud. + * + * @param names File names in one scenario directory. + * @returns The primary and child fixture names in replay/harvest order. */ -export function childFixturePaths(dir: string, childSessions: number): string[] { - return Array.from({ length: childSessions }, (_, i) => join(dir, `session.${i + 1}.jsonl`)) +export function sessionFixtureNames(names: readonly string[]): string[] { + if (!names.includes('session.jsonl')) throw new Error('missing session.jsonl') + const children: { name: string; index: number }[] = [] + for (const name of names) { + if (name === 'session.jsonl') continue + if (!name.startsWith('session.') || !name.endsWith('.jsonl')) continue + const match = /^session\.([1-9]\d*)\.jsonl$/.exec(name) + if (match === null) throw new Error(`invalid child session fixture name: ${name}`) + children.push({ name, index: Number(match[1]) }) + } + children.sort((a, b) => a.index - b.index) + for (const [offset, child] of children.entries()) { + const expected = offset + 1 + if (child.index !== expected) { + throw new Error(`child session fixtures must be contiguous: expected session.${expected}.jsonl, found ${child.name}`) + } + } + return ['session.jsonl', ...children.map(child => child.name)] +} + +/** Read one scenario directory's validated session-fixture inventory. */ +async function sessionFixtures(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + return sessionFixtureNames(entries.filter(entry => entry.isFile()).map(entry => entry.name)) } /** @@ -392,7 +410,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as InputScript const overrideFile = join(dir, 'replay.override.json') const workspaceDir = join(dir, 'workspace') - const childSessions = scenario.childSessions ?? 0 + const fixtureFiles = await sessionFixtures(dir) + const childFixtureFiles = fixtureFiles.slice(1) + const childSessions = childFixtureFiles.length const comparesLog = scenario.comparesLog ?? scenario.hasModelTurn const result = await runScenario(input, { agent, @@ -401,7 +421,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { ...existsSync(overrideFile) ? { overrideFile } : {}, // In REPLAY, forward the recorded child fixtures so each subagent session // replays from its own script. In RECORD they are harvested, not read. - ...!RECORDING && childSessions > 0 ? { childFiles: childFixturePaths(dir, childSessions) } : {}, + ...!RECORDING && childSessions > 0 ? { childFiles: childFixtureFiles.map(file => join(dir, file)) } : {}, ...existsSync(workspaceDir) ? { workspaceDir } : {}, // A scenario booting an overlay tree passes its own live config; the // bin's replay swap derives the sibling `*cordis.snapshot.yml` from it. @@ -432,7 +452,6 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const scrub = scenario.pinsHeader === true ? scrubSystemPrompts : scrubRequestHeaders - const fixtureFiles = ['session.jsonl', ...Array.from({ length: childSessions }, (_, i) => `session.${i + 1}.jsonl`)] const existingFixtures = REFRESHING ? await Promise.all(fixtureFiles.map(file => readFile(join(dir, file), 'utf8'))) : [] @@ -543,7 +562,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { expect(onDisk).toEqual(registered) }) - it('every registered scenario has its required fixture files', () => { + it('every registered scenario has its required fixture files', async () => { // Every scenario has an input script and an stdout golden. EVERY scenario // also needs `session.jsonl`: the suite boots `llm-replay` with that path // as the replay source for ALL scenarios (the factory passes @@ -556,7 +575,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { // `overridden` flag: required when set, forbidden when not — the harness // forwards the file purely on existence, so an unregistered stray sidecar // would silently replace the derived script. - for (const { name, overridden, childSessions, pinsHeader } of scenarios) { + for (const { name, overridden, pinsHeader } of scenarios) { const dir = join(snapshotsDir, name) expect(existsSync(join(dir, 'input.json')), `${name}/input.json`).toBe(true) expect(existsSync(join(dir, 'stdout.golden.jsonl')), `${name}/stdout.golden.jsonl`).toBe(true) @@ -565,11 +584,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { .toBe(overridden === true) expect(existsSync(join(dir, SYSTEM_PROMPT_SNAPSHOT)), `${name}/${SYSTEM_PROMPT_SNAPSHOT} presence must match \`pinsHeader\``) .toBe(pinsHeader === true) - // A nested-agent scenario ships one child fixture per recorded subagent - // session (`session.1.jsonl` …), the replay source for that child session. - for (const childFixture of childFixturePaths(dir, childSessions ?? 0)) { - expect(existsSync(childFixture), childFixture).toBe(true) - } + await expect(sessionFixtures(dir), `${name}: session fixture inventory`).resolves.toBeDefined() } }) @@ -615,10 +630,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { // all header bulk. Fixed-point checks make both storage rules fail loud. for (const scenario of scenarios) { const dir = join(snapshotsDir, scenario.name) - const files = [ - 'session.jsonl', - ...Array.from({ length: scenario.childSessions ?? 0 }, (_, i) => `session.${i + 1}.jsonl`), - ] + const files = await sessionFixtures(dir) for (const file of files) { const fixture = await readFile(join(dir, file), 'utf8') expect(scrubSystemPrompts(fixture), `${scenario.name}/${file} carries an unscrubbed system prompt`) diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 4ca9c8573d..f27f36c564 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -6,13 +6,13 @@ import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it } from 'vitest' import { defineAcpSnapshotSuite, type HarvestedLog, type Scenario } from '../src/index.ts' import { - childFixturePaths, fixtureContext, formatSystemPromptSnapshot, headerChangeCount, normalizedHeaders, normalizedSystemPrompts, refreshFixtureReplacements, + sessionFixtureNames, stabilizeRefreshLog, } from '../src/suite.ts' @@ -51,7 +51,7 @@ const RECORD_SRC = fileURLToPath(new URL('./fixtures/record-suite', import.meta. // example's code-mode scenarios). const REPLAY_SCENARIOS: Scenario[] = [ { name: 'pin-turn', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderChanges: 1, headerClass: 'main' }, - { name: 'plain-turn', hasModelTurn: true, recorded: true, childSessions: 1, headerClass: 'main', configPath: AGENT.configPath }, + { name: 'plain-turn', hasModelTurn: true, recorded: true, headerClass: 'main', configPath: AGENT.configPath }, { name: 'no-model', hasModelTurn: false, recorded: false, headerClass: 'main' }, { name: 'blocked-log', hasModelTurn: false, comparesLog: true, recorded: false, headerClass: 'main' }, { name: 'authored-error', hasModelTurn: true, recorded: false, overridden: true, headerClass: 'main' }, @@ -59,7 +59,7 @@ const REPLAY_SCENARIOS: Scenario[] = [ const RECORD_SCENARIOS: Scenario[] = [ { name: 'rec-pin', hasModelTurn: true, recorded: true, pinsHeader: true }, - { name: 'rec-child', hasModelTurn: true, recorded: true, childSessions: 1 }, + { name: 'rec-child', hasModelTurn: true, recorded: true }, // recorded:false in record mode → registered but skipped (never re-recorded). { name: 'rec-skip', hasModelTurn: true, recorded: false, overridden: true }, ] @@ -180,13 +180,41 @@ describe('defineAcpSnapshotSuite: registration contract', () => { }) }) -describe('childFixturePaths', () => { - it('yields one sibling path per child, 1-based', () => { - expect(childFixturePaths('/snap/s', 2)).toEqual(['/snap/s/session.1.jsonl', '/snap/s/session.2.jsonl']) +describe('sessionFixtureNames', () => { + it('orders the primary and contiguous child fixtures while ignoring other files', () => { + expect(sessionFixtureNames([ + 'stdout.golden.jsonl', + 'session.2.jsonl', + 'session.jsonl', + 'session.1.jsonl', + 'input.json', + ])).toEqual(['session.jsonl', 'session.1.jsonl', 'session.2.jsonl']) }) - it('yields nothing for a single-session scenario', () => { - expect(childFixturePaths('/snap/s', 0)).toEqual([]) + it('accepts a primary-only scenario', () => { + expect(sessionFixtureNames(['session.jsonl'])).toEqual(['session.jsonl']) + }) + + it('rejects a directory without the primary fixture', () => { + expect(() => sessionFixtureNames(['session.1.jsonl'])).toThrow('missing session.jsonl') + }) + + it('rejects gapped child fixtures', () => { + expect(() => sessionFixtureNames(['session.jsonl', 'session.2.jsonl'])) + .toThrow('expected session.1.jsonl, found session.2.jsonl') + }) + + it.each(['session.0.jsonl', 'session.child.jsonl', 'session.01.jsonl'])( + 'rejects invalid child fixture name %s', + (name) => { + expect(() => sessionFixtureNames(['session.jsonl', name])) + .toThrow(`invalid child session fixture name: ${name}`) + }, + ) + + it('rejects duplicate child indexes', () => { + expect(() => sessionFixtureNames(['session.jsonl', 'session.1.jsonl', 'session.1.jsonl'])) + .toThrow('expected session.2.jsonl, found session.1.jsonl') }) }) From 0e7d539bbc5b75ad224b312a11cc1e15e1ba527a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:18:00 +0800 Subject: [PATCH 06/33] refactor: share the ACP test launcher --- .../2026-07-08-shared-acp-snapshot-package.md | 10 +- examples/acp-agent/tests/acp.e2e.ts | 182 +++--------------- examples/acp-agent/tests/hooks.e2e.ts | 68 ++----- .../sandbox-acp-agent/tests/escalation.e2e.ts | 79 +++----- packages/support/README.md | 4 +- packages/support/acp-snapshot/README.md | 5 +- packages/support/acp-snapshot/package.json | 2 +- packages/support/acp-snapshot/src/harness.ts | 130 ++----------- packages/support/acp-snapshot/src/index.ts | 24 ++- packages/support/acp-snapshot/src/launcher.ts | 152 +++++++++++++++ .../acp-snapshot/tests/harness.spec.ts | 33 ++++ 11 files changed, 292 insertions(+), 397 deletions(-) create mode 100644 packages/support/acp-snapshot/src/launcher.ts diff --git a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md index eca3661b33..59734535b8 100644 --- a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md +++ b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md @@ -6,13 +6,15 @@ Status: implemented The ACP snapshot tier ([snapshot RFC](2026-06-19-acp-snapshot-tests.md)) was built from three modules living inside one example's test directory: `snapshot-harness.ts` (boot the real bin subprocess, drive it over ACP JSON-RPC, harvest the persisted logs), `snapshot-normalize.ts` (the pure golden normalizers), and the ~150-line scenario body plus fixture guards in `acp.snapshot.ts` (record/replay modes, the stdout-golden and log compares, the pinned-header uniformity guard, the orphan/required-file/single-pin meta-tests). -A second ACP example wanting snapshot coverage — the sandbox/approval composition is the immediate consumer — could only copy those modules, forking exactly the logic that must not drift: record write-back, header scrubbing, child-session harvest ordering. The spawn/client glue was already triplicated across `acp.e2e.ts`, `hooks.e2e.ts`, and the harness (`TODO(acp-test-harness)`). Location also decided test rigor: the per-file 100% coverage gate measures `packages/*/*/src` only, so none of this machinery was measured — the same gap that had moved `dsh-llm-replay` out of `examples/` into [packages/support](../../../../packages/support/README.md). And the harness's ACP client hardcoded `requestPermission → cancelled`, so an approval round-trip — the headline behavior of the sandbox composition — could not be expressed at the snapshot tier at all. +A second ACP example wanting snapshot coverage — the sandbox/approval composition is the immediate consumer — could only copy those modules, forking exactly the logic that must not drift: record write-back, header scrubbing, child-session harvest ordering. The spawn/client glue was also triplicated across `acp.e2e.ts`, `hooks.e2e.ts`, and the harness. Location decided test rigor: the per-file 100% coverage gate measures `packages/*/*/src` only, so none of this machinery was measured — the same gap that had moved `dsh-llm-replay` out of `examples/` into [packages/support](../../../../packages/support/README.md). And the harness's ACP client hardcoded `requestPermission → cancelled`, so an approval round-trip — the headline behavior of the sandbox composition — could not be expressed at the snapshot tier at all. ## Decision The machinery lives in [`packages/support/acp-snapshot`](../../../../packages/support/acp-snapshot/README.md) (`@deepseek-ai/dsh-acp-snapshot`); an example's `*.snapshot.ts` is its scenario table, its agent paths, and one factory call, over its own `snapshots/` fixtures and `cordis.snapshot.yml` overlay ([single-source replay config](2026-07-04-single-source-acp-replay-config.md)). Reading `DSH_SNAPSHOT` stays at that edge — the library takes a resolved `mode`. -**`src/harness.ts`** — `runScenario` and the input-script/result types, parameterized by an `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath`; absolute paths the consuming suite resolves from its own `import.meta.url`). The client's `session/request_permission` handler consumes an optional `InputScript.permissionAnswers` FIFO queue, each entry selecting by option **kind** (ids are agent-issued randoms a committed script cannot know; kinds are the ACP-stable vocabulary, mapped to the offered `optionId` at answer time); an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run — the agent itself is answered `cancelled`, so the scenario bug fails the harness rather than being absorbed as an agent-side denial. This is what lets an approval suite drive allow/reject round-trips deterministically from `input.json`. +**`src/launcher.ts`** — `launchAcpTestAgent` owns the common unbuilt-process boundary: absolute tsx loader resolution, `TSX_TSCONFIG_PATH`, isolated harness homes, stdio wiring, a raw-byte stdout tee, stderr and update capture, fail-closed permission fallback, update waiters, and graceful or signalled shutdown. Snapshot scenarios and ordinary e2e suites supply the same `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath`); a test that plays a user supplies only its permission handler. The ACP and hook e2e suites plus the sandbox/approval e2e suite use this launcher instead of rebuilding the SDK client boundary. + +**`src/harness.ts`** — `runScenario` and the input-script/result types layer deterministic steps, temp workspaces, snapshot environment, and persisted-log harvest over the launcher. Its `session/request_permission` handler consumes an optional `InputScript.permissionAnswers` FIFO queue, each entry selecting by option **kind** (ids are agent-issued randoms a committed script cannot know; kinds are the ACP-stable vocabulary, mapped to the offered `optionId` at answer time); an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run — the agent itself is answered `cancelled`, so the scenario bug fails the harness rather than being absorbed as an agent-side denial. This is what lets an approval suite drive allow/reject round-trips deterministically from `input.json`. **`src/normalize.ts`** — the pure normalizers, hook-free by policy: when a future event carries a new volatile field (an approval duration, say), the shared normalizer learns it in the same change, keeping one home for what "normalized" means rather than per-suite scrub extensions. @@ -29,8 +31,8 @@ The machinery lives in [`packages/support/acp-snapshot`](../../../../packages/su ## Testing -Extraction parity was proven mechanically: after the move, `pnpm run test:snapshot` matched the base commit's result with zero byte changes under `examples/acp-agent/tests/snapshots/`. The package's `src/` holds per-file 100% statements/branches/functions/lines under the gating unit run, driven through the REAL spawn path by a scripted fake ACP bin (`tests/fixtures/fake-acp-agent.ts`, behavior scripted per scenario via a `behavior.json` beside the fixture): `harness.spec.ts` covers every step op, both expect-error arms, the permission queue (selection, fallback, impossible-click), env forwarding, workspace seeding, and the harvest ordering/noise/fallback branches; `suite.spec.ts` runs the factory for real at collection time — a replay suite over committed synthetic fixtures and a record suite over a temp copy (write-back never touches the committed tree; `ACP_SNAPSHOT_SPEC_BOOTSTRAP=1` re-bootstraps it) — plus direct cases for the pure helpers. Two structurally unreachable guards carry reasoned `v8 ignore` comments. The fake bin substitutes the `session/new` cwd, not `process.cwd()`, into scripted logs, matching what the real bin's header carries (darwin realpaths `/var/folders/…` to `/private/var/folders/…`). +Extraction parity was proven mechanically: after the move, `pnpm run test:snapshot` matched the base commit's result with zero byte changes under `examples/acp-agent/tests/snapshots/`. The package's `src/` holds per-file 100% statements/branches/functions/lines under the gating unit run, driven through the real launcher by a scripted fake ACP bin (`tests/fixtures/fake-acp-agent.ts`, behavior scripted per scenario via a `behavior.json` beside the fixture): `harness.spec.ts` directly covers launcher defaults, captures, update waiting, shutdown, and environment/config variants, then covers every scenario step op, both expect-error arms, the permission queue (selection, fallback, impossible-click), workspace seeding, and the harvest ordering/noise/fallback branches; `suite.spec.ts` runs the factory for real at collection time — a replay suite over committed synthetic fixtures and a record suite over a temp copy (write-back never touches the committed tree; `ACP_SNAPSHOT_SPEC_BOOTSTRAP=1` re-bootstraps it) — plus direct cases for the pure helpers. The fake bin substitutes the `session/new` cwd, not `process.cwd()`, into scripted logs, matching what the real bin's header carries (darwin realpaths `/var/folders/…` to `/private/var/folders/…`). ## Consequences -A new example gets the whole snapshot tier from a scenario table plus fixtures — the sandbox branch merges master down and adds its own suite (own pin scenario, own overlay, fixtures via `test:snapshot:record`, approvals via `permissionAnswers`). The costs: `suite.ts` imports vitest, so the package is importable only inside a vitest run — a shape no other package has, stated in its README; each suite pins its own ~8 KB header fixture (a genuinely distinct composition deserves its own pin; an identical one would be caught by that suite's uniformity guard); and the e2e launcher duplication remains (`TODO(acp-test-harness)`) — the harness is the extraction target when that migration lands. +A new example gets the whole snapshot tier from a scenario table plus fixtures, while an ordinary ACP e2e gets the same tested process/client boundary from one launcher call. The costs: `suite.ts` imports vitest, so the package entry is importable only inside a vitest run — a shape no other package has, stated in its README; and each suite pins its own ~8 KB header fixture (a genuinely distinct composition deserves its own pin; an identical one would be caught by that suite's uniformity guard). diff --git a/examples/acp-agent/tests/acp.e2e.ts b/examples/acp-agent/tests/acp.e2e.ts index 8ff012c2c3..4b6c675208 100644 --- a/examples/acp-agent/tests/acp.e2e.ts +++ b/examples/acp-agent/tests/acp.e2e.ts @@ -1,20 +1,14 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { Readable, Writable } from 'node:stream' import { mkdtemp, rm, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { - ClientSideConnection, - ndJsonStream, - PROTOCOL_VERSION, - type Agent as AcpAgent, - type Client, - type RequestPermissionRequest, - type RequestPermissionResponse, - type SessionNotification, -} from '@agentclientprotocol/sdk' + launchAcpTestAgent, + type AgentUnderTest, + type LaunchedAcpTestAgent, +} from '@deepseek-ai/dsh-acp-snapshot' /** * End-to-end: boot examples/acp-agent as a real subprocess speaking ACP over @@ -26,127 +20,18 @@ import { * WITHOUT a key, since it only needs the server to boot and answer initialize. */ -// The dsh-acp-agent bin (the demo:acp entry) and this example's cordis.yml. The -// bin resolves its config-path arg from CWD; the subprocess runs from a temp -// workdir, so pass the example config's ABSOLUTE path. -const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -// Resolve tsx's loader to an ABSOLUTE path: the subprocess runs with cwd set to -// a temp workdir (this test launches there and uses it as the session cwd; the -// bridge no longer requires cwd === the launch dir, but a temp dir keeps the -// test hermetic), where a bare `--import tsx` would not resolve from -// node_modules. import.meta.resolve gives the worktree's tsx regardless of cwd. -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) -// Absolute path to the repo-root tsconfig. Dev/test/demo run UNBUILT: the -// `@deepseek-ai/dsh-*` workspace imports resolve through the `paths` map in the -// root tsconfig (tsx reads it), NOT through built `lib/` output. But tsx finds -// that tsconfig by searching UP from the child's cwd — and the child's cwd is a -// temp workdir OUTSIDE the repo, so the search misses and the dsh-* imports fail -// (the child dies before writing a byte). Point tsx at the repo tsconfig -// explicitly via TSX_TSCONFIG_PATH so resolution is cwd-independent. (Without -// this the suite only passed by accident when a stale built `lib/` happened to -// exist — exactly the contamination that masked the inject bug this suite now -// guards.) The repo root is four levels up from this file (examples/acp-agent/tests). -const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) - -interface Spawned { - child: ChildProcessWithoutNullStreams - client: ClientSideConnection - updates: SessionNotification['update'][] - stderr: string[] +const AGENT: AgentUnderTest = { + binScript: fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)), + configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), + tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)), } -// TODO(acp-test-harness): this subprocess/client boot glue is duplicated with -// hooks.e2e.ts and partly with dsh-acp-snapshot's harness. Migrate both e2e -// files onto that launcher before the TSX/env/permission-stub details drift. -function spawnAcpAgent(cwd: string, env: NodeJS.ProcessEnv = process.env): Spawned { - const child = spawn( - process.execPath, - ['--import', tsxLoader, binScript, configPath], - { - cwd, - env: { - ...env, - TSX_TSCONFIG_PATH: repoTsconfig, - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), - }, - stdio: ['pipe', 'pipe', 'pipe'], - }, - ) - const stderr: string[] = [] - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => stderr.push(chunk)) - - const updates: SessionNotification['update'][] = [] - const stream = ndJsonStream( - Writable.toWeb(child.stdin) as WritableStream, - Readable.toWeb(child.stdout) as ReadableStream, - ) - const makeClient = (_agent: AcpAgent): Client => ({ - sessionUpdate(params: SessionNotification): Promise { - updates.push(params.update) - return Promise.resolve() - }, - requestPermission(_params: RequestPermissionRequest): Promise { - // This example composes no ask-producing policy (no hooks), so the - // bridge never prompts here; answer cancelled (fail closed) if it ever - // does — an unexpected prompt must not grant anything. - return Promise.resolve({ outcome: { outcome: 'cancelled' } }) - }, - }) - const client = new ClientSideConnection(makeClient, stream) - return { child, client, updates, stderr } -} - -let spawned: Spawned | undefined +let spawned: LaunchedAcpTestAgent | undefined let workdir: string | undefined -function hasStdoutLine(out: string[]): boolean { - return out.join('').split('\n').some(line => line.trim().length > 0) -} - -async function waitForStdoutLine(child: ChildProcessWithoutNullStreams, out: string[], stderr: string[], timeoutMs: number): Promise { - await new Promise((resolve, reject) => { - const cleanup = () => { - clearTimeout(timeout) - child.stdout.off('data', onData) - child.off('exit', onExit) - child.off('error', onError) - } - const pass = () => { - cleanup() - resolve() - } - const fail = (reason: string) => { - cleanup() - reject(new Error(`${reason}; stderr: ${stderr.join('')}`)) - } - const onData = () => { - if (hasStdoutLine(out)) pass() - } - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - fail(`ACP child exited before emitting a stdout frame (code ${code ?? 'null'}, signal ${signal ?? 'null'})`) - } - const onError = (error: Error) => { - fail(`ACP child failed before emitting a stdout frame: ${error.message}`) - } - const timeout = setTimeout(() => { - fail(`ACP child did not emit a stdout frame within ${timeoutMs}ms`) - }, timeoutMs) - - child.stdout.on('data', onData) - child.on('exit', onExit) - child.on('error', onError) - onData() - }) -} - afterEach(async () => { - if (spawned) { - spawned.child.kill('SIGKILL') - spawned = undefined - } + await spawned?.close('SIGKILL') + spawned = undefined if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) workdir = undefined }) @@ -154,39 +39,18 @@ afterEach(async () => { describe('acp-agent over real stdio (no key required)', () => { it('emits only framed JSON-RPC on stdout', async () => { workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-')) - // Collect raw stdout bytes directly (bypass the SDK framing) to inspect. + // Inspect the launcher's raw-byte tee in addition to driving its SDK client. // A dummy key lets the deepseek adapter APPLY (it only checks the key is // present at boot, not valid — the key is used only on a real model call, // which this purity test never triggers). So this runs WITHOUT real creds. - const child = spawn(process.execPath, ['--import', tsxLoader, binScript, configPath], { + spawned = launchAcpTestAgent({ + agent: AGENT, cwd: workdir, - env: { - ...process.env, - DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot', - TSX_TSCONFIG_PATH: repoTsconfig, - DSH_HOME: join(workdir, '.dsh'), - DSH_AGENTS_HOME: join(workdir, '.agents'), - }, - stdio: ['pipe', 'pipe', 'pipe'], + env: { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' }, }) - const out: string[] = [] - const stderr: string[] = [] - child.stdout.setEncoding('utf8') - child.stderr.setEncoding('utf8') - child.stdout.on('data', (c: string) => out.push(c)) - child.stderr.on('data', (c: string) => stderr.push(c)) + await spawned.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) - // Send a single initialize request as a newline-delimited JSON-RPC frame. - const req = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} } }) - child.stdin.write(req + '\n') - - try { - await waitForStdoutLine(child, out, stderr, 15_000) - } finally { - child.kill('SIGKILL') - } - - const lines = out.join('').split('\n').filter(l => l.trim().length > 0) + const lines = spawned.rawStdout().split('\n').filter(line => line.trim().length > 0) expect(lines.length).toBeGreaterThan(0) for (const line of lines) { // Every stdout line MUST parse as JSON (a JSON-RPC frame). A non-JSON @@ -210,7 +74,11 @@ describe('acp-agent over real stdio (no key required)', () => { workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-')) // A dummy key lets the deepseek adapter boot (it only checks presence, not // validity, at apply time); no model call is made, so the key is never used. - spawned = spawnAcpAgent(workdir, { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' }) + spawned = launchAcpTestAgent({ + agent: AGENT, + cwd: workdir, + env: { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' }, + }) const { client } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) @@ -223,7 +91,7 @@ describe('acp-agent over real stdio (no key required)', () => { describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: real prompt over ACP', () => { it('runs a real turn and the agent writes the requested file (verified on disk)', async () => { workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-')) - spawned = spawnAcpAgent(workdir) + spawned = launchAcpTestAgent({ agent: AGENT, cwd: workdir }) const { client, updates } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) @@ -264,7 +132,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: real prompt over it('with the terminal_output capability, a real bash call renders as a terminal card (content + _meta + exit)', async () => { workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-')) - spawned = spawnAcpAgent(workdir) + spawned = launchAcpTestAgent({ agent: AGENT, cwd: workdir }) const { client, updates } = spawned // Advertise the Zed `_meta.terminal_output` capability so the bridge emits diff --git a/examples/acp-agent/tests/hooks.e2e.ts b/examples/acp-agent/tests/hooks.e2e.ts index bdb800186a..a553addc96 100644 --- a/examples/acp-agent/tests/hooks.e2e.ts +++ b/examples/acp-agent/tests/hooks.e2e.ts @@ -1,20 +1,14 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { Readable, Writable } from 'node:stream' import { mkdtemp, rm, writeFile, access } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { - ClientSideConnection, - ndJsonStream, - PROTOCOL_VERSION, - type Agent as AcpAgent, - type Client, - type RequestPermissionRequest, - type RequestPermissionResponse, - type SessionNotification, -} from '@agentclientprotocol/sdk' + launchAcpTestAgent, + type AgentUnderTest, + type LaunchedAcpTestAgent, +} from '@deepseek-ai/dsh-acp-snapshot' /** * With-key e2e: the Claude Code hook bridge running against the REAL acp-agent @@ -34,54 +28,18 @@ import { * only a real model deciding to call bash exercises the PreToolUse seam live. */ -const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) -const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) - -interface Spawned { - child: ChildProcessWithoutNullStreams - client: ClientSideConnection - updates: SessionNotification['update'][] - stderr: string[] +const AGENT: AgentUnderTest = { + binScript: fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)), + configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), + tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)), } -function spawnAcpAgent(cwd: string): Spawned { - const child = spawn( - process.execPath, - ['--import', tsxLoader, binScript, configPath], - { cwd, env: { ...process.env, TSX_TSCONFIG_PATH: repoTsconfig }, stdio: ['pipe', 'pipe', 'pipe'] }, - ) - const stderr: string[] = [] - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => stderr.push(chunk)) - - const updates: SessionNotification['update'][] = [] - const stream = ndJsonStream( - Writable.toWeb(child.stdin) as WritableStream, - Readable.toWeb(child.stdout) as ReadableStream, - ) - const makeClient = (_agent: AcpAgent): Client => ({ - sessionUpdate(params: SessionNotification): Promise { - updates.push(params.update) - return Promise.resolve() - }, - requestPermission(_params: RequestPermissionRequest): Promise { - return Promise.resolve({ outcome: { outcome: 'cancelled' } }) - }, - }) - const client = new ClientSideConnection(makeClient, stream) - return { child, client, updates, stderr } -} - -let spawned: Spawned | undefined +let spawned: LaunchedAcpTestAgent | undefined let workdir: string | undefined afterEach(async () => { - if (spawned) { - spawned.child.kill('SIGKILL') - spawned = undefined - } + await spawned?.close('SIGKILL') + spawned = undefined if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) workdir = undefined }) @@ -96,7 +54,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: a PreToolUse hook hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'echo "bash blocked by policy" >&2; exit 2' }] }] }, })) - spawned = spawnAcpAgent(workdir) + spawned = launchAcpTestAgent({ agent: AGENT, cwd: workdir }) const { client, updates } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) diff --git a/examples/sandbox-acp-agent/tests/escalation.e2e.ts b/examples/sandbox-acp-agent/tests/escalation.e2e.ts index 93915717a1..a7e8787e36 100644 --- a/examples/sandbox-acp-agent/tests/escalation.e2e.ts +++ b/examples/sandbox-acp-agent/tests/escalation.e2e.ts @@ -1,20 +1,18 @@ -import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { Readable, Writable } from 'node:stream' +import { spawnSync } from 'node:child_process' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' import { - ClientSideConnection, - ndJsonStream, PROTOCOL_VERSION, - type Agent as AcpAgent, - type Client, type RequestPermissionRequest, - type RequestPermissionResponse, - type SessionNotification, } from '@agentclientprotocol/sdk' +import { + launchAcpTestAgent, + type AgentUnderTest, + type LaunchedAcpTestAgent, +} from '@deepseek-ai/dsh-acp-snapshot' /** * examples/sandbox-acp-agent end to end. @@ -35,12 +33,11 @@ import { * escalation target the model picks can land the write. */ -const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) -// The subprocess runs from a temp cwd OUTSIDE the repo; point tsx at the repo -// tsconfig so the unbuilt `paths` map resolves (see examples/AGENTS.md). -const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const AGENT: AgentUnderTest = { + binScript: fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)), + configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), + tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)), +} // A usable confining runner, probed the same way the executor suites do: // bwrap on Linux, Seatbelt's sandbox-exec on macOS. Without one the strict @@ -56,44 +53,19 @@ const hasSeatbelt = process.platform === 'darwin' && spawnSync('sandbox-exec', [ }).status === 0 const hasRunner = hasBwrap || hasSeatbelt -interface Spawned { - child: ChildProcessWithoutNullStreams - client: ClientSideConnection - updates: SessionNotification['update'][] +interface Spawned extends LaunchedAcpTestAgent { permissionRequests: RequestPermissionRequest[] - stderr: string[] } /** Boot the example as an ACP subprocess; the scripted client answers every permission prompt with `answer`. */ -function spawnSandboxAcpAgent(cwd: string, answer: 'allow-once' | 'reject-once'): Spawned { - const child = spawn( - process.execPath, - ['--import', tsxLoader, binScript, configPath], - { - cwd, - // A dummy key lets the deepseek adapter boot keyless (presence-checked at - // apply, used only on a real model call); the with-key tests carry the - // real key, so the fallback is inert there. - env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot', TSX_TSCONFIG_PATH: repoTsconfig }, - stdio: ['pipe', 'pipe', 'pipe'], - }, - ) - const stderr: string[] = [] - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => stderr.push(chunk)) - - const updates: SessionNotification['update'][] = [] +function launchSandboxAcpAgent(cwd: string, answer: 'allow-once' | 'reject-once'): Spawned { const permissionRequests: RequestPermissionRequest[] = [] - const stream = ndJsonStream( - Writable.toWeb(child.stdin) as WritableStream, - Readable.toWeb(child.stdout) as ReadableStream, - ) - const makeClient = (_agent: AcpAgent): Client => ({ - sessionUpdate(params: SessionNotification): Promise { - updates.push(params.update) - return Promise.resolve() - }, - requestPermission(params: RequestPermissionRequest): Promise { + const launched = launchAcpTestAgent({ + agent: AGENT, + cwd, + // A dummy key lets the adapter boot keylessly; live tests carry the real key. + env: { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' }, + requestPermission(params) { permissionRequests.push(params) const option = params.options.find(o => o.optionId === answer) // The scripted human: pick the requested option when the prompt offers @@ -102,15 +74,14 @@ function spawnSandboxAcpAgent(cwd: string, answer: 'allow-once' | 'reject-once') return Promise.resolve({ outcome: { outcome: 'selected', optionId: option.optionId } }) }, }) - const client = new ClientSideConnection(makeClient, stream) - return { child, client, updates, permissionRequests, stderr } + return Object.assign(launched, { permissionRequests }) } let spawned: Spawned | undefined let workdir: string | undefined afterEach(async () => { - if (spawned !== undefined && spawned.child.exitCode === null) spawned.child.kill('SIGKILL') + await spawned?.close('SIGKILL') spawned = undefined if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) workdir = undefined @@ -119,7 +90,7 @@ afterEach(async () => { describe('sandbox-acp-agent keyless smoke (real cordis.yml via the Loader)', () => { it('boots the tree — sandbox executor + approval service + bridge — and opens a session', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-smoke-')) - spawned = spawnSandboxAcpAgent(workdir, 'reject-once') + spawned = launchSandboxAcpAgent(workdir, 'reject-once') const { client } = spawned // A dummy key boots the adapter; no prompt is ever sent, so no model call // and no sandbox runner probe happen. This drives the fiber tree the same @@ -132,7 +103,7 @@ describe('sandbox-acp-agent keyless smoke (real cordis.yml via the Loader)', () it('advertises both session config options and honors a switch end to end (no key, no model)', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-config-')) - spawned = spawnSandboxAcpAgent(workdir, 'reject-once') + spawned = launchSandboxAcpAgent(workdir, 'reject-once') const { client } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) // This tree composes bash-sandbox (mode: read-only) + approval → both @@ -164,7 +135,7 @@ describe('sandbox-acp-agent keyless smoke (real cordis.yml via the Loader)', () describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('sandbox-acp-agent e2e: the live approval loop', () => { it('denial → model escalation → editor prompt → allow-once → the retried write lands on disk', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-e2e-')) - spawned = spawnSandboxAcpAgent(workdir, 'allow-once') + spawned = launchSandboxAcpAgent(workdir, 'allow-once') const { client, permissionRequests } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) @@ -193,7 +164,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('sandbox-acp-agent it('a rejected escalation stays denied: no write lands, the turn still ends', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-e2e-')) - spawned = spawnSandboxAcpAgent(workdir, 'reject-once') + spawned = launchSandboxAcpAgent(workdir, 'reject-once') const { client, permissionRequests } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) diff --git a/packages/support/README.md b/packages/support/README.md index c8883a89ff..9a05ad3c9a 100644 --- a/packages/support/README.md +++ b/packages/support/README.md @@ -4,9 +4,9 @@ Packages that exist to serve development, testing, and the examples rather than | Package | Role | ctx key | |---|---|---| -| `acp-snapshot/` | ACP snapshot suite kit: subprocess scenario harness + golden normalizers + the `defineAcpSnapshotSuite` factory | (library — imported by example `*.snapshot.ts` suites) | +| `acp-snapshot/` | ACP test kit: shared subprocess/client launcher + snapshot harness, normalizers, and suite factory | (library — imported by ACP e2e and `*.snapshot.ts` suites) | | `invariants/` | Dev-mode event-contract assertions | (listens on `session/*`, `agent/*`) | | `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | | `subagent-mock/` | Scripted `SubagentProvider` for deterministic seam/tool tests | (registers on `ctx.subagents`) | -`invariants` runs only in dev mode (contract checks, not runtime behavior). `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the snapshot tier's harness/normalizer/suite machinery so every example's suite is a scenario table over one shared, gate-covered implementation. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. +`invariants` runs only in dev mode (contract checks, not runtime behavior). `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the ACP subprocess/client boundary plus the snapshot harness, normalizers, and suite machinery, so e2e tests share one launcher and every snapshot suite is a scenario table over one gate-covered implementation. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index ac9c0ceee3..4a43d0eef9 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -2,8 +2,9 @@ The ACP snapshot suite kit: the shared machinery behind the keyless snapshot tier (`pnpm run test:snapshot`, [testing policy](../../../docs/testing.md)). An example gets a full snapshot suite from a scenario table plus a fixtures directory; every compare/guard mechanic lives here, under the per-file coverage gate, instead of being copied per example. -Three layers, importable separately: +Four layers, importable separately: +- **`launchAcpTestAgent` (launcher)** — boots an unbuilt ACP agent from a temp cwd, pins tsx to the repo tsconfig, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`runScenario` (harness)** — boots the real agent bin as a subprocess via tsx (unbuilt, Loader path), drives it over ACP JSON-RPC stdio from a deterministic `input.json` script, tees raw stdout for the golden + purity check, and harvests every persisted session JSONL (parent + subagent children, primary-first) after a graceful stdin-EOF shutdown. Parameterized by `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath` — absolute paths; the subprocess cwd is a temp dir outside the repo). - **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept), `scrubSystemPrompts` (prompt text → `{{system}}` in every JSONL), and `scrubRequestHeaders` (the remaining header bulk → `{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header RFC](../../../docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). - **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario golden + re-persisted-log compares, record/refresh fixture write-back, the per-header-class pin (`system-prompt.golden.md` plus the JSONL's full tool schemas) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt-scrubbed, non-pinning fixtures fully header-scrubbed). Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. @@ -39,4 +40,4 @@ A scenario booting a differently-composed tree sets its own `configPath` (an ove The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config RFC](../../../docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log goldens, and each pin's Markdown prompt snapshot from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). -Constraints: `suite.ts` imports vitest, so the package is importable only inside a vitest run (the harness and normalizers have no such dependency but ship from the same entry). ACP-specific by design — the harness speaks the SDK's `ClientSideConnection`. Permission round-trips are scriptable: `InputScript.permissionAnswers` is a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) the client maps to the agent-issued `optionId` at answer time; an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run (the agent is answered `cancelled`, so a tolerant agent cannot absorb the scenario bug). Session config options are scriptable too: the `setConfigOption` step switches a knob over `session/set_config_option`, and `setConfigOptionExpectError` asserts the bridge rejects an unknown id or out-of-vocabulary value (the error frame stays in the transcript). +Constraints: `suite.ts` imports vitest, so the package entry is importable only inside a vitest run (the launcher, harness, and normalizers have no such dependency but ship from the same entry). ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection`. Permission round-trips are scriptable: `InputScript.permissionAnswers` is a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) the client maps to the agent-issued `optionId` at answer time; an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run (the agent is answered `cancelled`, so a tolerant agent cannot absorb the scenario bug). Session config options are scriptable too: the `setConfigOption` step switches a knob over `session/set_config_option`, and `setConfigOptionExpectError` asserts the bridge rejects an unknown id or out-of-vocabulary value (the error frame stays in the transcript). diff --git a/packages/support/acp-snapshot/package.json b/packages/support/acp-snapshot/package.json index 363bc86e25..b14be09c5b 100644 --- a/packages/support/acp-snapshot/package.json +++ b/packages/support/acp-snapshot/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-acp-snapshot", - "description": "ACP snapshot suite kit: real-subprocess scenario harness, golden normalizers, and the suite factory behind the keyless snapshot tier", + "description": "ACP test kit: shared subprocess launcher, snapshot scenario harness, golden normalizers, and suite factory", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index 491d3ea884..93bb556ba6 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -16,54 +16,20 @@ * @module @deepseek-ai/dsh-acp-snapshot/harness */ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import { cp, mkdtemp, readFile, readdir, rm } from 'node:fs/promises' import { existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, delimiter } from 'node:path' -import { fileURLToPath } from 'node:url' -import { Readable, Writable } from 'node:stream' import { ClientSideConnection, - ndJsonStream, PROTOCOL_VERSION, - type Agent as AcpAgent, - type Client, type RequestPermissionRequest, type RequestPermissionResponse, type SessionNotification, } from '@agentclientprotocol/sdk' +import { launchAcpTestAgent, type AgentUnderTest, type LaunchedAcpTestAgent } from './launcher.ts' -// Resolve tsx's ESM loader to an ABSOLUTE path once: the child runs with its -// cwd in a temp dir OUTSIDE the repo, where a bare `--import tsx` would not -// resolve from node_modules. import.meta.resolve gives this package's tsx -// regardless of the child cwd. -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) - -/** - * The agent composition a scenario runs against: which bin to boot and which - * leaf config it loads. All paths are ABSOLUTE — the subprocess cwd is a temp - * dir outside the repo, so relative resolution would miss; a suite resolves - * them from its own `import.meta.url`. - */ -export interface AgentUnderTest { - /** The agent bin entry (e.g. `packages/ui/acp-agent/src/bin.ts`), run unbuilt via tsx. */ - binScript: string - /** - * The example's live `cordis.yml`. Under `DSH_SNAPSHOT=replay` the bin swaps - * it for the sibling `cordis.snapshot.yml` (the keyless replay overlay), so - * one path serves both modes. - */ - configPath: string - /** - * The repo-root tsconfig whose `paths` map resolves the unbuilt workspace - * imports. Passed to the child as `TSX_TSCONFIG_PATH`: tsx finds a tsconfig - * by searching UP from the child's cwd — a temp dir outside the repo — so - * without the explicit pin the dsh-* imports fail before the bin writes a - * byte. - */ - tsconfigPath: string -} +export type { AgentUnderTest } from './launcher.ts' /** * One step of a scenario's deterministic input script (`input.json`). The @@ -194,11 +160,9 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise // Everything past the temp-dir creation runs under a try/finally that always // removes both dirs — so a failure in workspace seeding, spawn, or any step // never leaks them (the "e2e tests own their resources" rule). - let child: ChildProcessWithoutNullStreams | undefined + let launched: LaunchedAcpTestAgent | undefined let sessionId: string | undefined let sessionLogs: HarvestedLog[] = [] - const rawBuffers: Buffer[] = [] - const stderrChunks: string[] = [] try { // Seed the workspace if the scenario ships one (a file the agent reads/edits). // Copied into the temp cwd so the agent's bash tools see it; the goldens @@ -207,51 +171,15 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise await cp(opts.workspaceDir, cwd, { recursive: true }) } const env: NodeJS.ProcessEnv = { - ...process.env, - TSX_TSCONFIG_PATH: opts.agent.tsconfigPath, DSH_SNAPSHOT: opts.mode, DSH_SNAPSHOT_FILE: opts.fixtureFile, DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot, - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), ...opts.overrideFile !== undefined ? { DSH_SNAPSHOT_OVERRIDE: opts.overrideFile } : {}, ...opts.childFiles !== undefined && opts.childFiles.length > 0 ? { DSH_SNAPSHOT_CHILD_FILES: opts.childFiles.join(delimiter) } : {}, } - child = spawn( - process.execPath, - ['--import', tsxLoader, opts.agent.binScript, opts.configPath ?? opts.agent.configPath], - { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] }, - ) - - child.stderr.setEncoding('utf8') - child.stderr.on('data', (c: string) => stderrChunks.push(c)) - - // Tee raw stdout: accumulate the bytes for the golden + purity check, and ALSO - // feed the same bytes to the SDK client through a passthrough. Buffer the raw - // bytes (not per-chunk utf8 strings) and decode once at the end, so a - // multibyte sequence split across two 'data' events can't corrupt the golden. - const passthrough = new Readable({ read() {} }) - child.stdout.on('data', (buf: Buffer) => { - rawBuffers.push(buf) - passthrough.push(buf) - }) - child.stdout.on('end', () => passthrough.push(null)) - - const stream = ndJsonStream( - Writable.toWeb(child.stdin) as WritableStream, - Readable.toWeb(passthrough) as ReadableStream, - ) - // Watcher so a step can block until the client OBSERVES a particular - // session/update — used by promptAndCancel to pin frame order (send cancel - // only after the streamed agent_message_chunk has arrived, so those frames - // deterministically precede the cancelled prompt response). - const updateWaiters: { match: (u: SessionNotification['update']) => boolean; resolve: () => void }[] = [] - const waitForUpdate = (match: (u: SessionNotification['update']) => boolean): Promise => - new Promise(resolve => updateWaiters.push({ match, resolve })) - // Permission answers are consumed FIFO across the whole run; exhaustion // falls back to `cancelled` so approval-free scenarios keep the plain stub. const permissionQueue = [...input.permissionAnswers ?? []] @@ -263,22 +191,11 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise // callback answers `cancelled` (a well-defined path for the agent), // captures the error here, and the step loop fails the run on it. let scriptError: Error | undefined - const makeClient = (_agent: AcpAgent): Client => ({ - sessionUpdate(params: SessionNotification): Promise { - for (let i = updateWaiters.length - 1; i >= 0; i--) { - const waiter = updateWaiters[i] - // The index is always in-bounds (i only decreases; splice removes at - // i, so lower entries stay valid); the guard satisfies - // noUncheckedIndexedAccess. - /* v8 ignore next 1 -- unreachable in-bounds guard, see above */ - if (waiter === undefined) continue - if (waiter.match(params.update)) { - updateWaiters.splice(i, 1) - waiter.resolve() - } - } - return Promise.resolve() - }, + launched = launchAcpTestAgent({ + agent: opts.agent, + cwd, + ...opts.configPath !== undefined ? { configPath: opts.configPath } : {}, + env, requestPermission(params: RequestPermissionRequest): Promise { const answer = permissionQueue.shift() if (answer === undefined) return Promise.resolve({ outcome: { outcome: 'cancelled' } }) @@ -296,10 +213,11 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise return Promise.resolve({ outcome: { outcome: 'selected', optionId: option.optionId } }) }, }) - const client = new ClientSideConnection(makeClient, stream) + const active = launched + const { client } = active for (const step of input.steps) { - await runStep(client, step, cwd, waitForUpdate, () => sessionId, (id) => { sessionId = id }) + await runStep(client, step, cwd, match => active.waitForUpdate(match), () => sessionId, (id) => { sessionId = id }) // A permission exchange happens while a step's request is in flight, so // by the time the step settles any script bug it exposed is captured — // fail the run HERE, as a harness error, rather than hoping the agent's @@ -308,26 +226,22 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise } // Done driving: close stdin so the server disposes gracefully (flushing // persistence) and exits. Then await exit so the harvested log is complete. - child.stdin.end() - await waitForExit(child) + await active.close() // Harvest EVERY persisted log (parent + any subagent children) while the // temp dirs still exist, ordered primary-first. sessionLogs = await harvestSessionLogs(sessionsRoot) } finally { // Failure-safe teardown: kill a still-running child and drop the temp dirs // even if seeding/spawn/a step/harvest threw, so a flaky run never leaks a - // process or dir. `child` is undefined only if spawn itself threw. - if (child !== undefined && child.exitCode === null && child.signalCode === null) { - child.kill('SIGKILL') - await waitForExit(child) - } + // process or dir. `launched` is undefined only if launch itself threw. + await launched?.close('SIGKILL') await rm(cwd, { recursive: true, force: true }) await rm(sessionsRoot, { recursive: true, force: true }) } return { - rawStdout: Buffer.concat(rawBuffers).toString('utf8'), - stderr: stderrChunks.join(''), + rawStdout: launched.rawStdout(), + stderr: launched.stderr(), cwd, ...sessionId !== undefined ? { sessionId } : {}, sessionLogs, @@ -339,7 +253,7 @@ async function runStep( client: ClientSideConnection, step: InputStep, cwd: string, - waitForUpdate: (match: (u: SessionNotification['update']) => boolean) => Promise, + waitForUpdate: (match: (u: SessionNotification['update']) => boolean) => Promise, getSessionId: () => string | undefined, setSessionId: (id: string) => void, ): Promise { @@ -433,16 +347,6 @@ async function runStep( } } -/** Resolve once the child process exits (any code/signal). */ -function waitForExit(child: ChildProcessWithoutNullStreams): Promise { - // Race guard: both call sites run within one synchronous frame of - // stdin.end()/kill(), so the exit event cannot have been delivered yet; - // kept for any future caller that awaits in between. - /* v8 ignore next 1 -- unreachable race guard, see above */ - if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve() - return new Promise(resolve => child.once('exit', () => { resolve() })) -} - /** * Harvest EVERY persisted `.jsonl` session log under a sessions root, parse each * header line, and return them ordered primary-first: the top-level session (no diff --git a/packages/support/acp-snapshot/src/index.ts b/packages/support/acp-snapshot/src/index.ts index 74bee95385..402bd3aa3d 100644 --- a/packages/support/acp-snapshot/src/index.ts +++ b/packages/support/acp-snapshot/src/index.ts @@ -1,13 +1,14 @@ /** * ACP snapshot suite kit — the shared machinery behind the keyless snapshot - * tier (`pnpm run test:snapshot`). Three layers, composable per example: - * the subprocess scenario harness ({@link runScenario}), the pure golden - * normalizers ({@link normalizeStdout} / {@link normalizeSessionLog} / - * {@link scrubRequestHeaders} / {@link scrubSystemPrompts}), and the suite factory - * ({@link defineAcpSnapshotSuite}) that registers a scenario table as a full - * describe/it tree. An example's `*.snapshot.ts` supplies only its - * {@link AgentUnderTest} paths, its snapshots directory, and its - * {@link Scenario} table. + * tier (`pnpm run test:snapshot`). Four layers, composable per example: the + * shared subprocess/client launcher ({@link launchAcpTestAgent}), the scripted + * scenario harness ({@link runScenario}), the pure golden normalizers + * ({@link normalizeStdout} / {@link normalizeSessionLog} / + * {@link scrubRequestHeaders} / {@link scrubSystemPrompts}), and the suite + * factory ({@link defineAcpSnapshotSuite}) that registers a scenario table as a + * full describe/it tree. Ordinary ACP e2e tests can use the launcher directly; + * an example's `*.snapshot.ts` supplies only its {@link AgentUnderTest} paths, + * snapshots directory, and {@link Scenario} table. * * NOTE: ./suite.ts imports vitest, so this package is importable only inside a * vitest run — a support-tier constraint stated in the README. @@ -17,7 +18,6 @@ export { runScenario, - type AgentUnderTest, type HarvestedLog, type InputScript, type InputStep, @@ -25,6 +25,12 @@ export { type RunOptions, type RunResult, } from './harness.ts' +export { + launchAcpTestAgent, + type AcpTestLaunchOptions, + type AgentUnderTest, + type LaunchedAcpTestAgent, +} from './launcher.ts' export { normalizeSessionLog, normalizeStdout, diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts new file mode 100644 index 0000000000..635a02ca35 --- /dev/null +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -0,0 +1,152 @@ +/** + * Shared launcher for ACP tests that drive an unbuilt agent subprocess over + * JSON-RPC stdio. It owns the tsx loader, workspace-resolution environment, + * stdout tee, SDK client, update collection, permission fallback, and process + * shutdown so e2e and snapshot suites do not each reconstruct that boundary. + * + * @module @deepseek-ai/dsh-acp-snapshot/launcher + */ + +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Readable, Writable } from 'node:stream' +import { + ClientSideConnection, + ndJsonStream, + type Agent as AcpAgent, + type Client, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, +} from '@agentclientprotocol/sdk' + +// The child runs from a temp directory outside the repo, where a bare +// `--import tsx` cannot resolve. Resolve this package's loader once instead. +const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) + +/** The unbuilt agent entry, leaf config, and workspace tsconfig an ACP test boots. */ +export interface AgentUnderTest { + /** The agent bin entry (for example `packages/ui/acp-agent/src/bin.ts`). */ + binScript: string + /** The leaf `cordis.yml` loaded by the bin. */ + configPath: string + /** The repo tsconfig whose paths resolve unbuilt workspace imports. */ + tsconfigPath: string +} + +/** Options for one ACP test subprocess. */ +export interface AcpTestLaunchOptions { + /** The agent composition to boot. */ + agent: AgentUnderTest + /** Process cwd and default session-home root. */ + cwd: string + /** Alternate leaf config for this launch. */ + configPath?: string + /** Extra environment values layered over the parent environment. */ + env?: NodeJS.ProcessEnv + /** Permission handler; omitted requests fail closed as `cancelled`. */ + requestPermission?: (params: RequestPermissionRequest) => Promise +} + +/** A running ACP test process and its captured client-side surfaces. */ +export interface LaunchedAcpTestAgent { + /** The child process, exposed for process-level assertions. */ + child: ChildProcessWithoutNullStreams + /** The SDK connection backed by the child's stdio. */ + client: ClientSideConnection + /** Session updates in receive order. */ + updates: SessionNotification['update'][] + /** Decode all stdout bytes captured so far. */ + rawStdout(): string + /** Decode all stderr chunks captured so far. */ + stderr(): string + /** Resolve when a future session update matches the predicate. */ + waitForUpdate(match: (update: SessionNotification['update']) => boolean): Promise + /** Gracefully close stdin, or send a signal, and wait for process exit. */ + close(signal?: NodeJS.Signals): Promise +} + +/** + * Boot an ACP agent subprocess and connect an SDK client to its stdio. + * + * @param options Agent paths, cwd, environment, and optional permission handler. + * @returns The running process, connected client, captures, and shutdown handle. + */ +export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTestAgent { + const { agent, cwd } = options + const child = spawn( + process.execPath, + ['--import', tsxLoader, agent.binScript, options.configPath ?? agent.configPath], + { + cwd, + env: { + ...process.env, + ...options.env, + TSX_TSCONFIG_PATH: agent.tsconfigPath, + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + }, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ) + + const stderrChunks: string[] = [] + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => stderrChunks.push(chunk)) + + const rawBuffers: Buffer[] = [] + const passthrough = new Readable({ read() {} }) + child.stdout.on('data', (buffer: Buffer) => { + rawBuffers.push(buffer) + passthrough.push(buffer) + }) + child.stdout.on('end', () => passthrough.push(null)) + + const updates: SessionNotification['update'][] = [] + const updateWaiters: { + match: (update: SessionNotification['update']) => boolean + resolve: (update: SessionNotification['update']) => void + }[] = [] + const stream = ndJsonStream( + Writable.toWeb(child.stdin) as WritableStream, + Readable.toWeb(passthrough) as ReadableStream, + ) + const makeClient = (_agent: AcpAgent): Client => ({ + sessionUpdate(params: SessionNotification): Promise { + updates.push(params.update) + for (let index = updateWaiters.length - 1; index >= 0; index--) { + const waiter = updateWaiters[index] + /* v8 ignore next 1 -- index is bounded by the array length */ + if (waiter === undefined) continue + if (!waiter.match(params.update)) continue + updateWaiters.splice(index, 1) + waiter.resolve(params.update) + } + return Promise.resolve() + }, + requestPermission: options.requestPermission + ?? (() => Promise.resolve({ outcome: { outcome: 'cancelled' } })), + }) + const client = new ClientSideConnection(makeClient, stream) + + return { + child, + client, + updates, + rawStdout: () => Buffer.concat(rawBuffers).toString('utf8'), + stderr: () => stderrChunks.join(''), + waitForUpdate: match => new Promise(resolve => updateWaiters.push({ match, resolve })), + async close(signal?: NodeJS.Signals): Promise { + if (child.exitCode !== null || child.signalCode !== null) return + if (signal === undefined) child.stdin.end() + else child.kill(signal) + await waitForExit(child) + }, + } +} + +/** Resolve once a running child exits. */ +function waitForExit(child: ChildProcessWithoutNullStreams): Promise { + return new Promise(resolve => child.once('exit', () => { resolve() })) +} diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index b0817a8d04..ec1ed5cf6f 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -3,7 +3,9 @@ import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it } from 'vitest' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { runScenario, type AgentUnderTest, type InputStep } from '../src/harness.ts' +import { launchAcpTestAgent } from '../src/launcher.ts' /** * Unit tests for the subprocess harness, driven through the REAL spawn path @@ -38,6 +40,37 @@ async function scenario(behavior: object): Promise<{ dir: string; fixtureFile: s const boot: InputStep[] = [{ op: 'initialize' }, { op: 'newSession' }] describe('runScenario', () => { + it('centralizes ACP boot, captures, updates, fail-closed permissions, and shutdown', { timeout: 20_000 }, async () => { + const { dir, fixtureFile } = await scenario({ permissionProbe: true, echoEnv: true, stderrNote: 'launcher stderr' }) + const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-launcher-sessions-')) + tempDirs.push(sessionsRoot) + const launched = launchAcpTestAgent({ + agent: AGENT, + cwd: dir, + configPath: AGENT.configPath, + env: { + DSH_SNAPSHOT: 'replay', + DSH_SNAPSHOT_FILE: fixtureFile, + DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot, + }, + }) + await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await launched.client.newSession({ cwd: dir, mcpServers: [] }) + const nextChunk = launched.waitForUpdate(update => update.sessionUpdate === 'agent_message_chunk') + await launched.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) + expect((await nextChunk).sessionUpdate).toBe('agent_message_chunk') + expect(launched.updates.some(update => update.sessionUpdate === 'agent_message_chunk')).toBe(true) + expect(launched.rawStdout()).toContain('permission:{\\"outcome\\":\\"cancelled\\"}') + expect(launched.stderr()).toContain('launcher stderr') + await launched.close() + await launched.close('SIGKILL') + + // The minimal shape needs no environment or config override. + const minimal = launchAcpTestAgent({ agent: AGENT, cwd: dir }) + await minimal.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + await minimal.close() + }) + it('drives a full turn: initialize (terminal caps), session, prompt, permission stub, harvest', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ permissionProbe: true, From 6ad4b4fa42eaf296a2692c4e0886dee02cbfe75f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:46:46 +0800 Subject: [PATCH 07/33] fix: let snapshot recording create fixture inventory --- packages/support/acp-snapshot/src/suite.ts | 38 ++++++++++++++----- .../support/acp-snapshot/tests/suite.spec.ts | 17 ++++++++- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 6311a15031..4d87093c8a 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -31,7 +31,7 @@ * @module @deepseek-ai/dsh-acp-snapshot/suite */ -import { readFile, readdir, writeFile } from 'node:fs/promises' +import { readFile, readdir, rm, writeFile } from 'node:fs/promises' import { existsSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -410,9 +410,12 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as InputScript const overrideFile = join(dir, 'replay.override.json') const workspaceDir = join(dir, 'workspace') - const fixtureFiles = await sessionFixtures(dir) + // Replay/refresh need the committed inventory up front because those + // files drive the model scripts. Record mode creates that inventory + // from the harvested live logs, so it must also work for a brand-new + // scenario with no session.jsonl yet. + let fixtureFiles = RECORDING ? [] : await sessionFixtures(dir) const childFixtureFiles = fixtureFiles.slice(1) - const childSessions = childFixtureFiles.length const comparesLog = scenario.comparesLog ?? scenario.hasModelTurn const result = await runScenario(input, { agent, @@ -421,7 +424,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { ...existsSync(overrideFile) ? { overrideFile } : {}, // In REPLAY, forward the recorded child fixtures so each subagent session // replays from its own script. In RECORD they are harvested, not read. - ...!RECORDING && childSessions > 0 ? { childFiles: childFixtureFiles.map(file => join(dir, file)) } : {}, + ...!RECORDING && childFixtureFiles.length > 0 ? { childFiles: childFixtureFiles.map(file => join(dir, file)) } : {}, ...existsSync(workspaceDir) ? { workspaceDir } : {}, // A scenario booting an overlay tree passes its own live config; the // bin's replay swap derives the sibling `*cordis.snapshot.yml` from it. @@ -460,18 +463,35 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { || (REFRESHING && comparesLog) if (writesSessionFixtures) { expect(result.sessionLogs.length, `${mode} produced no session log to harvest`).toBeGreaterThan(0) - expect(result.sessionLogs.length, `expected ${childSessions + 1} session logs (parent + children)`) - .toBe(childSessions + 1) + if (REFRESHING) { + expect(result.sessionLogs.length, `expected ${fixtureFiles.length} session logs (parent + children)`) + .toBe(fixtureFiles.length) + } + const outputFixtureFiles = [ + 'session.jsonl', + ...Array.from({ length: result.sessionLogs.length - 1 }, (_, i) => `session.${i + 1}.jsonl`), + ] const primary = (result.sessionLogs[0] as HarvestedLog).content - await writeFile(join(dir, 'session.jsonl'), scrub( + await writeFile(join(dir, outputFixtureFiles[0] as string), scrub( REFRESHING ? stabilizeRefreshLog(primary, existingFixtures[0] as string, replacements) : primary, )) for (let i = 1; i < result.sessionLogs.length; i++) { const child = (result.sessionLogs[i] as HarvestedLog).content - await writeFile(join(dir, `session.${i}.jsonl`), scrub( + await writeFile(join(dir, outputFixtureFiles[i] as string), scrub( REFRESHING ? stabilizeRefreshLog(child, existingFixtures[i] as string, replacements) : child, )) } + if (RECORDING) { + const outputNames = new Set(outputFixtureFiles) + const entries = await readdir(dir, { withFileTypes: true }) + await Promise.all(entries + .filter(entry => entry.isFile() + && entry.name.startsWith('session.') + && entry.name.endsWith('.jsonl') + && !outputNames.has(entry.name)) + .map(entry => rm(join(dir, entry.name)))) + fixtureFiles = outputFixtureFiles + } if (scenario.pinsHeader === true) { const primary = result.sessionLogs[0] as HarvestedLog const prompts = normalizedSystemPrompts(primary.content, ctx) @@ -498,7 +518,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { // prompt becomes the fixture's `{{system}}`; non-pinning scenarios // additionally tokenize tools/prefix. The dedicated header guard below // compares those omitted values against their class's pin artifacts. - expect(result.sessionLogs.length, 'this scenario must persist a session log').toBe(childSessions + 1) + expect(result.sessionLogs.length, 'this scenario must persist one log per session fixture').toBe(fixtureFiles.length) for (let i = 0; i < fixtureFiles.length; i++) { const harvested = scrub((result.sessionLogs[i] as HarvestedLog).content) const fixture = scrub(await readFile(join(dir, fixtureFiles[i] as string), 'utf8')) diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index f27f36c564..7aa218526c 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -1,4 +1,4 @@ -import { cpSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -69,7 +69,13 @@ const RECORD_SCENARIOS: Scenario[] = [ // committed record fixtures/goldens in place. const BOOTSTRAP = process.env.ACP_SNAPSHOT_SPEC_BOOTSTRAP === '1' const recordDir = BOOTSTRAP ? RECORD_SRC : mkdtempSync(join(tmpdir(), 'acp-snap-record-suite-')) -if (!BOOTSTRAP) cpSync(RECORD_SRC, recordDir, { recursive: true }) +if (!BOOTSTRAP) { + cpSync(RECORD_SRC, recordDir, { recursive: true }) + // Record mode owns its output inventory: a new scenario has no primary yet, + // while a changed child count can leave old numbered fixtures behind. + rmSync(join(recordDir, 'rec-pin', 'session.jsonl')) + writeFileSync(join(recordDir, 'rec-child', 'session.2.jsonl'), 'stale child\n') +} const refreshDir = mkdtempSync(join(tmpdir(), 'acp-snap-refresh-suite-')) cpSync(REPLAY_DIR, refreshDir, { recursive: true }) staleRefreshFixtures(refreshDir) @@ -141,6 +147,13 @@ describe('defineAcpSnapshotSuite: refresh write-back', () => { }) }) +describe('defineAcpSnapshotSuite: record inventory write-back', () => { + it('creates a missing primary fixture and prunes stale child fixtures', () => { + expect(readFileSync(join(recordDir, 'rec-pin', 'session.jsonl'), 'utf8')).toContain('"type":"session"') + expect(() => readFileSync(join(recordDir, 'rec-child', 'session.2.jsonl'), 'utf8')).toThrow() + }) +}) + describe('defineAcpSnapshotSuite: registration contract', () => { it("throws when a scenario's header class has no pinning scenario", () => { expect(() => { From 9028c9b63b6c3ed1737c168ae5e1548bab064668 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:35:34 +0800 Subject: [PATCH 08/33] fix: contain ACP update predicate failures --- packages/support/acp-snapshot/src/launcher.ts | 13 +++++++++++-- packages/support/acp-snapshot/tests/harness.spec.ts | 4 ++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts index 635a02ca35..975b1bb852 100644 --- a/packages/support/acp-snapshot/src/launcher.ts +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -107,6 +107,7 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe const updateWaiters: { match: (update: SessionNotification['update']) => boolean resolve: (update: SessionNotification['update']) => void + reject: (reason: unknown) => void }[] = [] const stream = ndJsonStream( Writable.toWeb(child.stdin) as WritableStream, @@ -119,7 +120,15 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe const waiter = updateWaiters[index] /* v8 ignore next 1 -- index is bounded by the array length */ if (waiter === undefined) continue - if (!waiter.match(params.update)) continue + let matches: boolean + try { + matches = waiter.match(params.update) + } catch (error: unknown) { + updateWaiters.splice(index, 1) + waiter.reject(error) + continue + } + if (!matches) continue updateWaiters.splice(index, 1) waiter.resolve(params.update) } @@ -136,7 +145,7 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe updates, rawStdout: () => Buffer.concat(rawBuffers).toString('utf8'), stderr: () => stderrChunks.join(''), - waitForUpdate: match => new Promise(resolve => updateWaiters.push({ match, resolve })), + waitForUpdate: match => new Promise((resolve, reject) => updateWaiters.push({ match, resolve, reject })), async close(signal?: NodeJS.Signals): Promise { if (child.exitCode !== null || child.signalCode !== null) return if (signal === undefined) child.stdin.end() diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index ec1ed5cf6f..db715db90c 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -57,7 +57,11 @@ describe('runScenario', () => { await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await launched.client.newSession({ cwd: dir, mcpServers: [] }) const nextChunk = launched.waitForUpdate(update => update.sessionUpdate === 'agent_message_chunk') + const predicateFailure = new Error('predicate failed') + const failedPredicate = launched.waitForUpdate(() => { throw predicateFailure }) + .catch((error: unknown): unknown => error) await launched.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) + expect(await failedPredicate).toBe(predicateFailure) expect((await nextChunk).sessionUpdate).toBe('agent_message_chunk') expect(launched.updates.some(update => update.sessionUpdate === 'agent_message_chunk')).toBe(true) expect(launched.rawStdout()).toContain('permission:{\\"outcome\\":\\"cancelled\\"}') From 60ce23d77c52b6f4c183c320d2a1b54c4aedea7f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 06:07:37 +0800 Subject: [PATCH 09/33] fix: surface ACP launcher spawn failures --- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/src/harness.ts | 1 + packages/support/acp-snapshot/src/launcher.ts | 22 ++++++++++++++++++- .../acp-snapshot/tests/harness.spec.ts | 13 ++++++++++- 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 4a43d0eef9..0058e9116f 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -4,7 +4,7 @@ The ACP snapshot suite kit: the shared machinery behind the keyless snapshot tie Four layers, importable separately: -- **`launchAcpTestAgent` (launcher)** — boots an unbuilt ACP agent from a temp cwd, pins tsx to the repo tsconfig, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. +- **`launchAcpTestAgent` (launcher)** — boots an unbuilt ACP agent from a temp cwd, pins tsx to the repo tsconfig, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through its startup lifecycle, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`runScenario` (harness)** — boots the real agent bin as a subprocess via tsx (unbuilt, Loader path), drives it over ACP JSON-RPC stdio from a deterministic `input.json` script, tees raw stdout for the golden + purity check, and harvests every persisted session JSONL (parent + subagent children, primary-first) after a graceful stdin-EOF shutdown. Parameterized by `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath` — absolute paths; the subprocess cwd is a temp dir outside the repo). - **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept), `scrubSystemPrompts` (prompt text → `{{system}}` in every JSONL), and `scrubRequestHeaders` (the remaining header bulk → `{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header RFC](../../../docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). - **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario golden + re-persisted-log compares, record/refresh fixture write-back, the per-header-class pin (`system-prompt.golden.md` plus the JSONL's full tool schemas) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt-scrubbed, non-pinning fixtures fully header-scrubbed). Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index 93bb556ba6..d04b0bc2b5 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -214,6 +214,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise }, }) const active = launched + await active.spawned const { client } = active for (const step of input.steps) { diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts index 975b1bb852..3508b02ec5 100644 --- a/packages/support/acp-snapshot/src/launcher.ts +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -53,6 +53,8 @@ export interface AcpTestLaunchOptions { export interface LaunchedAcpTestAgent { /** The child process, exposed for process-level assertions. */ child: ChildProcessWithoutNullStreams + /** Resolve when the OS spawns the child; reject with its asynchronous spawn failure. */ + spawned: Promise /** The SDK connection backed by the child's stdio. */ client: ClientSideConnection /** Session updates in receive order. */ @@ -90,6 +92,18 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe stdio: ['pipe', 'pipe', 'pipe'], }, ) + // A spawn-level failure is an asynchronous `error` event. Observe it in the + // same tick as spawn so a missing cwd or OS rejection cannot crash the test + // runner, then make startup and shutdown surface the original error. + const childFailure = new Promise(resolve => child.once('error', resolve)) + const spawned = Promise.race([ + new Promise(resolve => child.once('spawn', resolve)), + childFailure.then((error): never => { throw error }), + ]) + // `spawned` is public and close() also awaits it, but a caller may ignore both. + // Keep that misuse from turning the already-observed child error into an + // unhandled promise rejection. + void spawned.catch(() => undefined) const stderrChunks: string[] = [] child.stderr.setEncoding('utf8') @@ -141,16 +155,22 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe return { child, + spawned, client, updates, rawStdout: () => Buffer.concat(rawBuffers).toString('utf8'), stderr: () => stderrChunks.join(''), waitForUpdate: match => new Promise((resolve, reject) => updateWaiters.push({ match, resolve, reject })), async close(signal?: NodeJS.Signals): Promise { + await spawned if (child.exitCode !== null || child.signalCode !== null) return if (signal === undefined) child.stdin.end() else child.kill(signal) - await waitForExit(child) + const failure = await Promise.race([ + waitForExit(child).then((): undefined => undefined), + childFailure, + ]) + if (failure !== undefined) throw failure }, } } diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index db715db90c..69c02a407f 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -40,6 +40,13 @@ async function scenario(behavior: object): Promise<{ dir: string; fixtureFile: s const boot: InputStep[] = [{ op: 'initialize' }, { op: 'newSession' }] describe('runScenario', () => { + it('surfaces an asynchronous child spawn failure through startup and close', async () => { + const { dir } = await scenario({}) + const launched = launchAcpTestAgent({ agent: AGENT, cwd: join(dir, 'missing') }) + await expect(launched.spawned).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(launched.close()).rejects.toMatchObject({ code: 'ENOENT' }) + }) + it('centralizes ACP boot, captures, updates, fail-closed permissions, and shutdown', { timeout: 20_000 }, async () => { const { dir, fixtureFile } = await scenario({ permissionProbe: true, echoEnv: true, stderrNote: 'launcher stderr' }) const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-launcher-sessions-')) @@ -72,7 +79,11 @@ describe('runScenario', () => { // The minimal shape needs no environment or config override. const minimal = launchAcpTestAgent({ agent: AGENT, cwd: dir }) await minimal.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) - await minimal.close() + const childFailure = new Error('child process failed') + const exited = new Promise(resolve => minimal.child.once('exit', () => { resolve() })) + minimal.child.emit('error', childFailure) + await expect(minimal.close('SIGKILL')).rejects.toBe(childFailure) + await exited }) it('drives a full turn: initialize (terminal caps), session, prompt, permission stub, harvest', { timeout: 20_000 }, async () => { From 140d32681ef2fc4b35dbae710044c7b7c4e693b7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 06:33:22 +0800 Subject: [PATCH 10/33] fix: make ACP test teardown failure-safe --- examples/acp-agent/tests/acp.e2e.ts | 14 ++++-- examples/acp-agent/tests/hooks.e2e.ts | 14 ++++-- .../sandbox-acp-agent/tests/escalation.e2e.ts | 14 ++++-- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/src/harness.ts | 44 ++++++++++++------- packages/support/acp-snapshot/src/launcher.ts | 25 +++++++++-- .../acp-snapshot/tests/harness.spec.ts | 8 ++-- 7 files changed, 86 insertions(+), 35 deletions(-) diff --git a/examples/acp-agent/tests/acp.e2e.ts b/examples/acp-agent/tests/acp.e2e.ts index 4b6c675208..871006dfb4 100644 --- a/examples/acp-agent/tests/acp.e2e.ts +++ b/examples/acp-agent/tests/acp.e2e.ts @@ -30,10 +30,16 @@ let spawned: LaunchedAcpTestAgent | undefined let workdir: string | undefined afterEach(async () => { - await spawned?.close('SIGKILL') - spawned = undefined - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) - workdir = undefined + try { + await spawned?.close('SIGKILL') + } finally { + spawned = undefined + try { + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + } finally { + workdir = undefined + } + } }) describe('acp-agent over real stdio (no key required)', () => { diff --git a/examples/acp-agent/tests/hooks.e2e.ts b/examples/acp-agent/tests/hooks.e2e.ts index a553addc96..8dc169f437 100644 --- a/examples/acp-agent/tests/hooks.e2e.ts +++ b/examples/acp-agent/tests/hooks.e2e.ts @@ -38,10 +38,16 @@ let spawned: LaunchedAcpTestAgent | undefined let workdir: string | undefined afterEach(async () => { - await spawned?.close('SIGKILL') - spawned = undefined - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) - workdir = undefined + try { + await spawned?.close('SIGKILL') + } finally { + spawned = undefined + try { + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + } finally { + workdir = undefined + } + } }) describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: a PreToolUse hook blocks bash (real model)', () => { diff --git a/examples/sandbox-acp-agent/tests/escalation.e2e.ts b/examples/sandbox-acp-agent/tests/escalation.e2e.ts index a7e8787e36..0ddaa6426e 100644 --- a/examples/sandbox-acp-agent/tests/escalation.e2e.ts +++ b/examples/sandbox-acp-agent/tests/escalation.e2e.ts @@ -81,10 +81,16 @@ let spawned: Spawned | undefined let workdir: string | undefined afterEach(async () => { - await spawned?.close('SIGKILL') - spawned = undefined - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) - workdir = undefined + try { + await spawned?.close('SIGKILL') + } finally { + spawned = undefined + try { + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + } finally { + workdir = undefined + } + } }) describe('sandbox-acp-agent keyless smoke (real cordis.yml via the Loader)', () => { diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 0058e9116f..26192f1cac 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -4,7 +4,7 @@ The ACP snapshot suite kit: the shared machinery behind the keyless snapshot tie Four layers, importable separately: -- **`launchAcpTestAgent` (launcher)** — boots an unbuilt ACP agent from a temp cwd, pins tsx to the repo tsconfig, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through its startup lifecycle, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. +- **`launchAcpTestAgent` (launcher)** — boots an unbuilt ACP agent from a temp cwd, pins tsx to the repo tsconfig, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through its startup lifecycle, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit before resolving or propagating a child error, so callers can remove owned paths after either outcome. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`runScenario` (harness)** — boots the real agent bin as a subprocess via tsx (unbuilt, Loader path), drives it over ACP JSON-RPC stdio from a deterministic `input.json` script, tees raw stdout for the golden + purity check, and harvests every persisted session JSONL (parent + subagent children, primary-first) after a graceful stdin-EOF shutdown. Parameterized by `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath` — absolute paths; the subprocess cwd is a temp dir outside the repo). - **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept), `scrubSystemPrompts` (prompt text → `{{system}}` in every JSONL), and `scrubRequestHeaders` (the remaining header bulk → `{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header RFC](../../../docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). - **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario golden + re-persisted-log compares, record/refresh fixture write-back, the per-header-class pin (`system-prompt.golden.md` plus the JSONL's full tool schemas) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt-scrubbed, non-pinning fixtures fully header-scrubbed). Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index d04b0bc2b5..3db1355b0f 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -163,7 +163,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise let launched: LaunchedAcpTestAgent | undefined let sessionId: string | undefined let sessionLogs: HarvestedLog[] = [] - try { + const outcome = await (async (): Promise => { // Seed the workspace if the scenario ships one (a file the agent reads/edits). // Copied into the temp cwd so the agent's bash tools see it; the goldens // normalize the cwd, so the seeded paths stay stable across runs. @@ -231,22 +231,36 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise // Harvest EVERY persisted log (parent + any subagent children) while the // temp dirs still exist, ordered primary-first. sessionLogs = await harvestSessionLogs(sessionsRoot) - } finally { - // Failure-safe teardown: kill a still-running child and drop the temp dirs - // even if seeding/spawn/a step/harvest threw, so a flaky run never leaks a - // process or dir. `launched` is undefined only if launch itself threw. - await launched?.close('SIGKILL') - await rm(cwd, { recursive: true, force: true }) - await rm(sessionsRoot, { recursive: true, force: true }) - } + return { + rawStdout: launched.rawStdout(), + stderr: launched.stderr(), + cwd, + ...sessionId !== undefined ? { sessionId } : {}, + sessionLogs, + } + })().then( + value => ({ status: 'fulfilled', value } as const), + (error: unknown) => ({ status: 'rejected', error } as const), + ) - return { - rawStdout: launched.rawStdout(), - stderr: launched.stderr(), - cwd, - ...sessionId !== undefined ? { sessionId } : {}, - sessionLogs, + // Failure-safe teardown: wait for a still-running child, then attempt BOTH + // directory removals even when an earlier cleanup rejects. The main outcome + // wins over teardown noise so a step/harvest failure is never replaced; on a + // successful run, the first cleanup failure remains visible to the caller. + const cleanupResults: PromiseSettledResult[] = [] + const cleanup = async (action: () => Promise): Promise => { + cleanupResults.push(...await Promise.allSettled([action()])) } + /* v8 ignore next 1 -- launch itself can only throw on a defensive synchronous spawn API failure */ + await cleanup(() => launched?.close('SIGKILL') ?? Promise.resolve()) + await cleanup(() => rm(cwd, { recursive: true, force: true })) + await cleanup(() => rm(sessionsRoot, { recursive: true, force: true })) + + if (outcome.status === 'rejected') throw outcome.error + const cleanupFailure = cleanupResults.find((result): result is PromiseRejectedResult => result.status === 'rejected') + /* v8 ignore next 1 -- defensive OS cleanup failure after an otherwise successful real subprocess run */ + if (cleanupFailure !== undefined) throw cleanupFailure.reason + return outcome.value } /** Drive one input step over the client connection. */ diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts index 3508b02ec5..7f2ec9b0a7 100644 --- a/packages/support/acp-snapshot/src/launcher.ts +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -95,7 +95,10 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe // A spawn-level failure is an asynchronous `error` event. Observe it in the // same tick as spawn so a missing cwd or OS rejection cannot crash the test // runner, then make startup and shutdown surface the original error. - const childFailure = new Promise(resolve => child.once('error', resolve)) + // Keep observing after the first error: a fallback kill attempted during + // shutdown may itself report another process error, which must not become an + // unhandled EventEmitter error after the promise has already settled. + const childFailure = new Promise(resolve => child.on('error', resolve)) const spawned = Promise.race([ new Promise(resolve => child.once('spawn', resolve)), childFailure.then((error): never => { throw error }), @@ -163,14 +166,23 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe waitForUpdate: match => new Promise((resolve, reject) => updateWaiters.push({ match, resolve, reject })), async close(signal?: NodeJS.Signals): Promise { await spawned - if (child.exitCode !== null || child.signalCode !== null) return + if (!isRunning(child)) return + const exited = waitForExit(child) if (signal === undefined) child.stdin.end() else child.kill(signal) const failure = await Promise.race([ - waitForExit(child).then((): undefined => undefined), + exited.then((): undefined => undefined), childFailure, ]) - if (failure !== undefined) throw failure + if (failure === undefined) return + + // An `error` after spawn is not an exit edge: in particular, a failed + // signal can leave the subprocess live. Force termination, await the + // already-observed exit edge, and only then propagate the child error so + // callers may safely remove cwd/session resources after close rejects. + child.kill('SIGKILL') + await exited + throw failure }, } } @@ -179,3 +191,8 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe function waitForExit(child: ChildProcessWithoutNullStreams): Promise { return new Promise(resolve => child.once('exit', () => { resolve() })) } + +/** Whether the child still lacks either OS termination marker. */ +function isRunning(child: ChildProcessWithoutNullStreams): boolean { + return child.exitCode === null && child.signalCode === null +} diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 69c02a407f..3c1bb0d44a 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -80,10 +80,12 @@ describe('runScenario', () => { const minimal = launchAcpTestAgent({ agent: AGENT, cwd: dir }) await minimal.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const childFailure = new Error('child process failed') - const exited = new Promise(resolve => minimal.child.once('exit', () => { resolve() })) + let exited = false + minimal.child.once('exit', () => { exited = true }) minimal.child.emit('error', childFailure) - await expect(minimal.close('SIGKILL')).rejects.toBe(childFailure) - await exited + await expect(minimal.close('SIGTERM')).rejects.toBe(childFailure) + // close rejects only after the fallback SIGKILL has produced an exit edge. + expect(exited).toBe(true) }) it('drives a full turn: initialize (terminal caps), session, prompt, permission stub, harvest', { timeout: 20_000 }, async () => { From e2a5a160d3763ee7faaa9b4235ae7284144f05ee Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:00:10 +0800 Subject: [PATCH 11/33] fix: settle ACP update waiters on shutdown --- packages/support/acp-snapshot/src/launcher.ts | 43 ++++++++++++++----- .../acp-snapshot/tests/harness.spec.ts | 3 ++ 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts index 7f2ec9b0a7..253e67fd5a 100644 --- a/packages/support/acp-snapshot/src/launcher.ts +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -114,18 +114,26 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe const rawBuffers: Buffer[] = [] const passthrough = new Readable({ read() {} }) - child.stdout.on('data', (buffer: Buffer) => { - rawBuffers.push(buffer) - passthrough.push(buffer) - }) - child.stdout.on('end', () => passthrough.push(null)) - const updates: SessionNotification['update'][] = [] const updateWaiters: { match: (update: SessionNotification['update']) => boolean resolve: (update: SessionNotification['update']) => void reject: (reason: unknown) => void }[] = [] + let updateStreamFailure: Error | undefined + const closeUpdateStream = (): void => { + if (updateStreamFailure !== undefined) return + updateStreamFailure = new Error('ACP test agent update stream closed before a matching session update arrived') + for (const waiter of updateWaiters.splice(0)) waiter.reject(updateStreamFailure) + } + child.stdout.on('data', (buffer: Buffer) => { + rawBuffers.push(buffer) + passthrough.push(buffer) + }) + child.stdout.on('end', () => { + passthrough.push(null) + closeUpdateStream() + }) const stream = ndJsonStream( Writable.toWeb(child.stdin) as WritableStream, Readable.toWeb(passthrough) as ReadableStream, @@ -163,10 +171,21 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe updates, rawStdout: () => Buffer.concat(rawBuffers).toString('utf8'), stderr: () => stderrChunks.join(''), - waitForUpdate: match => new Promise((resolve, reject) => updateWaiters.push({ match, resolve, reject })), + waitForUpdate(match): Promise { + if (updateStreamFailure !== undefined) return Promise.reject(updateStreamFailure) + return new Promise((resolve, reject) => updateWaiters.push({ match, resolve, reject })) + }, async close(signal?: NodeJS.Signals): Promise { - await spawned - if (!isRunning(child)) return + try { + await spawned + } catch (error: unknown) { + closeUpdateStream() + throw error + } + if (!isRunning(child)) { + closeUpdateStream() + return + } const exited = waitForExit(child) if (signal === undefined) child.stdin.end() else child.kill(signal) @@ -174,7 +193,10 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe exited.then((): undefined => undefined), childFailure, ]) - if (failure === undefined) return + if (failure === undefined) { + closeUpdateStream() + return + } // An `error` after spawn is not an exit edge: in particular, a failed // signal can leave the subprocess live. Force termination, await the @@ -182,6 +204,7 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe // callers may safely remove cwd/session resources after close rejects. child.kill('SIGKILL') await exited + closeUpdateStream() throw failure }, } diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 3c1bb0d44a..192d36dc6c 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -73,7 +73,10 @@ describe('runScenario', () => { expect(launched.updates.some(update => update.sessionUpdate === 'agent_message_chunk')).toBe(true) expect(launched.rawStdout()).toContain('permission:{\\"outcome\\":\\"cancelled\\"}') expect(launched.stderr()).toContain('launcher stderr') + const unmatched = expect(launched.waitForUpdate(() => false)).rejects.toThrow(/update stream closed/) await launched.close() + await unmatched + await expect(launched.waitForUpdate(() => true)).rejects.toThrow(/update stream closed/) await launched.close('SIGKILL') // The minimal shape needs no environment or config override. From 0b492e6e62be53abb8560d16f806a4078f56c651 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:22:22 +0800 Subject: [PATCH 12/33] fix: drain ACP test launcher streams --- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/src/launcher.ts | 17 +++++++++++++-- .../tests/fixtures/fake-acp-agent.ts | 21 +++++++++++++++++++ .../acp-snapshot/tests/harness.spec.ts | 21 +++++++++++++++++++ 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 26192f1cac..b95615abea 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -4,7 +4,7 @@ The ACP snapshot suite kit: the shared machinery behind the keyless snapshot tie Four layers, importable separately: -- **`launchAcpTestAgent` (launcher)** — boots an unbuilt ACP agent from a temp cwd, pins tsx to the repo tsconfig, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through its startup lifecycle, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit before resolving or propagating a child error, so callers can remove owned paths after either outcome. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. +- **`launchAcpTestAgent` (launcher)** — boots an unbuilt ACP agent from a temp cwd, pins tsx to the repo tsconfig, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through its startup lifecycle, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`runScenario` (harness)** — boots the real agent bin as a subprocess via tsx (unbuilt, Loader path), drives it over ACP JSON-RPC stdio from a deterministic `input.json` script, tees raw stdout for the golden + purity check, and harvests every persisted session JSONL (parent + subagent children, primary-first) after a graceful stdin-EOF shutdown. Parameterized by `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath` — absolute paths; the subprocess cwd is a temp dir outside the repo). - **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept), `scrubSystemPrompts` (prompt text → `{{system}}` in every JSONL), and `scrubRequestHeaders` (the remaining header bulk → `{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header RFC](../../../docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). - **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario golden + re-persisted-log compares, record/refresh fixture write-back, the per-header-class pin (`system-prompt.golden.md` plus the JSONL's full tool schemas) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt-scrubbed, non-pinning fixtures fully header-scrubbed). Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts index 253e67fd5a..815753ae5f 100644 --- a/packages/support/acp-snapshot/src/launcher.ts +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -65,7 +65,7 @@ export interface LaunchedAcpTestAgent { stderr(): string /** Resolve when a future session update matches the predicate. */ waitForUpdate(match: (update: SessionNotification['update']) => boolean): Promise - /** Gracefully close stdin, or send a signal, and wait for process exit. */ + /** Gracefully close stdin, or send a signal, then wait for process exit, inherited stdio closure, and ACP parser drain. */ close(signal?: NodeJS.Signals): Promise } @@ -132,7 +132,6 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe }) child.stdout.on('end', () => { passthrough.push(null) - closeUpdateStream() }) const stream = ndJsonStream( Writable.toWeb(child.stdin) as WritableStream, @@ -163,6 +162,17 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe ?? (() => Promise.resolve({ outcome: { outcome: 'cancelled' } })), }) const client = new ClientSideConnection(makeClient, stream) + // `exit` only reports the parent process's status. Descendants may retain + // inherited stdout/stderr handles and buffered ACP frames may still be + // crossing the SDK parser. Node's `close` follows stdio closure; the SDK's + // `closed` follows parser exhaustion. Capture both eagerly so a caller that + // invokes close after process exit still joins the complete drain boundary. + const stdioClosed = new Promise(resolve => child.once('close', () => { resolve() })) + const drained = Promise.all([stdioClosed, client.closed]).then(() => undefined) + // A caller may await a pending update without calling close(). Make natural + // stream exhaustion terminal for those waiters too, but only after the + // parser has dispatched every buffered frame. + void client.closed.then(closeUpdateStream) return { child, @@ -183,6 +193,7 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe throw error } if (!isRunning(child)) { + await drained closeUpdateStream() return } @@ -194,6 +205,7 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe childFailure, ]) if (failure === undefined) { + await drained closeUpdateStream() return } @@ -204,6 +216,7 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe // callers may safely remove cwd/session resources after close rejects. child.kill('SIGKILL') await exited + await drained closeUpdateStream() throw failure }, diff --git a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts index 861c9d2b04..9b0760213c 100644 --- a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts +++ b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts @@ -18,6 +18,7 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { readdirSync } from 'node:fs' +import { spawn } from 'node:child_process' import { dirname, join } from 'node:path' import { randomUUID } from 'node:crypto' import { createInterface } from 'node:readline' @@ -50,6 +51,8 @@ interface Behavior { echoWorkspace?: boolean /** Write a line to stderr on boot (spec-side stderr-capture assertions). */ stderrNote?: string + /** Let a short-lived descendant retain stdio and emit one final ACP update plus stderr line after this parent exits. */ + lateInheritedOutput?: boolean /** Session logs to persist on stdin EOF. */ logs?: ScriptedLog[] /** Leave a stray FILE directly under the sessions root (harvest must skip it). */ @@ -256,6 +259,24 @@ function flushLogsAndExit(): void { writeFileSync(join(sessionsRoot, 'bucket-noise', 'notes.txt'), 'not a session log\n') } if (behavior.deleteSessionsRoot === true) rmSync(sessionsRoot, { recursive: true, force: true }) + if (behavior.lateInheritedOutput === true) { + const frame = JSON.stringify({ + jsonrpc: '2.0', + method: 'session/update', + params: { + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'late inherited stdout' }, + }, + }, + }) + const code = [ + `setTimeout(() => process.stdout.write(${JSON.stringify(`${frame}\n`)}), 50)`, + `setTimeout(() => process.stderr.write(${JSON.stringify('late inherited stderr\n')}), 75)`, + ].join(';') + spawn(process.execPath, ['-e', code], { stdio: ['ignore', 1, 2] }).unref() + } process.exit(0) } diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 192d36dc6c..051db6a0ba 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -91,6 +91,27 @@ describe('runScenario', () => { expect(exited).toBe(true) }) + it('waits for inherited stdio and buffered ACP parsing after the parent exits', { timeout: 20_000 }, async () => { + const { dir, fixtureFile } = await scenario({ lateInheritedOutput: true }) + const launched = launchAcpTestAgent({ + agent: AGENT, + cwd: dir, + env: { DSH_SNAPSHOT_FILE: fixtureFile }, + }) + await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + await launched.client.newSession({ cwd: dir, mcpServers: [] }) + const lateUpdate = launched.waitForUpdate(update => + update.sessionUpdate === 'agent_message_chunk' + && update.content.type === 'text' + && update.content.text === 'late inherited stdout') + + await launched.close() + + await expect(lateUpdate).resolves.toMatchObject({ sessionUpdate: 'agent_message_chunk' }) + expect(launched.rawStdout()).toContain('late inherited stdout') + expect(launched.stderr()).toContain('late inherited stderr') + }) + it('drives a full turn: initialize (terminal caps), session, prompt, permission stub, harvest', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ permissionProbe: true, From e784e4dce5f5178b5e22a3d3376599144d8bea1b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:12:32 +0800 Subject: [PATCH 13/33] fix: await ACP client callbacks during shutdown --- packages/support/acp-snapshot/src/launcher.ts | 59 ++++++++++++------- .../acp-snapshot/tests/harness.spec.ts | 36 +++++++++++ 2 files changed, 75 insertions(+), 20 deletions(-) diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts index 815753ae5f..30702a5888 100644 --- a/packages/support/acp-snapshot/src/launcher.ts +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -65,7 +65,7 @@ export interface LaunchedAcpTestAgent { stderr(): string /** Resolve when a future session update matches the predicate. */ waitForUpdate(match: (update: SessionNotification['update']) => boolean): Promise - /** Gracefully close stdin, or send a signal, then wait for process exit, inherited stdio closure, and ACP parser drain. */ + /** Gracefully close stdin, or send a signal, then wait for process exit, inherited stdio closure, ACP parsing, and client callbacks. */ close(signal?: NodeJS.Signals): Promise } @@ -137,29 +137,41 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe Writable.toWeb(child.stdin) as WritableStream, Readable.toWeb(passthrough) as ReadableStream, ) + const inFlightClientCallbacks = new Set>() + const trackClientCallback = (callback: () => T | PromiseLike): Promise => { + const pending = Promise.resolve().then(callback) + inFlightClientCallbacks.add(pending) + void pending.then( + () => { inFlightClientCallbacks.delete(pending) }, + () => { inFlightClientCallbacks.delete(pending) }, + ) + return pending + } + const requestPermission = options.requestPermission + ?? (() => Promise.resolve({ outcome: { outcome: 'cancelled' as const } })) const makeClient = (_agent: AcpAgent): Client => ({ sessionUpdate(params: SessionNotification): Promise { - updates.push(params.update) - for (let index = updateWaiters.length - 1; index >= 0; index--) { - const waiter = updateWaiters[index] - /* v8 ignore next 1 -- index is bounded by the array length */ - if (waiter === undefined) continue - let matches: boolean - try { - matches = waiter.match(params.update) - } catch (error: unknown) { + return trackClientCallback(() => { + updates.push(params.update) + for (let index = updateWaiters.length - 1; index >= 0; index--) { + const waiter = updateWaiters[index] + /* v8 ignore next 1 -- index is bounded by the array length */ + if (waiter === undefined) continue + let matches: boolean + try { + matches = waiter.match(params.update) + } catch (error: unknown) { + updateWaiters.splice(index, 1) + waiter.reject(error) + continue + } + if (!matches) continue updateWaiters.splice(index, 1) - waiter.reject(error) - continue + waiter.resolve(params.update) } - if (!matches) continue - updateWaiters.splice(index, 1) - waiter.resolve(params.update) - } - return Promise.resolve() + }) }, - requestPermission: options.requestPermission - ?? (() => Promise.resolve({ outcome: { outcome: 'cancelled' } })), + requestPermission: params => trackClientCallback(() => requestPermission(params)), }) const client = new ClientSideConnection(makeClient, stream) // `exit` only reports the parent process's status. Descendants may retain @@ -168,7 +180,14 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe // `closed` follows parser exhaustion. Capture both eagerly so a caller that // invokes close after process exit still joins the complete drain boundary. const stdioClosed = new Promise(resolve => child.once('close', () => { resolve() })) - const drained = Promise.all([stdioClosed, client.closed]).then(() => undefined) + const drained = Promise.all([stdioClosed, client.closed]).then(async () => { + // The ACP SDK's readable loop dispatches client callbacks without awaiting + // them. Once `closed` settles no new callbacks can start, but callbacks + // already in flight still belong to this launch's teardown boundary. + while (inFlightClientCallbacks.size > 0) { + await Promise.allSettled([...inFlightClientCallbacks]) + } + }) // A caller may await a pending update without calling close(). Make natural // stream exhaustion terminal for those waiters too, but only after the // parser has dispatched every buffered frame. diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 051db6a0ba..902f48d2cf 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -1,4 +1,5 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { once } from 'node:events' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -112,6 +113,41 @@ describe('runScenario', () => { expect(launched.stderr()).toContain('late inherited stderr') }) + it('waits for in-flight client callbacks after the ACP stream closes', { timeout: 20_000 }, async () => { + const { dir, fixtureFile } = await scenario({ permissionProbe: true }) + let releasePermission: (() => void) | undefined + const permissionReleased = new Promise((resolve) => { releasePermission = resolve }) + let markPermissionStarted: (() => void) | undefined + const permissionStarted = new Promise((resolve) => { markPermissionStarted = resolve }) + let permissionFinished = false + const launched = launchAcpTestAgent({ + agent: AGENT, + cwd: dir, + env: { DSH_SNAPSHOT_FILE: fixtureFile }, + async requestPermission() { + markPermissionStarted?.() + await permissionReleased + permissionFinished = true + return { outcome: { outcome: 'cancelled' } } + }, + }) + await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await launched.client.newSession({ cwd: dir, mcpServers: [] }) + void launched.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => undefined) + await permissionStarted + + const childClosed = once(launched.child, 'close') + let closeSettled = false + const closing = launched.close('SIGKILL').then(() => { closeSettled = true }) + await childClosed + await launched.client.closed + expect(closeSettled).toBe(false) + + releasePermission?.() + await closing + expect(permissionFinished).toBe(true) + }) + it('drives a full turn: initialize (terminal caps), session, prompt, permission stub, harvest', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ permissionProbe: true, From d4c96deac3802164c430335309f936f3e5ab4f1d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:19:02 +0800 Subject: [PATCH 14/33] refactor: share ACP callback cleanup --- packages/support/acp-snapshot/src/launcher.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts index 30702a5888..f1271986c0 100644 --- a/packages/support/acp-snapshot/src/launcher.ts +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -141,10 +141,8 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe const trackClientCallback = (callback: () => T | PromiseLike): Promise => { const pending = Promise.resolve().then(callback) inFlightClientCallbacks.add(pending) - void pending.then( - () => { inFlightClientCallbacks.delete(pending) }, - () => { inFlightClientCallbacks.delete(pending) }, - ) + const untrack = (): void => { inFlightClientCallbacks.delete(pending) } + void pending.then(untrack, untrack) return pending } const requestPermission = options.requestPermission From 6b59b6050e379de30bb8f53bbc38172f8ffe896a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:43:12 +0800 Subject: [PATCH 15/33] fix: preserve ACP scenario cleanup failures --- packages/support/acp-snapshot/src/harness.ts | 20 +++++++--- .../acp-snapshot/tests/harness.spec.ts | 38 ++++++++++++++++++- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index 3db1355b0f..22b22deacd 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -244,9 +244,9 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise ) // Failure-safe teardown: wait for a still-running child, then attempt BOTH - // directory removals even when an earlier cleanup rejects. The main outcome - // wins over teardown noise so a step/harvest failure is never replaced; on a - // successful run, the first cleanup failure remains visible to the caller. + // directory removals even when an earlier cleanup rejects. Report every + // teardown failure alongside a scenario failure so neither orthogonal + // outcome hides the other. const cleanupResults: PromiseSettledResult[] = [] const cleanup = async (action: () => Promise): Promise => { cleanupResults.push(...await Promise.allSettled([action()])) @@ -256,10 +256,18 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise await cleanup(() => rm(cwd, { recursive: true, force: true })) await cleanup(() => rm(sessionsRoot, { recursive: true, force: true })) + const cleanupFailures = cleanupResults + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map(result => result.reason as unknown) + if (cleanupFailures.length > 0) { + throw new AggregateError( + outcome.status === 'rejected' ? [outcome.error, ...cleanupFailures] : cleanupFailures, + outcome.status === 'rejected' + ? 'snapshot scenario and cleanup failed' + : 'snapshot cleanup failed', + ) + } if (outcome.status === 'rejected') throw outcome.error - const cleanupFailure = cleanupResults.find((result): result is PromiseRejectedResult => result.status === 'rejected') - /* v8 ignore next 1 -- defensive OS cleanup failure after an otherwise successful real subprocess run */ - if (cleanupFailure !== undefined) throw cleanupFailure.reason return outcome.value } diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 902f48d2cf..de764cfd8a 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -3,11 +3,29 @@ import { once } from 'node:events' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { afterAll, describe, expect, it } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { runScenario, type AgentUnderTest, type InputStep } from '../src/harness.ts' import { launchAcpTestAgent } from '../src/launcher.ts' +const fsControl = vi.hoisted(() => ({ cleanupFailure: undefined as Error | undefined })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async rm(...args: Parameters): Promise { + if (String(args[0]).includes('acp-snap-cwd-') && fsControl.cleanupFailure !== undefined) { + const failure = fsControl.cleanupFailure + fsControl.cleanupFailure = undefined + await actual.rm(...args) + throw failure + } + await actual.rm(...args) + }, + } +}) + /** * Unit tests for the subprocess harness, driven through the REAL spawn path * (tsx loader, temp cwd, env plumbing) against the scripted fake ACP bin in @@ -238,6 +256,24 @@ describe('runScenario', () => { )).rejects.toThrow(/expected the prompt to fail/) }) + it('reports scenario and cleanup failures together', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({ prompt: 'respond' }) + const cleanupFailure = new Error('cleanup failed') + fsControl.cleanupFailure = cleanupFailure + + const failure = await runScenario( + { steps: [...boot, { op: 'promptExpectError', text: 'fine' }] }, + { agent: AGENT, mode: 'replay', fixtureFile }, + ).catch((error: unknown): unknown => error) + + expect(failure).toBeInstanceOf(AggregateError) + const failures = (failure as AggregateError).errors as unknown[] + expect(failures).toHaveLength(2) + expect(failures[0]).toBeInstanceOf(Error) + expect((failures[0] as Error).message).toMatch(/expected the prompt to fail/) + expect(failures[1]).toBe(cleanupFailure) + }) + it('newSessionExpectError swallows the rejection, with and without extra dirs', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ rejectExtraDirs: true }) const result = await runScenario( From a8c0e8a03c35b1303d7780b8215e482809c32d6f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:55:15 +0800 Subject: [PATCH 16/33] test: cover successful ACP cleanup failure --- .../support/acp-snapshot/tests/harness.spec.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index de764cfd8a..23b4192a4d 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -274,6 +274,21 @@ describe('runScenario', () => { expect(failures[1]).toBe(cleanupFailure) }) + it('reports cleanup failure after an otherwise successful scenario', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({}) + const cleanupFailure = new Error('cleanup failed') + fsControl.cleanupFailure = cleanupFailure + + const failure = await runScenario( + { steps: boot }, + { agent: AGENT, mode: 'replay', fixtureFile }, + ).catch((error: unknown): unknown => error) + + expect(failure).toBeInstanceOf(AggregateError) + expect((failure as AggregateError).message).toBe('snapshot cleanup failed') + expect((failure as AggregateError).errors as unknown[]).toEqual([cleanupFailure]) + }) + it('newSessionExpectError swallows the rejection, with and without extra dirs', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ rejectExtraDirs: true }) const result = await runScenario( From 3e2ba3f5574b43dc5af5d03e146769be241f96a3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:13:05 +0800 Subject: [PATCH 17/33] fix: stop ACP teardown when fallback kill fails --- packages/support/acp-snapshot/src/launcher.ts | 27 ++++++++- .../acp-snapshot/tests/harness.spec.ts | 59 +++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts index f1271986c0..8d25a6f56c 100644 --- a/packages/support/acp-snapshot/src/launcher.ts +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -65,7 +65,7 @@ export interface LaunchedAcpTestAgent { stderr(): string /** Resolve when a future session update matches the predicate. */ waitForUpdate(match: (update: SessionNotification['update']) => boolean): Promise - /** Gracefully close stdin, or send a signal, then wait for process exit, inherited stdio closure, ACP parsing, and client callbacks. */ + /** Close the process and drain its streams and callbacks; rejects promptly if fallback termination is refused. */ close(signal?: NodeJS.Signals): Promise } @@ -231,8 +231,29 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe // signal can leave the subprocess live. Force termination, await the // already-observed exit edge, and only then propagate the child error so // callers may safely remove cwd/session resources after close rejects. - child.kill('SIGKILL') - await exited + const fallbackError = Promise.withResolvers() + const observeFallbackError = (error: Error): void => { fallbackError.resolve(error) } + child.once('error', observeFallbackError) + if (!child.kill('SIGKILL')) { + child.off('error', observeFallbackError) + closeUpdateStream() + throw new AggregateError( + [failure, new Error('Fallback SIGKILL was not accepted by the child process')], + 'ACP test agent failed and fallback termination was refused', + ) + } + const fallbackFailure = await Promise.race([ + exited.then((): undefined => undefined), + fallbackError.promise, + ]) + child.off('error', observeFallbackError) + if (fallbackFailure !== undefined) { + closeUpdateStream() + throw new AggregateError( + [failure, fallbackFailure], + 'ACP test agent failed and fallback termination was refused', + ) + } await drained closeUpdateStream() throw failure diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 23b4192a4d..ff7c304121 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -131,6 +131,65 @@ describe('runScenario', () => { expect(launched.stderr()).toContain('late inherited stderr') }) + it('rejects promptly when fallback termination is refused', async () => { + const { dir } = await scenario({}) + const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir }) + await launched.spawned + + const childFailure = Object.assign(new Error('signal refused'), { code: 'EPERM' }) + const originalKill = launched.child.kill.bind(launched.child) + const kill = vi.spyOn(launched.child, 'kill').mockReturnValue(false) + const closed = new Promise(resolve => launched.child.once('close', () => { resolve() })) + try { + launched.child.emit('error', childFailure) + const rejection = await launched.close('SIGTERM').catch((error: unknown): unknown => error) + expect(rejection).toBeInstanceOf(AggregateError) + expect(rejection).toMatchObject({ + message: 'ACP test agent failed and fallback termination was refused', + errors: [ + childFailure, + expect.objectContaining({ message: 'Fallback SIGKILL was not accepted by the child process' }), + ], + }) + expect(kill).toHaveBeenNthCalledWith(1, 'SIGTERM') + expect(kill).toHaveBeenNthCalledWith(2, 'SIGKILL') + } finally { + kill.mockRestore() + originalKill('SIGKILL') + await closed + } + }) + + it('rejects promptly when fallback termination emits an error', async () => { + const { dir } = await scenario({}) + const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir }) + await launched.spawned + + const childFailure = Object.assign(new Error('signal refused'), { code: 'EPERM' }) + const fallbackFailure = Object.assign(new Error('fallback signal refused'), { code: 'EPERM' }) + const originalKill = launched.child.kill.bind(launched.child) + const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => { + if (signal === 'SIGKILL') queueMicrotask(() => launched.child.emit('error', fallbackFailure)) + return signal === 'SIGKILL' + }) + const closed = new Promise(resolve => launched.child.once('close', () => { resolve() })) + try { + launched.child.emit('error', childFailure) + const rejection = await launched.close('SIGTERM').catch((error: unknown): unknown => error) + expect(rejection).toBeInstanceOf(AggregateError) + expect(rejection).toMatchObject({ + message: 'ACP test agent failed and fallback termination was refused', + errors: [childFailure, fallbackFailure], + }) + expect(kill).toHaveBeenNthCalledWith(1, 'SIGTERM') + expect(kill).toHaveBeenNthCalledWith(2, 'SIGKILL') + } finally { + kill.mockRestore() + originalKill('SIGKILL') + await closed + } + }) + it('waits for in-flight client callbacks after the ACP stream closes', { timeout: 20_000 }, async () => { const { dir, fixtureFile } = await scenario({ permissionProbe: true }) let releasePermission: (() => void) | undefined From eae8b8ce2e9109d1bf827d7c3d2c2804c38d90d4 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 14 Jul 2026 11:55:39 +0800 Subject: [PATCH 18/33] 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 19/33] 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 287041e39e93b368646f0fe8298b65feab692acc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:35:25 +0800 Subject: [PATCH 20/33] fix: preserve malformed snapshot fixtures --- packages/support/acp-snapshot/src/suite.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 4d87093c8a..906521c42d 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -486,8 +486,10 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const entries = await readdir(dir, { withFileTypes: true }) await Promise.all(entries .filter(entry => entry.isFile() - && entry.name.startsWith('session.') - && entry.name.endsWith('.jsonl') + // Only valid numbered children are record-owned stale output. + // Malformed session-like names stay for the inventory guard to + // reject instead of being silently deleted during mutation. + && /^session\.[1-9]\d*\.jsonl$/.test(entry.name) && !outputNames.has(entry.name)) .map(entry => rm(join(dir, entry.name)))) fixtureFiles = outputFixtureFiles From 2027c70a17661fe2b8b5aab1685cd443ac2c3b56 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:38:32 +0800 Subject: [PATCH 21/33] fix: drain failed ACP launches --- packages/support/acp-snapshot/src/launcher.ts | 4 +++- packages/support/acp-snapshot/tests/harness.spec.ts | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts index 3f9a21d596..30ca0b42b8 100644 --- a/packages/support/acp-snapshot/src/launcher.ts +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -178,13 +178,14 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe // `closed` follows parser exhaustion. Capture both eagerly so a caller that // invokes close after process exit still joins the complete drain boundary. const stdioClosed = new Promise(resolve => child.once('close', () => { resolve() })) - const drained = Promise.all([stdioClosed, client.closed]).then(async () => { + const drained = Promise.allSettled([stdioClosed, client.closed]).then(async ([, clientResult]) => { // The ACP SDK's readable loop dispatches client callbacks without awaiting // them. Once `closed` settles no new callbacks can start, but callbacks // already in flight still belong to this launch's teardown boundary. while (inFlightClientCallbacks.size > 0) { await Promise.allSettled([...inFlightClientCallbacks]) } + if (clientResult.status === 'rejected') throw clientResult.reason }) // A caller may await a pending update without calling close(). Make natural // stream exhaustion terminal for those waiters too, but only after the @@ -206,6 +207,7 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe try { await spawned } catch (error: unknown) { + await drained.catch(() => undefined) closeUpdateStream() throw error } diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index ff7c304121..6adb0d4268 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -62,8 +62,17 @@ describe('runScenario', () => { it('surfaces an asynchronous child spawn failure through startup and close', async () => { const { dir } = await scenario({}) const launched = launchAcpTestAgent({ agent: AGENT, cwd: join(dir, 'missing') }) + let stdioClosed = false + let clientClosed = false + launched.child.once('close', () => { stdioClosed = true }) + void launched.client.closed.then( + () => { clientClosed = true }, + () => { clientClosed = true }, + ) await expect(launched.spawned).rejects.toMatchObject({ code: 'ENOENT' }) await expect(launched.close()).rejects.toMatchObject({ code: 'ENOENT' }) + expect(stdioClosed).toBe(true) + expect(clientClosed).toBe(true) }) it('centralizes ACP boot, captures, updates, fail-closed permissions, and shutdown', { timeout: 20_000 }, async () => { From bb40259083f10cb2071c024f05810c74021e4035 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:11:33 +0800 Subject: [PATCH 22/33] test: preserve ACP cleanup failures --- examples/acp-agent/tests/acp.e2e.ts | 18 ++++------ examples/acp-agent/tests/cleanup.e2e.ts | 38 ++++++++++++++++++++++ examples/acp-agent/tests/cleanup.ts | 23 +++++++++++++ examples/acp-agent/tests/escalation.e2e.ts | 18 ++++------ examples/acp-agent/tests/hooks.e2e.ts | 18 ++++------ 5 files changed, 82 insertions(+), 33 deletions(-) create mode 100644 examples/acp-agent/tests/cleanup.e2e.ts create mode 100644 examples/acp-agent/tests/cleanup.ts diff --git a/examples/acp-agent/tests/acp.e2e.ts b/examples/acp-agent/tests/acp.e2e.ts index a1ddbeccc6..a0bfc9d943 100644 --- a/examples/acp-agent/tests/acp.e2e.ts +++ b/examples/acp-agent/tests/acp.e2e.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, readFile } from 'node:fs/promises' +import { mkdtemp, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -9,6 +9,7 @@ import { type AgentUnderTest, type LaunchedAcpTestAgent, } from '@deepseek-ai/dsh-acp-snapshot' +import { cleanupAcpExampleTest } from './cleanup.ts' /** * End-to-end: boot examples/acp-agent as a real subprocess speaking ACP over @@ -31,16 +32,11 @@ let spawned: LaunchedAcpTestAgent | undefined let workdir: string | undefined afterEach(async () => { - try { - await spawned?.close('SIGKILL') - } finally { - spawned = undefined - try { - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) - } finally { - workdir = undefined - } - } + const ownedSpawned = spawned + const ownedWorkdir = workdir + spawned = undefined + workdir = undefined + await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir) }) describe('acp-agent over real stdio (no key required)', () => { diff --git a/examples/acp-agent/tests/cleanup.e2e.ts b/examples/acp-agent/tests/cleanup.e2e.ts new file mode 100644 index 0000000000..1f6e6493e0 --- /dev/null +++ b/examples/acp-agent/tests/cleanup.e2e.ts @@ -0,0 +1,38 @@ +/** Regression coverage for ACP example teardown. */ + +import { access, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanupAcpExampleTest } from './cleanup.ts' + +let fallbackWorkdir: string | undefined + +afterEach(async () => { + if (fallbackWorkdir !== undefined) await rm(fallbackWorkdir, { recursive: true, force: true }) + fallbackWorkdir = undefined +}) + +describe('cleanupAcpExampleTest', () => { + it('removes the workspace after process shutdown fails', async () => { + fallbackWorkdir = await mkdtemp(join(tmpdir(), 'acp-cleanup-')) + const closeFailure = new Error('close failed') + const spawned = { close: vi.fn().mockRejectedValue(closeFailure) } + + await expect(cleanupAcpExampleTest(spawned, fallbackWorkdir)) + .rejects.toMatchObject({ errors: [closeFailure] }) + await expect(access(fallbackWorkdir)).rejects.toThrow() + fallbackWorkdir = undefined + }) + + it('reports process and workspace failures together', async () => { + const closeFailure = new Error('close failed') + const spawned = { close: vi.fn().mockRejectedValue(closeFailure) } + + const failure = await cleanupAcpExampleTest(spawned, '\0').catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(AggregateError) + expect((failure as AggregateError).errors).toHaveLength(2) + expect((failure as AggregateError).errors[0]).toBe(closeFailure) + }) +}) diff --git a/examples/acp-agent/tests/cleanup.ts b/examples/acp-agent/tests/cleanup.ts new file mode 100644 index 0000000000..28a896334a --- /dev/null +++ b/examples/acp-agent/tests/cleanup.ts @@ -0,0 +1,23 @@ +/** Shared teardown for ACP example tests. */ + +import { rm } from 'node:fs/promises' +import type { LaunchedAcpTestAgent } from '@deepseek-ai/dsh-acp-snapshot' + +/** + * Close the test agent, then remove its workspace, attempting both operations + * and reporting every failure instead of allowing the later one to mask the + * earlier one. + */ +export async function cleanupAcpExampleTest( + spawned: Pick | undefined, + workdir: string | undefined, +): Promise { + const results: PromiseSettledResult[] = [] + if (spawned !== undefined) results.push(...await Promise.allSettled([spawned.close('SIGKILL')])) + if (workdir !== undefined) results.push(...await Promise.allSettled([rm(workdir, { recursive: true, force: true })])) + + const failures = results + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map(result => result.reason as unknown) + if (failures.length > 0) throw new AggregateError(failures, 'ACP example cleanup failed') +} diff --git a/examples/acp-agent/tests/escalation.e2e.ts b/examples/acp-agent/tests/escalation.e2e.ts index da8d3408c2..3b84dd721c 100644 --- a/examples/acp-agent/tests/escalation.e2e.ts +++ b/examples/acp-agent/tests/escalation.e2e.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process' -import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { mkdtemp, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -13,6 +13,7 @@ import { type AgentUnderTest, type LaunchedAcpTestAgent, } from '@deepseek-ai/dsh-acp-snapshot' +import { cleanupAcpExampleTest } from './cleanup.ts' /** * The default ACP composition (`cordis.yml`) end to end. @@ -81,16 +82,11 @@ let spawned: Spawned | undefined let workdir: string | undefined afterEach(async () => { - try { - await spawned?.close('SIGKILL') - } finally { - spawned = undefined - try { - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) - } finally { - workdir = undefined - } - } + const ownedSpawned = spawned + const ownedWorkdir = workdir + spawned = undefined + workdir = undefined + await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir) }) describe('default sandbox composition keyless smoke (real cordis.yml via the Loader)', () => { diff --git a/examples/acp-agent/tests/hooks.e2e.ts b/examples/acp-agent/tests/hooks.e2e.ts index df231184c3..dbe98ab358 100644 --- a/examples/acp-agent/tests/hooks.e2e.ts +++ b/examples/acp-agent/tests/hooks.e2e.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile, access } from 'node:fs/promises' +import { mkdtemp, writeFile, access } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -9,6 +9,7 @@ import { type AgentUnderTest, type LaunchedAcpTestAgent, } from '@deepseek-ai/dsh-acp-snapshot' +import { cleanupAcpExampleTest } from './cleanup.ts' /** * With-key e2e: the Claude Code hook bridge running against the REAL acp-agent @@ -38,16 +39,11 @@ let spawned: LaunchedAcpTestAgent | undefined let workdir: string | undefined afterEach(async () => { - try { - await spawned?.close('SIGKILL') - } finally { - spawned = undefined - try { - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) - } finally { - workdir = undefined - } - } + const ownedSpawned = spawned + const ownedWorkdir = workdir + spawned = undefined + workdir = undefined + await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir) }) describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: a PreToolUse hook blocks bash (real model)', () => { From 7e9bf9b951913b1c5d70b5e94e22ccd8f60bed76 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:03:01 +0800 Subject: [PATCH 23/33] refactor: remove impossible ACP drain branch --- packages/support/acp-snapshot/src/launcher.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts index 30ca0b42b8..c0dc21dc06 100644 --- a/packages/support/acp-snapshot/src/launcher.ts +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -178,14 +178,13 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe // `closed` follows parser exhaustion. Capture both eagerly so a caller that // invokes close after process exit still joins the complete drain boundary. const stdioClosed = new Promise(resolve => child.once('close', () => { resolve() })) - const drained = Promise.allSettled([stdioClosed, client.closed]).then(async ([, clientResult]) => { + const drained = Promise.all([stdioClosed, client.closed]).then(async () => { // The ACP SDK's readable loop dispatches client callbacks without awaiting // them. Once `closed` settles no new callbacks can start, but callbacks // already in flight still belong to this launch's teardown boundary. while (inFlightClientCallbacks.size > 0) { await Promise.allSettled([...inFlightClientCallbacks]) } - if (clientResult.status === 'rejected') throw clientResult.reason }) // A caller may await a pending update without calling close(). Make natural // stream exhaustion terminal for those waiters too, but only after the @@ -207,7 +206,7 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe try { await spawned } catch (error: unknown) { - await drained.catch(() => undefined) + await drained closeUpdateStream() throw error } From 8e7cf8cc10c3559ded0ee7f93f62d14ff5f18ad4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:09:18 +0800 Subject: [PATCH 24/33] Update ACP launcher example path --- packages/support/acp-snapshot/src/launcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts index c0dc21dc06..95292df38a 100644 --- a/packages/support/acp-snapshot/src/launcher.ts +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -27,7 +27,7 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) /** The unbuilt agent entry, leaf config, and workspace tsconfig an ACP test boots. */ export interface AgentUnderTest { - /** The agent bin entry (for example `packages/ui/acp-agent/src/bin.ts`). */ + /** The agent bin entry (for example `packages/examples/acp-demo/src/bin.ts`). */ binScript: string /** The leaf `cordis.yml` loaded by the bin. */ configPath: string From 3b1d1bfa1207ebd9344fe87115102e9b10863e5e Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 16 Jul 2026 11:36:16 +0800 Subject: [PATCH 25/33] 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 26/33] 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 27/33] 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 28/33] 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 29/33] 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 e846a115f29fd4fdfe7d6fc7ac303688a093a715 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:51:44 +0800 Subject: [PATCH 30/33] test(acp-snapshot): cover pre-spawn launch failure --- .../support/acp-snapshot/tests/harness.spec.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 3436f32f96..2b9d6e032f 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -244,6 +244,23 @@ describe('runScenario', () => { )).rejects.toThrow(/agent stderr:\nfake agent requested startup failure/) }) + it('preserves launch-resolution errors when no child process exists', async () => { + const { dir, fixtureFile } = await scenario({}) + vi.stubEnv('DSH_EXAMPLE_MODE', 'lib') + try { + await expect(runScenario( + { steps: [] }, + { + agent: { ...AGENT, binScript: join(dir, 'outside-src.ts'), libBinScript: undefined }, + mode: 'replay', + fixtureFile, + }, + )).rejects.toThrow(/expected a "\/src\/" segment/) + } finally { + vi.unstubAllEnvs() + } + }) + it('drives a full turn: initialize (terminal caps), session, prompt, permission stub, harvest', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ permissionProbe: true, 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 31/33] 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 32/33] 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 33/33] 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())