docs: tighten parallel tool-call prose
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user