feat(agent-loop): run safe tool calls in parallel

This commit is contained in:
Dudu-0223
2026-07-13 11:02:21 +08:00
parent 4cda9dd03d
commit 7ea1bf119f
48 changed files with 1542 additions and 141 deletions

View File

@@ -26,14 +26,15 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo
```ts
interface Config {
agents: Array<{
id: string // required
id: string // required
model?: string
cwd?: string // optional workspace cwd for the fresh session
cwd?: string // optional workspace cwd for the fresh session
maxParallelToolCalls?: number // positive integer; per-agent parallel tool-call cap (default 10)
}>
}
```
Agents listed in config are auto-created at startup. `cwd` applies only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. There is no per-agent persona: the deployment persona is `dsh-system-prompt`'s own `persona` config, shared by every agent in the context. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from the `assemble({ agent })` context — runtime facts of the agents THIS loop drives, unlike the `harness:identity`/`deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin.
Agents listed in config are auto-created at startup. `cwd` applies only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. `maxParallelToolCalls` (a positive integer, default `DEFAULT_MAX_PARALLEL_TOOL_CALLS` = `10`) bounds how many parallel-safe calls one assistant step runs at once; `1` restores fully serial execution. It is validated in the schema (`z.number().step(1).min(1)`), so a bad value fails config load rather than being silently dropped. There is no per-agent persona: the deployment persona is `dsh-system-prompt`'s own `persona` config, shared by every agent in the context. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from the `assemble({ agent })` context — runtime facts of the agents THIS loop drives, unlike the `harness:identity`/`deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin.
### Classes
@@ -67,10 +68,12 @@ forever:
stream llm.stream(freeze({header..., messages: prefix+boundary})) → session('assistant/chunk')
message = waterfall agent/step-result
session('assistant/message')
each tool-call: session('tool/call')
→ tools.execute() [waterfall tools/pre-execute → dispatch → tools/post-execute]
→ session('tool/result')
append buffered post-execute additionalContext as session('context/message')(s)
schedule tool-calls: group by tools.executionMode (exclusive call = barrier;
run of parallel-safe calls = one rolling-pool group, ≤ maxParallelToolCalls in flight)
each STARTED call: session('tool/call') ⟵ model-order per started call; log positions
→ ordered tools/pre-execute → pooled dispatch/body → ordered tools/post-execute may interleave with sibling results as the pool replenishes
commit cursor appends session('tool/result') in MODEL order (slot-buffered)
append buffered post-execute additionalContext (model call order) as session('context/message')(s)
drain steering → session('steering/message')
cont = waterfall agent/turn-continuation → ContinuationDecision
({action:'continue', reason?} records reason as next-step steering)
@@ -83,6 +86,8 @@ forever:
Error containment: a throwing plugin ends the **turn**, never the loop. Dispose mid-turn emits `agent/status('disposed')` and ends with reason `disposed`. A step that hits the model's output-token ceiling makes the turn end `max-tokens` (the rule: any `max-tokens` step in the turn surfaces as `max-tokens`; `disposed`/`aborted`/`error` still take precedence) — distinct from a clean `completed` stop.
Tool scheduling: within one assistant step the loop partitions tool calls into ordered groups via `ctx.tools.executionMode` — an exclusive call is its own group (an ordering barrier), a run of consecutive parallel-safe calls is one group. A parallel group runs in a rolling pool: up to `maxParallelToolCalls` calls start in model order, and each settle starts the next until the group drains. Only dispatch/body overlaps — `tools/pre-execute`/`tools/post-execute` run in model call order, each STARTED call appends its own `tool/call` (whose log position may interleave with sibling `tool/result`s), and a model-order commit cursor appends `tool/result` from slot-buffered settlements so derived history stays model-ordered (pairing by the assistant message + `callId`). `additionalContext` from the group is injected in model call order after every result. Abort stops replenishment, drains only already-started calls to results, drops buffered context, and re-raises so `runTurn` owns the end reason; a group not yet started appends no `tool/call`. `maxParallelToolCalls: 1` is byte-for-byte the old serial path.
Cancellation: `agent.cancel()` is the single public stop primitive — it clears the queued + steering FIFOs, aborts the in-flight step, and drives a turn-scoped marker the driver checks at every point a turn could start or continue (right after the idle wait, after the `running` flip, before each step, and at the continuation gate) so a turn about to start is dropped. A cancelled turn ends `aborted`; a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. The marker is reset once per loop iteration, so a cancel governs exactly one turn and never leaks onto a later prompt. (The loop still aborts its own per-step `AbortController` directly on disposal and from `cancel()`; that controller is loop-internal, not a public verb.)
### What is NOT here

View File

@@ -0,0 +1,15 @@
/**
* Loop-level tunable defaults shared between the plugin entry (`index.ts`) and
* the tool-call scheduler (`tool-calls.ts`). Kept in a leaf module so importing
* a default never pulls in the service class or the scheduler.
*
* @module dsh-agent-loop/constants
*/
/**
* Default cap on simultaneously in-flight tool calls within one assistant step,
* when {@link AgentOptions.maxParallelToolCalls} is unset. Matches the
* rolling-pool size Claude Code uses; a group larger than the cap is not
* truncated — the cap limits concurrency, not the group.
*/
export const DEFAULT_MAX_PARALLEL_TOOL_CALLS = 10

View File

