fix(agent-loop): validate parallel cap before logging calls

This commit is contained in:
Dudu-0223
2026-07-13 19:14:36 +08:00
parent ca2dd34291
commit 8c8e5fdd24
4 changed files with 29 additions and 5 deletions

View File

@@ -19,7 +19,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 { executeToolCalls, resolveMaxParallelToolCalls } from './tool-calls.ts'
import type { ReactLoopAgent } from './agent.ts'
import type { Inbox } from './inbox.ts'
@@ -858,6 +858,11 @@ async function runStep(
//
// sourceEventSeqs records the assistant/chunk provenance, but is omitted when
// no chunks streamed (the surface invariant rejects an empty sourceEventSeqs).
const toolCalls = message.content.filter(block => block.type === 'tool-call')
const scheduling = toolCalls.length > 0
? { maxParallel: resolveMaxParallelToolCalls(agent.options.maxParallelToolCalls) }
: undefined
if (message.content.length > 0 || assembler.usage) {
session.append(
'assistant/message',
@@ -874,13 +879,14 @@ async function runStep(
// 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 = await executeToolCalls(ctx, agent, turn, step, toolCalls, signal)
const pendingContext = scheduling !== undefined
? await executeToolCalls(ctx, agent, turn, step, toolCalls, signal, scheduling.maxParallel)
: []
// Append buffered post-execute context AFTER every tool/result, preserving
// tool-call/result adjacency across the whole batch. inject() appends into the

View File

@@ -60,6 +60,7 @@ interface Slot {
* @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).
* @param maxParallel - the already-validated cap snapshot for parallel groups.
* @returns the per-step `additionalContext` buffer in model call order.
*/
export async function executeToolCalls(
@@ -69,9 +70,9 @@ export async function executeToolCalls(
step: number,
toolCalls: ToolCallBlock[],
signal: AbortSignal,
maxParallel: number,
): Promise<HookContext[]> {
const { session, options } = agent
const maxParallel = options.maxParallelToolCalls ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS
const { session } = agent
// Plan: parse each call's raw JSON arguments exactly once, and build one
// distinct ToolExecution per call so a `tools/execute` wrapper that mutates
@@ -108,6 +109,20 @@ export async function executeToolCalls(
return pendingContext
}
/**
* Resolve and validate the per-step parallel dispatch cap before the assistant
* tool-call message is logged, so invalid mutable options fail without leaving
* dangling model-visible tool calls in the session transcript.
*
* @param maxParallelToolCalls - the live agent option value.
* @returns the positive integer cap to use for this step.
*/
export function resolveMaxParallelToolCalls(maxParallelToolCalls: number | undefined): number {
const maxParallel = maxParallelToolCalls ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS
assertMaxParallelToolCalls(maxParallel)
return maxParallel
}
/** Parse a model-produced raw arguments string, falling back to the raw string on invalid JSON (empty ⇒ `{}`). */
function parseArguments(raw: string): unknown {
try {

View File

@@ -218,6 +218,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
expect(gated.started).toEqual([])
expect(adapter.requests).toHaveLength(1)
expect(events(agent).some(e => e.type === 'assistant/message')).toBe(false)
expect(events(agent).filter(e => e.type === 'tool/call' || e.type === 'tool/result')).toEqual([])
const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('error')