fix(agent-loop): validate parallel cap before logging calls
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user