@@ -29,6 +29,22 @@ declare module 'cordis' {
}
}
declare module '@deepseek-ai/dsh-agent' {
interface AgentOptions {
/**
* Maximum tool calls this agent runs concurrently within one assistant step
* (a positive integer; defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}).
* The loop's rolling pool starts up to this many parallel-safe calls at once
* and replenishes as each settles; `1` preserves the fully serial path.
* A merge-extensible field — the loop owns it (it neither the agent nor the
* subagent seam sets it), read in `runStep` when scheduling a parallel group.
*/
maxParallelToolCalls?: number
}
}
export { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts'
/**
* Plugin config: the agents to create — or resume, via `resumeSessionId` —
* declaratively at startup, so a cordis.yml deployment needs no code.
@@ -40,6 +56,11 @@ export interface Config {
id: AgentId
/** Optional workspace cwd for the config-created fresh session. */
cwd?: string
/**
* Maximum parallel-safe tool calls to run concurrently within one assistant
* step. Must be a positive integer; `1` preserves serial execution.
*/
maxParallelToolCalls?: number
/**
* If set, the config agent RESUMES this persisted session id instead of
* starting a fresh `${id}-session-<uuid>`. Sourced from an env var in
@@ -81,6 +102,9 @@ export class AgentLoop extends Service implements AgentFactory {
model: z.string(),
cwd: z.string(),
resumeSessionId: z.string(),
// A positive integer; a bad value (0, negative, fractional) fails config
// validation here rather than being silently dropped from cordis.yml.
maxParallelToolCalls: z.number().step(1).min(1),
})).default([]),
}) as unknown as z<Config>
@@ -144,6 +168,7 @@ export class AgentLoop extends Service implements AgentFactory {
* @returns the running agent, owned by the calling fiber (no handle).
*/
create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent {
this.validateAgentOptions(options)
this.assertAgentIdFree(id)
// Config/programmatic path: prepare the session and let start() fold its
// lifecycle into the agent's composite effect (so a fiber unload tears the
@@ -168,6 +193,7 @@ export class AgentLoop extends Service implements AgentFactory {
* @returns the handle whose dispose tears down exactly this agent.
*/
createAgent(options: CreateAgentOptions): AgentHandle {
this.validateAgentOptions(options.agentOptions ?? {})
// Check the agent id BEFORE preparing the session: register() would reject a
// duplicate id only AFTER the session enters the store, leaving an orphaned
// live session (and lazy persistence state) that blocks reuse of that id.
@@ -196,6 +222,7 @@ export class AgentLoop extends Service implements AgentFactory {
* @returns the handle for the agent resumed on the reconstructed session.
*/
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
this.validateAgentOptions(options.agentOptions ?? {})
// Read the service through `ctx.get('sessionPersistence')` — a direct
// global-store lookup keyed by the isolate symbol — NOT
// `this.ctx.sessionPersistence`. AgentLoop deliberately does NOT inject
@@ -228,6 +255,7 @@ export class AgentLoop extends Service implements AgentFactory {
* AgentLoop's static inject, so they resolve fine).
*/
private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise<AgentHandle> {
this.validateAgentOptions(options.agentOptions ?? {})
this.assertAgentIdFree(options.agentId)
const { meta, events } = await persistence.load(options.resumeSessionId)
// Re-check the agent id AFTER the await: the pre-load check above can go
@@ -267,6 +295,14 @@ export class AgentLoop extends Service implements AgentFactory {
}
}
/** Validate merge-extended options the loop owns before any session is prepared or loaded. */
private validateAgentOptions(options: AgentOptions): void {
const { maxParallelToolCalls } = options
if (maxParallelToolCalls !== undefined && (!Number.isInteger(maxParallelToolCalls) || maxParallelToolCalls < 1)) {
throw new Error('maxParallelToolCalls must be a positive integer')
}
}
/**
* Shared: construct a ReactLoopAgent over a PREPARED (not-yet-entered)
* session, then build the ONE composite effect that owns the whole agent

View File

@@ -10,7 +10,7 @@
import type { Context } from 'cordis'
import type { FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm'
import { BlockAssembler, HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm'
import type { ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { ContinuationDecision, PromptDecision } from '@deepseek-ai/dsh-agent'
import { canonicalHeader } from '@deepseek-ai/dsh-session'
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
import { createTransmissionLog, recordRequestHeader } from './request-log.ts'
@@ -18,6 +18,7 @@ import type { TransmissionLog } from './request-log.ts'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import { executeToolCalls } from './tool-calls.ts'
import type { ReactLoopAgent } from './agent.ts'
/** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */
@@ -172,11 +173,12 @@ export interface LoopHandle {
* session('assistant/chunk')
* msg = waterfall agent/step-result ⟵ BEFORE the log append, so the
* session('assistant/message' {content, usage?}) session records what actually ran
* each tool-call in msg (sequential, abort-checked):
* session('tool/call'); ctx.tools.execute() ⟵ tools/pre-execute (allow/deny/ask)
* → dispatch → tools/post-execute
* session('tool/result')
* append buffered post-execute additionalContext → session('context/message')(s)
* schedule tool-calls in msg by ctx.tools.executionMode (exclusive = barrier;
* consecutive parallel-safe = one rolling-pool group, ≤ maxParallelToolCalls in flight):
* each STARTED call: session('tool/call'); tools/pre-execute (MODEL order)
* → tools/execute dispatch/body (parallel pool) → tools/post-execute (MODEL order)
* session('tool/result') committed in MODEL order (slot-buffered)
* append buffered post-execute additionalContext (model order) → session('context/message')(s)
* drain steering → session('steering/message')
* session('step/end') ⟵ durable step boundary (no agent/* mirror)
* cont = waterfall agent/turn-continuation ⟵ ContinuationDecision; default
@@ -864,68 +866,26 @@ async function runStep(
)
}
// --- Tool execution (sequential; parallel execution is a TODO) ---
// ToolRegistry.execute converts tool failures (including aborts) into
// isError results, so abort is re-checked around every call here.
// --- Tool execution (scheduled by per-call concurrency safety) ---
// executeToolCalls groups the step's calls by ctx.tools.executionMode and runs
// parallel-safe runs through a rolling pool. Only dispatch/body overlaps:
// tools/pre-execute and tools/post-execute run in model order, tool/result is
// committed in model order, and the returned additionalContext buffer is
// ordered the same way. Tool failures (including aborts) become isError
// results; the scheduler re-checks the shared signal around calls and throws
// the abort so this step's caller ends the turn.
const toolCalls = message.content.filter(block => block.type === 'tool-call')
// Per-step buffer of `additionalContext` attached by tools/post-execute
// listeners. Appended as context/message(s) only AFTER every tool/result for
// the step, so a multi-call step keeps tool-call/result adjacency
// (interleaving context between a call's result and the next call's would
// break the pairing the next model request relies on).
const pendingContext: HookContext[] = []
for (const call of toolCalls) {
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
const callEvent = session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments })
let parsedArguments: unknown
try {
parsedArguments = call.arguments ? JSON.parse(call.arguments) : {}
} catch {
parsedArguments = call.arguments
}
// TODO(pre-tool-input-rewrite): tools/pre-execute deliberately cannot rewrite
// `arguments` — tool/call (the audit record) and assistant/message (the
// model-history source) are logged BEFORE execute, and live consumers (ACP,
// tool-bash presentation) read the pre-execution args, so an execution-only
// rewrite would desync the UI from what ran. Designing that consistently is
// its own proposed RFC (docs/rfc/proposed/feature/…-pre-tool-input-rewrite.md).
const result = await ctx.tools.execute({
callId: call.id,
name: call.name,
arguments: parsedArguments,
agent,
signal,
})
session.append('tool/result', {
turn, step,
// The correlation id MUST be the loop's authoritative call.id (the
// model-transcript id that deriveMessages turns into toolCallId), NOT
// result.callId — a post-execute waterfall listener returning a
// mismatched id would otherwise orphan the call↔result pairing in the
// next model request. A listener-internal id, if ever needed, belongs in
// a separate diagnostic field, never overloaded onto callId.
callId: call.id,
content: result.content,
isError: result.isError,
...result.error ? { error: result.error } : {},
// The tool's private presentation payload (e.g. a result-time diff),
// persisted so a UI bridge reproduces the card on replay.
...result.meta !== undefined ? { meta: result.meta } : {},
}, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] })
// Buffer (don't append yet) any post-execute additionalContext for this call.
if (result.additionalContext) pendingContext.push(result.additionalContext)
// signal CAN flip during the await above (abort() inside a tool);
// the analyzer can't see through the await boundary.
/* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
/* v8 ignore stop */
}
const pendingContext = await executeToolCalls(ctx, agent, turn, step, toolCalls, signal)
// Append buffered post-execute context AFTER every tool/result, preserving
// tool-call/result adjacency across the whole batch. inject() appends into the
// open turn (a context/message at its chronological position).
// open turn (a context/message at its chronological position). The scheduler
// returns the buffer in model call order.
for (const context of pendingContext) {
agent.inject(context.content, { source: context.source })
}

View File

@@ -0,0 +1,326 @@
/**
* The agent loop's per-step tool-call scheduler. `runStep` (loop.ts) hands it
* the assistant message's `tool-call` blocks; this module parses each call's
* arguments once, classifies it via `ctx.tools.executionMode`, partitions the
* calls into ordered groups (one exclusive call, or a run of consecutive
* parallel-safe calls), and executes each group — a parallel group through a
* rolling pool bounded by the agent's `maxParallelToolCalls`.
*
* The session log stays the source of truth and is reconstructable regardless
* of dispatch timing: each STARTED call appends its own `tool/call` before its
* body runs, `tool/result` events are appended in MODEL order (slot-buffered
* behind a commit cursor), and buffered `additionalContext` is injected in model
* call order after every result. A `tool/call`'s log position may interleave
* with a sibling's `tool/result` as the pool replenishes; that is safe because
* `tool/call` is log-only and derived history pairs the assistant message's
* `tool-call` blocks with the ordered `tool/result`s by `callId`.
*
* @module dsh-agent-loop/tool-calls
*/
import type { Context } from 'cordis'
import { assertNever, type ToolCallBlock } from '@deepseek-ai/dsh-llm'
import type { HookContext } from '@deepseek-ai/dsh-agent'
import type { Session } from '@deepseek-ai/dsh-session'
import { TOOL_REGISTRY_SCHEDULER, type ToolExecution, type ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { ReactLoopAgent } from './agent.ts'
import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts'
/** One tool call after argument parsing, ready to schedule. */
interface PlannedCall {
/** The model-transcript call (authoritative `id`/`name`/raw `arguments`). */
block: ToolCallBlock
/** The distinct per-call execution object handed to the tool pipeline. */
exec: ToolExecution
}
/** A settled call's slot, filled in model order before ordered finalization. */
interface Slot {
/** The raw dispatch/pre result. */
result: ToolExecutionResult
/** Whether the result still needs ordered `tools/post-execute` finalization. */
needsPost: boolean
}
/**
* Execute one assistant step's tool calls, honoring per-call concurrency safety.
*
* Appends `tool/call` (per started call) and `tool/result` (in model order) to
* the session, and returns the ordered `additionalContext` buffer for the loop
* to inject after the batch. On abort it drains only already-started calls to
* results, drops buffered context, and throws the abort error so `runTurn` owns
* the turn-end reason.
*
* @param ctx - the loop context (reaches `ctx.tools`).
* @param agent - the agent being driven (owns the session, options, and is
* passed to each `ToolExecution`).
* @param turn - the current turn number (for the session events).
* @param step - the current step number (for the session events).
* @param toolCalls - the assistant message's `tool-call` blocks, in model order.
* @param signal - the step's abort signal (shared by every call).
* @returns the per-step `additionalContext` buffer in model call order.
*/
export async function executeToolCalls(
ctx: Context,
agent: ReactLoopAgent,
turn: number,
step: number,
toolCalls: ToolCallBlock[],
signal: AbortSignal,
): Promise<HookContext[]> {
const { session, options } = agent
const maxParallel = options.maxParallelToolCalls ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS
// Plan: parse each call's raw JSON arguments exactly once, and build one
// distinct ToolExecution per call so a `tools/execute` wrapper that mutates
// `exec` in place (e.g. replacing exec.signal with a per-call deadline) cannot
// race through a shared payload.
const planned: PlannedCall[] = toolCalls.map(block => ({
block,
exec: {
callId: block.id,
name: block.name,
arguments: parseArguments(block.arguments),
agent,
signal,
},
}))
// Partition into ordered groups: an exclusive call is its own group (a
// barrier), a run of consecutive parallel-safe calls is one group. Grouping
// uses executionMode so an exclusive tool between two reads splits them into
// separate ordered groups (no read/write race inside one assistant step).
const groups = groupByMode(ctx, planned)
const pendingContext: HookContext[] = []
for (const group of groups) {
// Groups are never empty (groupByMode only pushes non-empty runs/singletons).
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- non-empty group
const first = group[0]!
if (group.length === 1 && ctx.tools.executionMode(first.exec).kind === 'exclusive') {
await runExclusive(ctx, session, turn, step, first, signal, pendingContext)
} else {
await runParallelGroup(ctx, session, turn, step, group, signal, maxParallel, pendingContext)
}
}
return pendingContext
}
/** Parse a model-produced raw arguments string, falling back to the raw string on invalid JSON (empty ⇒ `{}`). */
function parseArguments(raw: string): unknown {
try {
return raw ? JSON.parse(raw) : {}
} catch {
return raw
}
}
/**
* Group planned calls into ordered runs: each exclusive call is a singleton
* group; consecutive parallel-safe calls coalesce into one group. `executionMode`
* is queried once per call here and again by the caller to pick the exclusive
* fast-path — both reads are pure and cheap.
*/
function groupByMode(ctx: Context, planned: PlannedCall[]): PlannedCall[][] {
const groups: PlannedCall[][] = []
let run: PlannedCall[] = []
const flush = (): void => {
if (run.length > 0) {
groups.push(run)
run = []
}
}
for (const call of planned) {
if (ctx.tools.executionMode(call.exec).kind === 'parallel') {
run.push(call)
} else {
flush()
groups.push([call])
}
}
flush()
return groups
}
/**
* The exclusive single-call path keeps the public one-call pipeline sequential:
* abort-check, `tool/call`, pre/dispatch/post via `ctx.tools.execute`,
* `tool/result`, buffer context, post-await abort-check.
*/
async function runExclusive(
ctx: Context,
session: Session,
turn: number,
step: number,
call: PlannedCall,
signal: AbortSignal,
pendingContext: HookContext[],
): Promise<void> {
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
const callSeq = appendToolCall(session, turn, step, call.block)
const result = await ctx.tools.execute(call.exec)
appendToolResult(session, turn, step, call.block, result, callSeq)
if (result.additionalContext) pendingContext.push(result.additionalContext)
// signal CAN flip during the await above (abort() inside a tool); the analyzer
// can't see through the await boundary.
/* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
/* v8 ignore stop */
}
/**
* The rolling-pool path for a group of parallel-safe calls. Starts calls in
* model order up to `maxParallel`, and whenever one settles starts the next
* unstarted call until the group is exhausted. Settled dispatches land in
* model-order slots; a commit cursor appends `tool/result` (and collects
* `additionalContext`) only while the next slot is ready, so the log stays
* model-ordered regardless of completion order.
*
* Abort: an already-aborted signal starts nothing and throws before any
* `tool/call`. An abort mid-group stops replenishment, awaits only the started
* calls, commits their results in order, drops buffered context, and throws.
*/
async function runParallelGroup(
ctx: Context,
session: Session,
turn: number,
step: number,
group: PlannedCall[],
signal: AbortSignal,
maxParallel: number,
pendingContext: HookContext[],
): Promise<void> {
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
const slots: (Slot | undefined)[] = group.map(() => undefined)
// callSeqs[i] is the `tool/call` event seq for started slot i (its provenance
// for the matching tool/result). A slot is only committed after it is started,
// so its callSeq is always set by then.
const callSeqs: number[] = group.map(() => -1)
let nextToStart = 0
let committed = 0
let started = 0
let aborted: boolean = signal.aborted
// Advance the commit cursor over contiguous settled slots: run post-execute in
// model order, append each tool/result, and collect its additionalContext.
const commitReady = async (): Promise<void> => {
while (committed < group.length) {
const slot = slots[committed]
if (slot === undefined) break
const call = group[committed]
const result = slot.needsPost
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index
? await ctx.tools[TOOL_REGISTRY_SCHEDULER].finalize(call!.exec, slot.result)
: slot.result
// committed < group.length, so call and its callSeq (set at start) exist.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index
appendToolResult(session, turn, step, call!.block, result, callSeqs[committed]!)
if (result.additionalContext) pendingContext.push(result.additionalContext)
committed++
}
}
const inFlight = new Map<number, Promise<number>>()
const startCall = async (index: number): Promise<void> => {
// index is always < group.length (bounded by every caller).
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index
const call = group[index]!
callSeqs[index] = appendToolCall(session, turn, step, call.block)
started++
const prepared = await ctx.tools[TOOL_REGISTRY_SCHEDULER].prepare(call.exec)
switch (prepared.kind) {
case 'dispatch': {
const promise = ctx.tools[TOOL_REGISTRY_SCHEDULER].dispatch(call.exec).then((result) => {
slots[index] = { result, needsPost: true }
return index
})
inFlight.set(index, promise)
break
}
case 'post-result':
slots[index] = { result: prepared.result, needsPost: true }
break
case 'final-result':
slots[index] = { result: prepared.result, needsPost: false }
break
/* v8 ignore next -- closed-union exhaustiveness guard */
default:
assertNever(prepared, 'tool-call scheduler prepare result')
}
}
const fillPool = async (): Promise<void> => {
while (!aborted && nextToStart < group.length && inFlight.size < maxParallel) {
await startCall(nextToStart)
nextToStart++
await commitReady()
// The signal CAN flip while an ordered pre-execute listener is running.
if (signal.aborted) aborted = true
}
}
// Prime the pool up to the cap. Ordered pre-execute listeners may be async;
// dispatch/body is the only stage that overlaps across in-flight calls.
await fillPool()
while (inFlight.size > 0) {
const settledIndex = await Promise.race(inFlight.values())
inFlight.delete(settledIndex)
// Commit every contiguous settled slot now available.
await commitReady()
// The signal CAN flip during the await above (abort() inside a tool); the
// analyzer can't see through the await boundary. An abort stops the pool
// from starting any further calls, but already-started calls still drain.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (signal.aborted) aborted = true
await fillPool()
}
if (aborted) {
// Every started call has settled and committed in order; buffered context
// from this aborted step is dropped (not injected). Raise the abort so the
// existing runTurn catch owns turn/end reason selection. Unstarted calls
// beyond the cap never appended a tool/call.
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
throw new Error(String(signal.reason ?? 'aborted'))
}
// A defensive check the started count matches what we committed — a parallel
// group with no abort commits every started slot, and started === group.length.
/* v8 ignore next -- unreachable: a non-aborted group starts and commits all calls */
if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls')
}
/** Append the `tool/call` audit event for one started call; returns its seq (the tool/result's provenance). */
function appendToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): number {
const event = session.append('tool/call', { turn, step, callId: block.id, name: block.name, arguments: block.arguments })
return event.seq
}
/** Append one call's `tool/result`, keyed by the authoritative model-transcript call id and provenanced to its `tool/call`. */
function appendToolResult(
session: Session,
turn: number,
step: number,
block: ToolCallBlock,
result: ToolExecutionResult,
callSeq: number,
): void {
session.append('tool/result', {
turn, step,
// The correlation id MUST be the loop's authoritative call.id (the
// model-transcript id deriveMessages turns into toolCallId), NOT
// result.callId — a post-execute listener returning a mismatched id would
// otherwise orphan the call↔result pairing in the next model request.
callId: block.id,
content: result.content,
isError: result.isError,
...result.error ? { error: result.error } : {},
// The tool's private presentation payload (e.g. a result-time diff),
// persisted so a UI bridge reproduces the card on replay.
...result.meta !== undefined ? { meta: result.meta } : {},
}, { surfaceOp: 'append', sourceEventSeqs: [callSeq] })
}

View File

@@ -0,0 +1,462 @@
/**
* The per-step tool-call scheduler (`tool-calls.ts`): grouping by
* `ctx.tools.executionMode`, the rolling pool for parallel groups, model-order
* `tool/result` commit despite out-of-order settlement, interleaved `tool/call`
* audit records, ordered `tools/pre-execute`/`tools/post-execute`,
* model-ordered `additionalContext`, and abort behavior.
*
* Tools are mocked and deterministic — no real API, no snapshot here (the
* transcript-facing live-order behavior is pinned by the ACP snapshot goldens).
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import LlmService from '@deepseek-ai/dsh-llm'
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter) {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { dispose(); resolve() }
})
})
}
function events(agent: ReactLoopAgent): SessionEvent[] {
return [...agent.session.events]
}
/** An assistant message with N tool-call blocks named `name` (ids c1..cN, arg = index). */
function multiCall(calls: { id: string; name: string; args: object }[]): StreamChunk[] {
const chunks: StreamChunk[] = []
calls.forEach((call, index) => {
chunks.push(
{ type: 'block-start', index, blockType: 'tool-call' },
{ type: 'block-end', index, block: { type: 'tool-call', id: CallId(call.id), name: call.name, arguments: JSON.stringify(call.args) } },
)
})
chunks.push(
{ type: 'usage', usage: { inputTokens: 5, outputTokens: 5 } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
)
return chunks
}
/** A parallel-safe tool whose calls block until the test releases them by callId. */
function gatedParallelTool(name: string) {
const gates = new Map<string, () => void>()
const started: string[] = []
const tool = defineTool({
name,
description: `gated ${name}`,
parameters: { id: { type: 'string', required: true } },
isConcurrencySafe: () => true,
async execute(args) {
started.push(args.id)
await new Promise<void>((resolve) => { gates.set(args.id, resolve) })
return [{ type: 'text', text: `done-${args.id}` }]
},
})
return {
tool,
started,
/** Release one in-flight call by its arg id (its `execute` resolves). */
release(id: string) { gates.get(id)?.(); gates.delete(id) },
pending() { return [...gates.keys()] },
}
}
/** Poll until `predicate` holds, letting microtasks/timers drain between checks. */
async function until(predicate: () => boolean): Promise<void> {
for (let i = 0; i < 1000 && !predicate(); i++) await new Promise(r => setTimeout(r, 0))
if (!predicate()) throw new Error('until: condition never held')
}
describe('tool-call scheduler: grouping and barriers', () => {
it('runs parallel-safe siblings concurrently (all start before any completes)', async () => {
const adapter = new MockAdapter([
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }, { id: 'c3', name: 'p', args: { id: '3' } }]),
textResponse('done'),
])
const ctx = await harness(adapter)
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
// All three start before any is released — proof of concurrency.
await until(() => gated.started.length === 3)
expect(gated.started).toEqual(['1', '2', '3'])
gated.release('1'); gated.release('2'); gated.release('3')
await waitForIdle(ctx, agent)
})
it('an exclusive call between two parallel-safe calls forms a barrier (3 groups)', async () => {
// read A (safe), write A (exclusive), read A (safe) → the write must not
// overlap either read. The exclusive tool records whether a read was still
// in flight when it ran.
const order: string[] = []
const adapter = new MockAdapter([
multiCall([
{ id: 'c1', name: 'r', args: { id: 'A1' } },
{ id: 'c2', name: 'w', args: { id: 'A2' } },
{ id: 'c3', name: 'r', args: { id: 'A3' } },
]),
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
name: 'r', description: 'read', parameters: { id: { type: 'string', required: true } },
isConcurrencySafe: () => true,
async execute(args) { order.push(`r-start-${args.id}`); order.push(`r-end-${args.id}`); return [{ type: 'text', text: 'r' }] },
}))
ctx.tools.register(defineTool({
name: 'w', description: 'write', parameters: { id: { type: 'string', required: true } },
async execute(args) { order.push(`w-${args.id}`); return [{ type: 'text', text: 'w' }] },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
// The write ran strictly between the two reads (barrier ordering).
expect(order).toEqual(['r-start-A1', 'r-end-A1', 'w-A2', 'r-start-A3', 'r-end-A3'])
})
})
describe('tool-call scheduler: model-order results despite out-of-order settlement', () => {
it('commits tool/result in model order even when a later call settles first', async () => {
const adapter = new MockAdapter([
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]),
textResponse('done'),
])
const ctx = await harness(adapter)
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 2)
// Release the SECOND call first; its result must NOT be committed until the
// first commits (the commit cursor holds it in a slot).
gated.release('2')
await new Promise(r => setTimeout(r, 5))
const beforeFirst = events(agent).filter(e => e.type === 'tool/result')
expect(beforeFirst).toEqual([])
gated.release('1')
await waitForIdle(ctx, agent)
const results = events(agent).filter(e => e.type === 'tool/result')
expect(results.map(e => e.data.callId)).toEqual([CallId('c1'), CallId('c2')])
})
it('derived history pairs calls in model order regardless of tool/call log interleaving', async () => {
const adapter = new MockAdapter([
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]),
textResponse('done'),
])
const ctx = await harness(adapter)
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 2)
gated.release('2'); gated.release('1')
await waitForIdle(ctx, agent)
// deriveMessages pairs the assistant tool-call blocks with tool-result
// blocks by callId — model order, independent of log interleaving.
const messages = agent.session.deriveMessages()
const toolResults = messages.flatMap(m => m.content.filter(b => b.type === 'tool-result'))
expect(toolResults.map(b => b.toolCallId)).toEqual([CallId('c1'), CallId('c2')])
})
})
describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => {
it('rejects invalid programmatic maxParallelToolCalls values before creating agents', async () => {
const ctx = await harness(new MockAdapter([]))
expect(() => ctx.agentLoop.create(AgentId('bad-zero'), { model: 'mock', maxParallelToolCalls: 0 }))
.toThrow('maxParallelToolCalls must be a positive integer')
expect(() => ctx.agentLoop.createAgent({
agentId: AgentId('bad-fractional'),
sessionId: SessionId('bad-fractional-session'),
agentOptions: { model: 'mock', maxParallelToolCalls: 1.5 },
})).toThrow('maxParallelToolCalls must be a positive integer')
})
it('starts at most the cap, replenishing as calls settle', async () => {
const adapter = new MockAdapter([
multiCall([1, 2, 3, 4].map(n => ({ id: `c${n}`, name: 'p', args: { id: String(n) } }))),
textResponse('done'),
])
const ctx = await harness(adapter)
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', maxParallelToolCalls: 2 })
agent.send([{ type: 'text', text: 'go' }])
// Only 2 start initially (the cap).
await until(() => gated.started.length === 2)
await new Promise(r => setTimeout(r, 5))
expect(gated.started).toEqual(['1', '2'])
// Releasing one starts the next in model order.
gated.release('1')
await until(() => gated.started.length === 3)
expect(gated.started).toEqual(['1', '2', '3'])
expect(events(agent)
.filter(e => e.type === 'tool/call' || e.type === 'tool/result')
.map(e => `${e.type}:${String(e.data.callId)}`)
.slice(0, 4))
.toEqual(['tool/call:c1', 'tool/call:c2', 'tool/result:c1', 'tool/call:c3'])
gated.release('2'); gated.release('3')
await until(() => gated.started.length === 4)
gated.release('4')
await waitForIdle(ctx, agent)
expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId))
.toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')])
})
it('maxParallelToolCalls: 1 is fully serial (no second start before the first settles)', async () => {
const adapter = new MockAdapter([
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]),
textResponse('done'),
])
const ctx = await harness(adapter)
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', maxParallelToolCalls: 1 })
agent.send([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 1)
await new Promise(r => setTimeout(r, 5))
expect(gated.started).toEqual(['1'])
gated.release('1')
await until(() => gated.started.length === 2)
gated.release('2')
await waitForIdle(ctx, agent)
})
})
describe('tool-call scheduler: ordered middleware and additionalContext', () => {
it('tools/pre-execute and tools/post-execute observe model call order', async () => {
const adapter = new MockAdapter([
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }, { id: 'c3', name: 'p', args: { id: '3' } }]),
textResponse('done'),
])
const ctx = await harness(adapter)
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
const pre: string[] = []
const post: string[] = []
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => { pre.push(String(exec.callId)); return next() })
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => { post.push(String(exec.callId)); return next() })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 3)
// Settle in reverse; post-execute (ordered by the commit cursor) still fires
// in model order because post runs on the commit path, not on dispatch.
gated.release('3'); gated.release('2'); gated.release('1')
await waitForIdle(ctx, agent)
expect(pre).toEqual([CallId('c1'), CallId('c2'), CallId('c3')].map(String))
expect(post).toEqual([CallId('c1'), CallId('c2'), CallId('c3')].map(String))
})
it('injects additionalContext in model call order, not settlement order', async () => {
const adapter = new MockAdapter([
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]),
textResponse('done'),
])
const ctx = await harness(adapter)
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
ctx.on('tools/post-execute', async (exec, _result): Promise<PostToolDecision> =>
({ kind: 'accept', additionalContext: { content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 2)
gated.release('2'); gated.release('1')
await waitForIdle(ctx, agent)
const log = events(agent)
// Both tool/results precede both context/messages, and context is model-ordered.
const contextTexts = log.filter(e => e.type === 'context/message')
.map(e => (e.data.content[0] as { text: string }).text)
expect(contextTexts).toEqual(['ctx-c1', 'ctx-c2'])
const lastResult = log.findLastIndex(e => e.type === 'tool/result')
const firstContext = log.findIndex(e => e.type === 'context/message')
expect(lastResult).toBeLessThan(firstContext)
})
it('keeps pre-produced deny/error results ordered without dispatching those calls', async () => {
const adapter = new MockAdapter([
multiCall([
{ id: 'c1', name: 'p', args: { id: '1' } },
{ id: 'c2', name: 'p', args: { id: '2' } },
{ id: 'c3', name: 'p', args: { id: '3' } },
]),
textResponse('done'),
])
const ctx = await harness(adapter)
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
const post: string[] = []
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (exec.callId === CallId('c2')) return { kind: 'deny', reason: 'blocked by policy' }
if (exec.callId === CallId('c3')) throw new Error('pre exploded')
return next()
})
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => {
post.push(String(exec.callId))
return next()
})
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 1)
gated.release('1')
await waitForIdle(ctx, agent)
expect(gated.started).toEqual(['1'])
expect(post).toEqual(['c1', 'c2'])
const results = events(agent).filter(e => e.type === 'tool/result')
expect(results.map(e => e.data.callId)).toEqual([CallId('c1'), CallId('c2'), CallId('c3')])
expect((results[1]!.data.content[0] as { text: string }).text).toContain('blocked by policy')
expect((results[2]!.data.content[0] as { text: string }).text).toContain('pre exploded')
})
})
describe('tool-call scheduler: abort handling', () => {
it('starts no calls when the signal is already aborted before a parallel group', async () => {
const adapter = new MockAdapter([
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]),
textResponse('should never be requested'),
])
const ctx = await harness(adapter)
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'assistant/message') {
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('already aborted')
}
})
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(gated.started).toEqual([])
expect(events(agent).filter(e => e.type === 'tool/call')).toEqual([])
expect(events(agent).filter(e => e.type === 'tool/result')).toEqual([])
})
it('stops starting siblings when abort fires during ordered pre-execute', async () => {
const adapter = new MockAdapter([
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]),
textResponse('should never be requested'),
])
const ctx = await harness(adapter)
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (exec.callId === CallId('c1')) {
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('pre cancelled')
}
return next()
})
agent.send([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 1)
await new Promise(r => setTimeout(r, 5))
expect(gated.started).toEqual(['1'])
gated.release('1')
await waitForIdle(ctx, agent)
expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId))
.toEqual([CallId('c1')])
expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId))
.toEqual([CallId('c1')])
})
it('stops replenishing after abort, commits started results, and drops buffered additionalContext', async () => {
const adapter = new MockAdapter([
multiCall([1, 2, 3, 4].map(n => ({ id: `c${n}`, name: 'p', args: { id: String(n) } }))),
textResponse('should never be requested'),
])
const ctx = await harness(adapter)
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => ({
...await next(),
additionalContext: { content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', maxParallelToolCalls: 2 })
agent.send([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 2)
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('stop now')
gated.release('1')
gated.release('2')
await waitForIdle(ctx, agent)
expect(gated.started).toEqual(['1', '2'])
expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId))
.toEqual([CallId('c1'), CallId('c2')])
expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId))
.toEqual([CallId('c1'), CallId('c2')])
expect(events(agent).filter(e => e.type === 'context/message')).toEqual([])
})
it('does not run an exclusive barrier after a parallel group aborts', async () => {
const adapter = new MockAdapter([
multiCall([
{ id: 'c1', name: 'p', args: { id: '1' } },
{ id: 'c2', name: 'p', args: { id: '2' } },
{ id: 'c3', name: 'x', args: { id: '3' } },
]),
textResponse('should never be requested'),
])
const ctx = await harness(adapter)
const gated = gatedParallelTool('p')
const exclusive: string[] = []
ctx.tools.register(gated.tool)
ctx.tools.register(defineTool({
name: 'x',
description: 'exclusive',
parameters: { id: { type: 'string', required: true } },
async execute(args) { exclusive.push(args.id); return [{ type: 'text', text: 'x' }] },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', maxParallelToolCalls: 2 })
agent.send([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 2)
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('stop before barrier')
gated.release('1')
gated.release('2')
await waitForIdle(ctx, agent)
expect(exclusive).toEqual([])
expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId))
.toEqual([CallId('c1'), CallId('c2')])
})
})

