mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(agent-loop): reclassify pending tool calls
This commit is contained in:
@@ -34,8 +34,8 @@ sequenceDiagram
|
||||
Session-->>SDK: <code>session/event</code> <code>assistant/chunk</code>*
|
||||
Driver->>Hooks: <code>agent/step-result</code> waterfall
|
||||
Driver->>Session: <code>assistant/message</code>
|
||||
Driver->>Tools: group calls by executionMode
|
||||
loop bounded rolling pool until group drains
|
||||
Driver->>Tools: classify next call by executionMode
|
||||
loop bounded rolling pool with reclassification before replenishing
|
||||
opt capacity available for an unstarted call
|
||||
Driver->>Session: <code>tool/call</code> pending audit
|
||||
Driver->>Tools: ordered pre / pooled dispatch
|
||||
|
||||
@@ -82,8 +82,8 @@ forever:
|
||||
'assistant/chunk'
|
||||
agent/step-result
|
||||
'assistant/message'
|
||||
schedule tool calls by ctx.tools.executionMode (exclusive = barrier;
|
||||
consecutive parallel-safe = one rolling-pool group, <= maxParallelToolCalls in flight):
|
||||
schedule tool calls by ctx.tools.executionMode (reclassify before pool replenishment;
|
||||
exclusive = barrier; parallel-safe = rolling pool, <= maxParallelToolCalls in flight):
|
||||
while the bounded pool has work:
|
||||
capacity available -> 'tool/call' -> tools/pre-execute -> monotonic guards -> tools/execute
|
||||
next model-order slot ready -> tools/post-execute -> 'tool/result'
|
||||
|
||||
@@ -24,7 +24,7 @@ A tagged mode, rather than a public boolean scheduler API, leaves room for a fut
|
||||
|
||||
## Scheduling and ordering
|
||||
|
||||
The loop waits for the complete assistant message, parses every call once, creates a distinct `ToolExecution` for each call, and scans them in model order. Consecutive parallel calls form one group; every exclusive call forms a singleton group and an ordering barrier. Groups execute sequentially.
|
||||
The loop waits for the complete assistant message, parses every call once, creates a distinct `ToolExecution` for each call, and scans them in model order. Consecutive parallel calls form one group; every exclusive call forms a singleton group and an ordering barrier. Groups execute sequentially. Classification is lazy: the scheduler resolves the next call after each barrier and reclassifies every later call before replenishing a parallel pool. If a registry mutation makes that call exclusive, the current pool drains before the call starts as the next barrier.
|
||||
|
||||
For example:
|
||||
|
||||
@@ -64,7 +64,7 @@ Filesystem read relies on a narrow recorder exception: its synchronous observati
|
||||
|
||||
## Verification
|
||||
|
||||
Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, the rolling cap, distinct execution objects, middleware order, ordered results and context, and abort draining. First-party tests pin each parallel declaration.
|
||||
Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, live reclassification after registry replacement, the rolling cap, distinct execution objects, middleware order, ordered results and context, and abort draining. First-party tests pin each parallel declaration.
|
||||
|
||||
Snapshot coverage pins the visible multi-call transcript: pending calls may overlap while completed results remain model-ordered. Code Mode coverage pins its serial boundary. No provider-backed e2e is required because scheduling is deterministic loop behavior.
|
||||
|
||||
@@ -98,4 +98,4 @@ Ordered commits may hold a fast result behind a slow earlier sibling. This prese
|
||||
|
||||
Concurrent external calls can compete for quota or process capacity. Providers own their capacity controls; the loop cap only limits calls from one agent step.
|
||||
|
||||
Tool registration is a scheduling boundary. The scheduler currently plans all groups before dispatch, so an earlier registry mutation can make a later classification stale. Binding dispatch to the classified definition or reclassifying after exclusive barriers remains a named correctness gap.
|
||||
Tool registration is a scheduling boundary. Registry mutations affect not-yet-started calls because the scheduler reclassifies after each barrier and before every pool replenishment. Already-started calls retain the scheduling decision under which they entered the pool.
|
||||
|
||||
@@ -53,7 +53,7 @@ The driver owns one agent for its lifetime. It records turn, step, request, stre
|
||||
|
||||
Plugin failure ends the current turn, not the loop. Cancellation clears pending work and aborts the current step without leaking to the next prompt. Terminal continuation stops remain authoritative through turn close and durability flush.
|
||||
|
||||
Within a step, consecutive parallel-safe calls form a rolling-pool group; exclusive calls are ordering barriers. Only dispatch/body overlaps. Pre/post policy, durable results, and additional context remain in model order. Abort stops replenishment, drains started calls, drops their buffered context, and ends the turn through the normal abort path.
|
||||
Within a step, consecutive parallel-safe calls form a rolling-pool group; exclusive calls are ordering barriers. The scheduler reclassifies pending calls after each barrier and before replenishing the pool, so a live tool-registry change applies before the next call starts. Only dispatch/body overlaps. Pre/post policy, durable results, and additional context remain in model order. Abort stops replenishment, drains started calls, drops their buffered context, and ends the turn through the normal abort path.
|
||||
|
||||
### What belongs to plugins
|
||||
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* The per-step tool-call scheduler (`tool-calls.ts`): grouping by
|
||||
* The per-step tool-call scheduler (`tool-calls.ts`): live classification 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.
|
||||
* `tool/result` commit despite out-of-order settlement, registry-change
|
||||
* reclassification, 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).
|
||||
@@ -63,15 +64,15 @@ function multiCall(calls: { id: string; name: string; args: object }[]): StreamC
|
||||
return chunks
|
||||
}
|
||||
|
||||
/** A parallel-safe tool whose calls block until the test releases them by callId. */
|
||||
function gatedParallelTool(name: string) {
|
||||
/** A tool whose calls block until the test releases them by callId. */
|
||||
function gatedTool(name: string, parallel: boolean) {
|
||||
const gates = new Map<string, () => void>()
|
||||
const started: string[] = []
|
||||
const tool = defineTool({
|
||||
name,
|
||||
description: `gated ${name}`,
|
||||
parameters: { id: { type: 'string', required: true } },
|
||||
isConcurrencySafe: () => true,
|
||||
...parallel ? { isConcurrencySafe: () => true } : {},
|
||||
async execute(args) {
|
||||
started.push(args.id)
|
||||
await new Promise<void>((resolve) => { gates.set(args.id, resolve) })
|
||||
@@ -87,6 +88,16 @@ function gatedParallelTool(name: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** A parallel-safe gated tool. */
|
||||
function gatedParallelTool(name: string) {
|
||||
return gatedTool(name, true)
|
||||
}
|
||||
|
||||
/** An exclusive gated tool. */
|
||||
function gatedExclusiveTool(name: string) {
|
||||
return gatedTool(name, false)
|
||||
}
|
||||
|
||||
/** 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))
|
||||
@@ -142,6 +153,81 @@ describe('tool-call scheduler: grouping and barriers', () => {
|
||||
// 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'])
|
||||
})
|
||||
|
||||
it('reclassifies pending calls after an exclusive barrier replaces their tool', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
multiCall([
|
||||
{ id: 'c1', name: 'replace', args: { id: '0' } },
|
||||
{ id: 'c2', name: 'x', args: { id: '1' } },
|
||||
{ id: 'c3', name: 'x', args: { id: '2' } },
|
||||
]),
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const replacement = gatedExclusiveTool('x')
|
||||
const disposeSafe = ctx.tools.register(defineTool({
|
||||
name: 'x',
|
||||
description: 'initially safe',
|
||||
parameters: { id: { type: 'string', required: true } },
|
||||
isConcurrencySafe: () => true,
|
||||
async execute(args) { return [{ type: 'text', text: `old-${args.id}` }] },
|
||||
}))
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'replace',
|
||||
description: 'replace x',
|
||||
parameters: { id: { type: 'string', required: true } },
|
||||
async execute() {
|
||||
disposeSafe()
|
||||
ctx.tools.register(replacement.tool)
|
||||
return [{ type: 'text', text: 'replaced' }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await until(() => replacement.started.length === 1)
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
expect(replacement.started).toEqual(['1'])
|
||||
replacement.release('1')
|
||||
await until(() => replacement.started.length === 2)
|
||||
expect(replacement.started).toEqual(['1', '2'])
|
||||
replacement.release('2')
|
||||
await waitForIdle(ctx, agent)
|
||||
})
|
||||
|
||||
it('stops replenishing when a result observer makes the next call exclusive', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
multiCall([
|
||||
{ id: 'c1', name: 'x', args: { id: '1' } },
|
||||
{ id: 'c2', name: 'x', args: { id: '2' } },
|
||||
{ id: 'c3', name: 'x', args: { id: '3' } },
|
||||
]),
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter, 2)
|
||||
const initial = gatedParallelTool('x')
|
||||
const replacement = gatedExclusiveTool('x')
|
||||
const disposeInitial = ctx.tools.register(initial.tool)
|
||||
ctx.on('tools/result', (exec) => {
|
||||
if (exec.callId !== CallId('c1')) return
|
||||
disposeInitial()
|
||||
ctx.tools.register(replacement.tool)
|
||||
})
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await until(() => initial.started.length === 2)
|
||||
initial.release('1')
|
||||
await until(() => events(agent).some(event =>
|
||||
event.type === 'tool/result' && event.data.callId === CallId('c1')))
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
expect(replacement.started).toEqual([])
|
||||
initial.release('2')
|
||||
await until(() => replacement.started.length === 1)
|
||||
expect(replacement.started).toEqual(['3'])
|
||||
replacement.release('3')
|
||||
await waitForIdle(ctx, agent)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-call scheduler: model-order results despite out-of-order settlement', () => {
|
||||
|
||||
@@ -822,8 +822,8 @@ function renderLifecycle(): string {
|
||||
` Session-->>SDK: ${mermaidCode('session/event')} ${mermaidCode('assistant/chunk')}*`,
|
||||
` Driver->>Hooks: ${mermaidCode('agent/step-result')} waterfall`,
|
||||
` Driver->>Session: ${mermaidCode('assistant/message')}`,
|
||||
' Driver->>Tools: group calls by executionMode',
|
||||
' loop bounded rolling pool until group drains',
|
||||
' Driver->>Tools: classify next call by executionMode',
|
||||
' loop bounded rolling pool with reclassification before replenishing',
|
||||
' opt capacity available for an unstarted call',
|
||||
` Driver->>Session: ${mermaidCode('tool/call')} pending audit`,
|
||||
' Driver->>Tools: ordered pre / pooled dispatch',
|
||||
|
||||
Reference in New Issue
Block a user