docs: tighten parallel tool-call prose

This commit is contained in:
Tianyi Cui
2026-07-18 14:59:26 +08:00
parent 3e07f9b270
commit 21ec178841
30 changed files with 161 additions and 320 deletions

View File

@@ -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

View File

@@ -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<ToolExecutionResult> {
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<T>(
input: ToolExecutionInput,
next: (prepared: ScheduledToolPreparation) => T | PromiseLike<T>,
@@ -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.

View File

@@ -284,13 +284,11 @@ export interface DefineToolOptions<S extends SchemaSpec> {
*/
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<S>): boolean
/**
@@ -325,7 +323,7 @@ export interface DefineToolOptions<S extends SchemaSpec> {
* @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<S extends SchemaSpec>(options: DefineToolOptions<S>): ToolDefinition {
// Object-literal execute methods don't use `this`; the reference is safe.
@@ -371,10 +369,7 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
return userPresentResult(args as InferArgs<S>, 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

View File

@@ -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({