View File

@@ -19,6 +19,7 @@ tools:
- `ctx.tools.get(name: string): ToolDefinition | undefined`
- `ctx.tools.schemas(): ToolSchema[]` Schemas of all registered tools (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
- `ctx.tools.execute(exec: ToolExecution): Promise<ToolExecutionResult>` Execute one tool call through the `tools/pre-execute``tools/execute``tools/post-execute` pipeline.
- `ctx.tools.executionMode(exec: ToolExecution): ToolExecutionMode` Classify how one call may be scheduled relative to its step-siblings — `{ kind: 'parallel' }` only when the registered tool's `isConcurrencySafe(exec.arguments)` returns `true`, else `{ kind: 'exclusive' }` (unknown tool, no declaration, non-`true`, or a thrown check). The agent-loop scheduler uses it to group calls; host-only, never model-visible.
### Injected services
@@ -35,8 +36,9 @@ tools:
### Key types
- `ToolDefinition``ToolSchema` + `execute(args, exec): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). It also carries an optional cooperative timeout budget `timeoutMs?: number` (ms) enforced by `@deepseek-ai/dsh-timeout-policy`, never sent to the model.
- `ToolDefinition``ToolSchema` + `execute(args, exec): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). It also carries an optional cooperative timeout budget `timeoutMs?: number` (ms) enforced by `@deepseek-ai/dsh-timeout-policy`, and an optional synchronous `isConcurrencySafe(args): boolean` concurrency classifier read by `executionMode` — both host-only scheduler metadata, never sent to the model.
- `ToolExecution` — one pending tool call: `{ callId, name, arguments, agent?, signal? }`.
- `ToolExecutionMode``{ kind: 'parallel' } | { kind: 'exclusive' }`, returned by `executionMode`. Object-tagged (not a bare boolean) so a future resource-grouping dimension (e.g. `{ kind: 'exclusive', group: 'session:...' }`) can extend a variant without a breaking change.
- `ToolExecutionResult` — outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). `additionalContext` (a `HookContext`) ferries any `tools/post-execute` context up to the loop, which buffers it and appends it as a `context/message` after all `tool/result`s in the step. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards it onto the `tool/result` session event for result-card rendering.
- `PreToolDecision``{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite (changing `arguments`) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when a deployment mounts it (`allowed-once` proceeds to dispatch; `rejected`/`cancelled`/`unavailable` deny with distinct reasons) and degrades to `deny` when none is mounted or the execution carries no agent.
- `PostToolDecision``{kind:'accept', content?, additionalContext?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContext?}` (turn it into an `isError` whose content is the corrective feedback). Output replacement is clean because `tool/result` is logged AFTER `execute()` returns.
@@ -83,6 +85,8 @@ See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, an
`defineTool` also validates an optional `timeoutMs` at definition time when present: it must be a positive finite number, or the helper throws — the budget is attached to the produced `ToolDefinition` (for `@deepseek-ai/dsh-timeout-policy`) and never reaches the model.
`defineTool` also accepts an optional `isConcurrencySafe(args): boolean` — the per-call concurrency classifier the agent-loop scheduler reads via `executionMode`. `args` is the typed `InferArgs<S>` shape. It is soft-validated exactly like the presenters: an arg mismatch yields `false` (the conservative exclusive default), never the hard `ToolArgsError`. Declaring `true` is a contract — the tool body must not mutate parent-owned async state (`exec.agent.session.append`, `agent.inject`) during `execute`; its only ordered outputs are the returned content, `meta`, error, and `additionalContext`. The one exception is a synchronous, side-effect-only commutative recorder (the `fs/observed` version recorder is the worked example); anything richer stays exclusive. Host-only, never model-visible.
### Structured-output schema subset
A separate vocabulary for callers that DEMAND a machine-readable value from an agent — the subagent seam's `SubagentStartRequest.outputSchema` (and, by extension, a workflow's `agent({ schema })`). Unlike `SchemaSpec` (the author-facing DSL for tool parameters), a `StructuredOutputSchema` is an object-rooted **raw JSON Schema subset** as data: it travels verbatim to the model as a forced tool's `parameters`, and the produced value is validated against it.
@@ -133,12 +137,16 @@ const bash = defineTool({
Under `mode: code` (or `both`) the registry turns the tool surface into a programming API, per the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): the model writes a TypeScript program (the body of an async function) and passes it to the ONE wire tool `run_code`; the program runs in `ctx.codeRuntime` (the [code-execution seam](../../code-runtime/README.md) — the shipped backend is a worker thread) with one async binding per registered tool (`await tools.bash({...})`), and ONLY what it prints or returns re-enters the model's context.
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of every registered tool except `run_code` (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is TOTAL: constructs outside the `defineTool` subset degrade to `unknown`, never throw.
- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized BEFORE dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and the logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order — the tool contract carries no concurrency-safety metadata yet), gated by `tools/pre-execute`/`tools/post-execute` like any native call (a deny reaches the program as a binding rejection), and logged as one `tool/code-dispatch` session event (log-only: `deriveMessages()` never surfaces it) with the deterministic sub-id `<parent>:code:<n>`. A failed sub-call REJECTS the program-side promise with the tool's error text — real code error handling, no bespoke envelope. A sub-call's `additionalContext` is deliberately DROPPED (no safe outlet mid-run without breaking tool-call/result adjacency; deferred until a real hook needs it through Code Mode).
- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized BEFORE dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and the logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order — Code Mode's in-program dispatch stays serial even though native sibling calls parallelize; lifting that is follow-up work for the Code Mode bridge), gated by `tools/pre-execute`/`tools/post-execute` like any native call (a deny reaches the program as a binding rejection), and logged as one `tool/code-dispatch` session event (log-only: `deriveMessages()` never surfaces it) with the deterministic sub-id `<parent>:code:<n>`. A failed sub-call REJECTS the program-side promise with the tool's error text — real code error handling, no bespoke envelope. A sub-call's `additionalContext` is deliberately DROPPED (no safe outlet mid-run without breaking tool-call/result adjacency; deferred until a real hook needs it through Code Mode).
- **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from.
The wire collapse is the registry's own contribution (`systemPrompt.tools()` is mode-aware), so the logged `request/header` records it for free — under `code`, the assembled tool list is exactly `[run_code]`, pinned by tests and the snapshot goldens. Try it: `pnpm run demo:code-mode` ([the coding-agent example's Code Mode overlay](../../../examples/coding-agent/README.md#code-mode)); `pnpm run demo:code-mode acp` serves the same mode over ACP instead of the REPL.
### Parallel execution
A `ToolDefinition` declares per-call concurrency safety via `isConcurrencySafe(args)`; the registry's `executionMode(exec)` turns that into `{ kind: 'parallel' | 'exclusive' }`. The agent loop groups a step's calls by mode — a run of consecutive parallel calls executes in a rolling pool (bounded by the agent's `maxParallelToolCalls`), an exclusive call runs alone as an ordering barrier. Only dispatch/body overlaps; `tools/pre-execute` and `tools/post-execute` still observe model call order, and `tool/result` events are appended in model order (see [`dsh-agent-loop`](../agent-loop/README.md)). The conservative first declarations: `web_search`, `web_fetch`, filesystem `read`, and `subagent` are parallel-safe; `write`/`edit`/`todo_write`/`bash`/`bash_output`/`bash_kill` stay exclusive. `run_code` stays exclusive and its in-program dispatch stays serial.
### What is NOT here (TODO)
- **Tool shapes review** — when real tools land (e.g. a concurrency-safety hint for parallel execution); phase 1 executes tool calls sequentially.
- **Parallel execution** — the loop currently iterates tool calls sequentially.
- **A `tools/execution-mode` waterfall** — `executionMode` is a plain method today; a Cordis seam letting hook/MCP/provider policy downgrade a tool's baseline decision is the future insertion point, not needed for the conservative first declaration set.
- **A bash read-only classifier** — `bash`/`bash_output`/`bash_kill` stay exclusive until the bash package can prove which commands are read-only; the loop never infers shell safety.

View File

@@ -136,11 +136,6 @@ declare module 'cordis' {
}
}
// TODO(review): revisit these shapes when the first real tools and
// sandbox/permission plugins land (e.g. a concurrency-safety hint for
// parallel execution — Claude Code partitions read-only tools; phase 1
// executes sequentially).
/**
* What a tool's `execute` returns. The bare {@link ContentBlock}`[]` form is the
* common case (model-facing content only); the object form additionally attaches
@@ -163,6 +158,31 @@ export interface ToolDefinition extends ToolSchema {
* cooperative implementation that can reach quiescence when the signal aborts.
*/
timeoutMs?: number
/**
* Optional synchronous, pure classification: may this call run concurrently
* with other tool calls in the same assistant step? The agent-loop scheduler
* calls it (via {@link ToolRegistry.executionMode}) to decide whether the call
* joins a parallel group or forms an exclusive barrier; a missing declaration,
* a thrown check, or any non-`true` return is treated as exclusive. Like
* `timeoutMs` it is host-only scheduler metadata — NEVER sent to the model,
* since `schemas()` whitelists only name/description/parameters.
*
* It may inspect the parsed `args` (`unknown` — a hand-rolled definition
* receives the raw parsed value; `defineTool` schema-validates first and
* returns `false` on invalid args, so an eventual `ToolArgsError` is produced
* only if the tool actually executes). The check performs no I/O and receives
* no live `Agent` or mutable `ToolExecution`.
*
* Declaring `true` is a contract: the tool body must NOT mutate the parent
* agent's session or other parent-owned async state during `execute` (no
* `exec.agent.session.append(...)`, no `agent.inject(...)`). Its only parent-
* step outputs are the returned content, `meta`, structured error, and
* `additionalContext` carried through the loop's ordered post-execute path.
* The narrow exception is a synchronous, side-effect-only recorder whose
* updates are commutative for concurrent calls by the same session (the
* `fs/observed` version recorder is the worked example).
*/
isConcurrencySafe?(args: unknown): boolean
/**
* Optional: how to present the PENDING state of one call in a UI, derived from
* the call's `args` (parsed arguments, `unknown` — the tool validates/narrows
@@ -209,6 +229,53 @@ export interface ToolExecution {
signal?: AbortSignal
}
/**
* How a single tool call may be scheduled relative to its siblings in one
* assistant step, as decided by {@link ToolRegistry.executionMode}. `parallel`
* calls may run concurrently within a rolling pool; an `exclusive` call runs
* alone and forms an ordering barrier. Object-tagged (rather than a bare
* boolean) so a future resource-grouping dimension can extend a variant — e.g.
* `{ kind: 'exclusive', group: 'session:...' }` — without a breaking change.
*/
export type ToolExecutionMode =
| { kind: 'parallel' }
| { kind: 'exclusive' }
/**
* Internal result of the scheduler-owned `tools/pre-execute` stage. Exported
* only so `dsh-agent-loop` can split ordered middleware from concurrent
* dispatch without exposing named staged service methods on `ctx.tools`.
* @internal
*/
export type ScheduledToolPreparation =
| { kind: 'dispatch' }
| { kind: 'post-result'; result: ToolExecutionResult }
| { kind: 'final-result'; result: ToolExecutionResult }
/**
* Internal scheduler view of the registry pipeline. `dsh-agent-loop` uses this
* symbol-keyed entry point to keep `tools/pre-execute` and `tools/post-execute`
* ordered while overlapping only `tools/execute` dispatch/body. Ordinary
* callers use {@link ToolRegistry.execute}; this symbol is not a plugin seam.
* @internal
*/
export interface ToolRegistryScheduler {
/** Run the ordered pre-execute gate and decide what stage follows. */
prepare(exec: ToolExecution): Promise<ScheduledToolPreparation>
/** Run only the around-dispatch/body stage. */
dispatch(exec: ToolExecution): Promise<ToolExecutionResult>
/** Run ordered post-execute finalization for a dispatch/pre result. */
finalize(exec: ToolExecution, result: ToolExecutionResult): Promise<ToolExecutionResult>
}
/**
* Symbol-keyed internal scheduler entry point on {@link ToolRegistry}. The
* generated service catalog deliberately skips computed members, so this does
* not create a named public staged API.
* @internal
*/
export const TOOL_REGISTRY_SCHEDULER: unique symbol = Symbol('@deepseek-ai/dsh-tools.scheduler')
/** Structured error metadata for a failed tool call (alongside the model-facing text). */
export interface ToolErrorInfo {
name: string
@@ -239,7 +306,6 @@ export interface ToolExecutionResult {
* text in `content` is always present; this is extra structure for code.
*/
error?: ToolErrorInfo
/**
/**
* Extra model-facing context a `tools/post-execute` listener attached for the
* NEXT request (Claude Code's PostToolUse `additionalContext`). It is NOT part
@@ -353,6 +419,13 @@ export class ToolRegistry extends Service {
mode: z.union(['native', 'code', 'both'] as const).default('native'),
})
/** Internal staged view consumed by `dsh-agent-loop`'s parallel scheduler. */
readonly [TOOL_REGISTRY_SCHEDULER]: ToolRegistryScheduler = {
prepare: exec => this.prepareScheduledExecution(exec),
dispatch: exec => this.dispatchScheduledExecution(exec),
finalize: (exec, result) => this.finalizeScheduledExecution(exec, result),
}
private store = new Map<string, ToolDefinition>()
private readonly mode: ToolPresentationMode
@@ -472,23 +545,47 @@ export class ToolRegistry extends Service {
}
/**
* Execute one tool call through the `tools/pre-execute` → `tools/execute`
* (around dispatch) → `tools/post-execute` pipeline. `pre-execute` is the gate
* (allow/deny), `tools/execute` wraps core dispatch (a timeout/retry/metrics
* seam), and `post-execute` is the inspect/transform seam; core dispatch sits
* as the base `next()` of the `tools/execute` waterfall. The whole thing is
* wrapped in one outer try/catch so a throwing listener (in any waterfall)
* becomes an `isError` result instead of failing the turn; the tool body ALSO
* keeps its own inner try/catch, so a thrown tool becomes an `isError` result
* that `tools/execute` and `post-execute` listeners can still inspect. If the
* tool is not registered, the result is an `isError` carrying a `UNKNOWN_TOOL`
* structured error. A thrown {@link HarnessError} surfaces its `{ name, code }`
* on the result.
* @param exec - the call to run (name, parsed arguments, caller agent, signal).
* @returns the final result after every waterfall; failures resolve as
* `isError` results, never rejections.
* Classify how one pending call may be scheduled relative to its siblings in
* the same assistant step. Looks up the registered tool and calls its
* `isConcurrencySafe(exec.arguments)` classifier. The default is exclusive:
* an unknown tool, a tool with no `isConcurrencySafe` declaration, a check
* that returns any non-`true` value, and a check that THROWS all resolve to
* `{ kind: 'exclusive' }` — only an explicit `true` yields `{ kind: 'parallel' }`.
*
* This is a plain method, not a cordis waterfall: the conservative first
* declaration set needs no policy-driven downgrade, and the method boundary
* leaves room to introduce a `tools/execution-mode` seam later if a real
* deployment needs hook, MCP, or provider policy to override a tool's baseline
* decision.
* @param exec - the call to classify (its `name` selects the tool, its parsed
* `arguments` feed the classifier). No I/O runs and `exec` is not mutated.
* @returns `{ kind: 'parallel' }` only when the registered tool's check
* returns `true`; `{ kind: 'exclusive' }` otherwise.
*/
async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
executionMode(exec: ToolExecution): ToolExecutionMode {
const tool = this.store.get(exec.name)
if (!tool?.isConcurrencySafe) return { kind: 'exclusive' }
try {
return tool.isConcurrencySafe(exec.arguments) ? { kind: 'parallel' } : { kind: 'exclusive' }
} catch {
// A thrown classifier is a tool-authoring bug, not a scheduling signal:
// fail closed to exclusive so a broken check can never widen concurrency.
return { kind: 'exclusive' }
}
}
/**
* Run the ordered `tools/pre-execute` gate for the agent-loop scheduler. This
* is an internal factoring point, not a plugin seam; ordinary callers use
* {@link execute}, which still performs the full sequential pipeline. A
* non-allow decision returns a result that still needs ordered post-execute
* finalization; a throwing pre listener returns a final error result.
* @param exec - the call to prepare.
* @returns whether the scheduler should dispatch the tool, post-process a
* pre-produced result, or use a final error result as-is.
* @internal
*/
private async prepareScheduledExecution(exec: ToolExecution): Promise<ScheduledToolPreparation> {
try {
// --- Gate: tools/pre-execute. An `ask` resolves through the approval
// seam (or degrades) to allow/deny before the shared deny path. ---
@@ -503,16 +600,27 @@ export class ToolRegistry extends Service {
content: [{ type: 'text', text: `Error: ${decision.reason}` }],
isError: true,
}
return await this.postExecute(exec, denied)
return { kind: 'post-result', result: denied }
}
return { kind: 'dispatch' }
} catch (error: unknown) {
return { kind: 'final-result', result: toolErrorResult(exec.callId, error) }
}
}
// --- Around-dispatch: tools/execute. The base `next` is the dispatch-
// with-normalization thunk — the tool body's own try/catch turns a throw
// into an isError result so a wrapper (and post-execute) can inspect it;
// an unknown tool routes through the same catch. A `tools/execute` listener
// (e.g. a timeout plugin) wraps this thunk: it may mutate `exec` before
// delegating and inspect the normalized result after. ---
const result = await this.ctx.waterfall(
/**
* Run only the concurrent dispatch/body stage for the agent-loop scheduler.
* The `tools/execute` around-dispatch waterfall wraps the normalized tool body
* here; ordered pre/post remain the scheduler's responsibility. Ordinary
* callers use {@link execute}.
* @param exec - the already-prepared call to dispatch.
* @returns the raw dispatch result before `tools/post-execute`; failures are
* normalized into `isError` results.
* @internal
*/
private async dispatchScheduledExecution(exec: ToolExecution): Promise<ToolExecutionResult> {
try {
return await this.ctx.waterfall(
this, 'tools/execute', exec,
async (): Promise<ToolExecutionResult> => {
try {
@@ -530,11 +638,7 @@ export class ToolRegistry extends Service {
}
},
)
return await this.postExecute(exec, result)
} catch (error: unknown) {
// Outer backstop: a throwing pre/post-execute listener (or the waterfall
// machinery) becomes an isError result, never a turn failure.
return toolErrorResult(exec.callId, error)
}
}
@@ -577,6 +681,50 @@ export class ToolRegistry extends Service {
}
}
/**
* Run the ordered `tools/post-execute` finalization stage for the agent-loop
* scheduler. This is an internal factoring point paired with
* {@link prepareScheduledExecution} and {@link dispatchScheduledExecution};
* ordinary callers use {@link execute}.
* @param exec - the call whose dispatch result is being finalized.
* @param result - the dispatch result or pre-produced denial result.
* @returns the final tool result after post-execute; throwing listeners are
* normalized into `isError` results.
* @internal
*/
private async finalizeScheduledExecution(exec: ToolExecution, result: ToolExecutionResult): Promise<ToolExecutionResult> {
try {
return await this.postExecute(exec, result)
} catch (error: unknown) {
return toolErrorResult(exec.callId, error)
}
}
/**
* Execute one tool call through the `tools/pre-execute` → `tools/execute`
* (around dispatch) → `tools/post-execute` pipeline. `pre-execute` is the gate
* (allow/deny), `tools/execute` wraps core dispatch (a timeout/retry/metrics
* seam), and `post-execute` is the inspect/transform seam; core dispatch sits
* as the base `next()` of the `tools/execute` waterfall. The staged scheduler
* helpers above are internal factoring points for the agent loop; this public
* one-call API remains the sequential composition direct callers use. Failures
* in any stage resolve as `isError` results instead of failing the turn. If the
* tool is not registered, the result is an `isError` carrying a `UNKNOWN_TOOL`
* structured error. A thrown {@link HarnessError} surfaces its `{ name, code }`
* on the result.
* @param exec - the call to run (name, parsed arguments, caller agent, signal).
* @returns the final result after every waterfall; failures resolve as
* `isError` results, never rejections.
*/
async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
const prepared = await this.prepareScheduledExecution(exec)
if (prepared.kind === 'final-result') return prepared.result
const result = prepared.kind === 'post-result'
? prepared.result
: await this.dispatchScheduledExecution(exec)
return await this.finalizeScheduledExecution(exec, result)
}
/**
* Run the `tools/post-execute` waterfall over a dispatched `result` and apply
* its {@link PostToolDecision}: `accept` keeps the call successful (replacing

View File

@@ -302,6 +302,16 @@ export interface DefineToolOptions<S extends SchemaSpec> {
* is never sent to the model.
*/
timeoutMs?: number
/**
* Optional synchronous concurrency-safety classifier (see
* {@link ToolDefinition.isConcurrencySafe}). `args` is the typed, schema-
* validated shape — zero casts. Validated SOFTLY, mirroring the presenters:
* on an arg mismatch the produced classifier returns `false` (the conservative
* exclusive default) instead of the hard {@link ToolArgsError} the execute path
* raises, since replay/scheduling may feed older-schema args. Host-only — never
* sent to the model.
*/
isConcurrencySafe?(args: InferArgs<S>): boolean
/**
* Tool execution function. `args` is typed as {@link InferArgs<S>} — zero
* casts needed. Returns either a bare {@link ContentBlock}`[]` (model-facing
@@ -357,9 +367,9 @@ export interface DefineToolOptions<S extends SchemaSpec> {
* execute body, and optional presenters.
* @returns a registry-ready {@link ToolDefinition}: its `execute` validates the
* raw args first (throwing {@link ToolArgsError} on mismatch, which the
* registry turns into an isError result), and its presenters validate softly
* (returning undefined on mismatch, since replay may feed them older-schema
* args).
* registry turns into an isError result), and its presenters and
* `isConcurrencySafe` classifier validate softly (returning undefined/`false`
* on mismatch, since replay/scheduling may feed them older-schema args).
*/
export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>): ToolDefinition {
// Object-literal execute methods don't use `this`; the reference is safe.
@@ -369,6 +379,8 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
const userPresentCall = options.presentCall
// eslint-disable-next-line @typescript-eslint/unbound-method
const userPresentResult = options.presentResult
// eslint-disable-next-line @typescript-eslint/unbound-method
const userIsConcurrencySafe = options.isConcurrencySafe
if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`)
}
@@ -403,5 +415,15 @@ export function defineTool<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.
if (userIsConcurrencySafe) {
tool.isConcurrencySafe = (args: unknown): boolean => {
if (validateArgs(options.parameters, args).length > 0) return false
return userIsConcurrencySafe(args as InferArgs<S>)
}
}
return tool
}

