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)