fix(agent-loop): reclassify pending tool calls

This commit is contained in:
Tianyi Cui
2026-07-17 21:55:10 +08:00
parent 17fe9e5b1e
commit 62f6251bf9
7 files changed
+140 -72

No files matched your search

+37 -55
View File
@@ -1,11 +1,12 @@
/**
* 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 runs every group through the same rolling pool
* bounded by the agent-loop's `maxParallelToolCalls` config — an exclusive
* group is a pool of one.
* arguments once, classifies pending calls via `ctx.tools.executionMode`, and
* runs ordered groups through a rolling pool bounded by the agent-loop's
* `maxParallelToolCalls` config. Exclusive calls are singleton barriers. A
* parallel group reclassifies each later call before it starts, so registry
* changes during an earlier barrier or ordered result commit take effect before
* the pool replenishes.
*
* The session log stays the source of truth and is reconstructable regardless
* of dispatch timing: each STARTED call appends its own `tool/call` before its
@@ -23,7 +24,7 @@ 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 ToolExecutionInput, type ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import { TOOL_REGISTRY_SCHEDULER, type ToolExecution, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { ReactLoopAgent } from './agent.ts'
/** One tool call after argument parsing, ready to schedule. */
@@ -89,19 +90,17 @@ export async function executeToolCalls(
},
}))
// 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)
// Every group runs through the same rolling pool: an exclusive call is a
// singleton group (pool of one, a barrier), a parallel-safe run is one group
// bounded by the cap. `groupByMode` already classified each call, so the loop
// does not re-query `executionMode` here.
const pendingContext: HookContext[] = []
for (const group of groups) {
await runGroup(ctx, session, turn, step, group, signal, maxParallel, pendingContext)
let next = 0
while (next < planned.length) {
// Classify the next group only after the previous one has fully committed.
// A registry mutation in an exclusive call or result observer therefore
// changes how every not-yet-started call is scheduled.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
const first = planned[next]!
const mode = ctx.tools.executionMode(first.exec).kind
const group = mode === 'parallel' ? planned.slice(next) : [first]
next += await runGroup(ctx, session, turn, step, group, mode, signal, maxParallel, pendingContext)
}
return pendingContext
}
@@ -115,41 +114,15 @@ function parseArguments(raw: string): unknown {
}
}
/**
* Group planned calls into ordered runs: each exclusive call is a singleton
* group; consecutive parallel-safe calls coalesce into one group. `executionMode`
* is the sole classification point — the caller runs every group through the
* rolling pool without re-querying it. The read is 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 rolling-pool path for one ordered group. A singleton exclusive group runs
* as a pool of one (a barrier); a parallel-safe run starts calls in model order
* up to `maxParallel`, 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.
* as a pool of one (a barrier). A parallel-safe run starts calls in model order
* up to `maxParallel`; before each later call starts, the scheduler reclassifies
* it against the live registry. An exclusive result stops replenishment, drains
* the current run, and remains for the caller's next singleton group. Settled
* dispatches land in model-order slots; a commit cursor appends `tool/result`
* (and collects `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
@@ -161,10 +134,11 @@ async function runGroup(
turn: number,
step: number,
group: PlannedCall[],
mode: ToolExecutionMode['kind'],
signal: AbortSignal,
maxParallel: number,
pendingContext: HookContext[],
): Promise<void> {
): Promise<number> {
/* 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)
@@ -227,6 +201,13 @@ async function runGroup(
const fillPool = async (): Promise<void> => {
while (!aborted && nextToStart < group.length && inFlight.size < maxParallel) {
// The caller classified the first item immediately before entering this
// group. Re-read every later item after ordered commits so a live registry
// change can turn it into the next barrier.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
const nextCall = group[nextToStart]!
if (nextToStart > 0 && mode === 'parallel'
&& ctx.tools.executionMode(nextCall.exec).kind !== 'parallel') break
await startCall(nextToStart)
nextToStart++
await commitReady()
@@ -259,10 +240,11 @@ async function runGroup(
/* 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 */
// A defensive check that every started call committed before this group
// returns; a reclassified barrier may leave the rest of `group` unstarted.
/* v8 ignore next -- unreachable: a non-aborted group commits every started call */
if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls')
return started
}
/** Append the `tool/call` audit event for one started call; returns its seq (the tool/result's provenance). */