View File

@@ -0,0 +1,133 @@
/**
* Per-call concurrency classification: `ToolDefinition.isConcurrencySafe`,
* `defineTool()`'s soft-validated forwarding of it, and the registry's
* `executionMode(exec)` decision. Also proves the classifier never leaks into
* the model-facing `schemas()` projection.
*/
import { describe, expect, expectTypeOf, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, {
defineTool,
type ToolDefinition,
type ToolExecution,
type ToolExecutionMode,
} from '@deepseek-ai/dsh-tools'
async function setup() {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
return ctx
}
function exec(name: string, args: unknown): ToolExecution {
return { callId: CallId('c1'), name, arguments: args }
}
describe('ToolRegistry.executionMode', () => {
it('returns parallel only when the registered tool declares isConcurrencySafe → true', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
name: 'safe',
description: 'parallel-safe',
parameters: {},
isConcurrencySafe: () => true,
async execute() { return [] },
}))
expect(ctx.tools.executionMode(exec('safe', {}))).toEqual({ kind: 'parallel' })
})
it('defaults to exclusive for a tool with no isConcurrencySafe declaration', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
name: 'plain',
description: 'no declaration',
parameters: {},
async execute() { return [] },
}))
expect(ctx.tools.executionMode(exec('plain', {}))).toEqual({ kind: 'exclusive' })
})
it('returns exclusive for an unknown tool', async () => {
const ctx = await setup()
expect(ctx.tools.executionMode(exec('nonexistent', {}))).toEqual({ kind: 'exclusive' })
})
it('returns exclusive when the classifier returns false for these args', async () => {
const ctx = await setup()
// Input-sensitive: safe to read, unsafe to write — the same tool differs by args.
ctx.tools.register(defineTool({
name: 'rw',
description: 'read or write',
parameters: { mode: { type: 'string', required: true } },
isConcurrencySafe: args => args.mode === 'read',
async execute() { return [] },
}))
expect(ctx.tools.executionMode(exec('rw', { mode: 'read' }))).toEqual({ kind: 'parallel' })
expect(ctx.tools.executionMode(exec('rw', { mode: 'write' }))).toEqual({ kind: 'exclusive' })
})
it('a defineTool classifier soft-fails to exclusive on invalid args (no ToolArgsError)', async () => {
const ctx = await setup()
// The typed classifier would read args.mode, but the required arg is missing:
// soft validation returns false (exclusive) rather than throwing, matching the
// presenter pattern. Executing the same bad args WOULD raise ToolArgsError.
ctx.tools.register(defineTool({
name: 'needs-mode',
description: 'requires mode',
parameters: { mode: { type: 'string', required: true } },
isConcurrencySafe: () => true,
async execute() { return [] },
}))
expect(ctx.tools.executionMode(exec('needs-mode', {}))).toEqual({ kind: 'exclusive' })
})
it('a thrown classifier fails closed to exclusive (raw definition)', async () => {
const ctx = await setup()
// A hand-rolled ToolDefinition (not via defineTool) whose check throws.
const raw: ToolDefinition = {
name: 'thrower',
description: 'classifier throws',
parameters: { type: 'object', properties: {} },
isConcurrencySafe() { throw new Error('boom') },
async execute() { return [] },
}
ctx.tools.register(raw)
expect(ctx.tools.executionMode(exec('thrower', {}))).toEqual({ kind: 'exclusive' })
})
it('a raw definition (no defineTool) receives the raw parsed value', async () => {
const ctx = await setup()
let seen: unknown
ctx.tools.register({
name: 'raw-safe',
description: 'raw',
parameters: { type: 'object', properties: {} },
isConcurrencySafe(args) { seen = args; return true },
async execute() { return [] },
})
expect(ctx.tools.executionMode(exec('raw-safe', { anything: 1 }))).toEqual({ kind: 'parallel' })
expect(seen).toEqual({ anything: 1 })
})
it('isConcurrencySafe never reaches the model-facing schemas() projection', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
name: 'safe',
description: 'parallel-safe',
parameters: { x: { type: 'string', required: true } },
isConcurrencySafe: () => true,
async execute() { return [] },
}))
const schema = ctx.tools.schemas()[0] as unknown as Record<string, unknown>
expect(Object.keys(schema).sort()).toEqual(['description', 'name', 'parameters'])
expect(schema.isConcurrencySafe).toBeUndefined()
})
it('ToolExecutionMode is the object-tagged union', () => {
expectTypeOf<ToolExecutionMode>().toEqualTypeOf<{ kind: 'parallel' } | { kind: 'exclusive' }>()
})
})