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
+26 -43
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.
+7 -12
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