fix(agent-loop): reclassify pending tool calls
This commit is contained in:
7 files changed
+140
-72
No files matched your search
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user