mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 14:38:23 -10:00
fix(core): count deduped provider call ids once in the subagent loop guard (#9450)
Request counts and result evidence were fed from different populations: the consecutive-identical request counter counted every streamed function call (pre-dedup), while results land once per deduped executed call. A provider-emitted duplicate call id — the exact pathology dedupeToolCallsById exists for — left the request counter one ahead of the result evidence, so the result-aware exemption failed safe and halted a fully productive task_list poller. The subagent stream loop now feeds the loop guard one event per call id per attempt (a per-round Set mirroring dedupeToolCallsById; id-less calls are never deduped), keeping request counts and result evidence on the same population. Cleared on retry alongside the accumulated function calls.
This commit is contained in:
1 parent
61dd8c5535
commit
1ac2a8104e
2 files changed
+117
No files matched your search
@@ -956,6 +956,13 @@ export class AgentCore {
|
||||
} as AgentRoundEvent);
|
||||
|
||||
const functionCalls: FunctionCall[] = [];
|
||||
// callIds already streamed to the loop guard this attempt. Mirrors
|
||||
// dedupeToolCallsById (which collapses execution to one call per
|
||||
// id): a provider can emit the same call id twice in one response,
|
||||
// and counting both emissions would leave the request counters one
|
||||
// ahead of the executed result evidence (one recordToolResult per
|
||||
// executed call), fail-safe-halting a productive stateful poller.
|
||||
const loopGuardStreamedCallIds = new Set<string>();
|
||||
let roundText = '';
|
||||
let roundThoughtText = '';
|
||||
let lastUsage: GenerateContentResponseUsageMetadata | undefined =
|
||||
@@ -986,6 +993,7 @@ export class AgentCore {
|
||||
stickyMaxOutputTokens = streamEvent.maxOutputTokensEscalated;
|
||||
}
|
||||
functionCalls.length = 0;
|
||||
loopGuardStreamedCallIds.clear();
|
||||
roundText = '';
|
||||
roundThoughtText = '';
|
||||
lastUsage = undefined;
|
||||
@@ -1067,6 +1075,17 @@ export class AgentCore {
|
||||
|
||||
for (const fc of chunkFunctionCalls) {
|
||||
const toolName = String(fc.name);
|
||||
// Provider-duplicate emissions of an already-streamed call id
|
||||
// execute once (dedupeToolCallsById collapses them), so feed
|
||||
// the loop guard once — request counts and result evidence
|
||||
// must stay the same population. Id-less calls are never
|
||||
// deduped, mirroring dedupeToolCallsById.
|
||||
if (fc.id) {
|
||||
if (loopGuardStreamedCallIds.has(fc.id)) {
|
||||
continue;
|
||||
}
|
||||
loopGuardStreamedCallIds.add(fc.id);
|
||||
}
|
||||
if (
|
||||
checkSubagentLoop({
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
|
||||
@@ -2555,6 +2555,104 @@ describe('subagent.ts', () => {
|
||||
expect(finishEvents[0].loopType).toBe('global_tool_call_duplicate');
|
||||
});
|
||||
|
||||
it('counts a provider-duplicate call id once so result evidence stays in sync (issue #9450)', async () => {
|
||||
// A provider can stream the SAME call id twice in one response — the
|
||||
// exact pathology dedupeToolCallsById exists for. Execution collapses
|
||||
// the pair to one call (one recorded result), so the loop guard must
|
||||
// also count one request; otherwise the request counter runs one
|
||||
// ahead of the result evidence and the result-aware exemption
|
||||
// fails safe, halting a fully productive poller.
|
||||
const taskListToolDef: FunctionDeclaration = {
|
||||
name: 'task_list',
|
||||
description: 'Lists team tasks',
|
||||
parameters: { type: Type.OBJECT, properties: {} },
|
||||
};
|
||||
|
||||
const { config } = await createMockConfig({
|
||||
getFunctionDeclarationsFiltered: vi
|
||||
.fn()
|
||||
.mockReturnValue([taskListToolDef]),
|
||||
getTool: vi.fn().mockReturnValue(undefined),
|
||||
});
|
||||
const toolConfig: ToolConfig = { tools: ['task_list'] };
|
||||
const taskListArgs = {
|
||||
status: 'in_progress',
|
||||
owner: 'peer-a',
|
||||
blockedBy: '',
|
||||
};
|
||||
|
||||
// Round 1 emits the same call id twice (the provider duplicate); the
|
||||
// remaining rounds emit one call each, the board changing every time.
|
||||
const duplicateId = 'dup_call_0';
|
||||
mockSendMessageStream.mockImplementation(
|
||||
createMockStream([
|
||||
[
|
||||
{ id: duplicateId, name: 'task_list', args: taskListArgs },
|
||||
{ id: duplicateId, name: 'task_list', args: taskListArgs },
|
||||
],
|
||||
...Array.from({ length: 5 }, (_, index) => [
|
||||
{
|
||||
id: `poll_${index + 1}`,
|
||||
name: 'task_list',
|
||||
args: taskListArgs,
|
||||
},
|
||||
]),
|
||||
'stop',
|
||||
]),
|
||||
);
|
||||
|
||||
let boardVersion = 0;
|
||||
const taskListInvocation = {
|
||||
params: taskListArgs,
|
||||
getDescription: vi.fn().mockReturnValue('List tasks'),
|
||||
toolLocations: vi.fn().mockReturnValue([]),
|
||||
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
|
||||
// Every executed poll returns a changed board.
|
||||
execute: vi.fn().mockImplementation(async () => {
|
||||
boardVersion += 1;
|
||||
return {
|
||||
llmContent: `#7 [in_progress] @peer-a — task (v${boardVersion})`,
|
||||
returnDisplay: 'Listed tasks',
|
||||
};
|
||||
}),
|
||||
};
|
||||
const taskListTool = {
|
||||
name: 'task_list',
|
||||
displayName: 'Task List',
|
||||
description: 'List tasks in the team task list',
|
||||
kind: 'READ' as const,
|
||||
schema: taskListToolDef,
|
||||
build: vi.fn().mockImplementation(() => taskListInvocation),
|
||||
canUpdateOutput: false,
|
||||
isOutputMarkdown: false,
|
||||
} as unknown as AnyDeclarativeTool;
|
||||
vi.mocked(
|
||||
(config.getToolRegistry() as unknown as ToolRegistry).getTool,
|
||||
).mockImplementation((name: string) =>
|
||||
name === 'task_list' ? taskListTool : undefined,
|
||||
);
|
||||
|
||||
const scope = await AgentHeadless.create(
|
||||
'test-agent',
|
||||
config,
|
||||
promptConfig,
|
||||
defaultModelConfig,
|
||||
{ ...defaultRunConfig, max_turns: 20 },
|
||||
toolConfig,
|
||||
);
|
||||
|
||||
await scope.execute(new ContextState());
|
||||
|
||||
// The duplicate id executes once (dedupeToolCallsById), so 6 executed
|
||||
// polls across 7 model turns; the changed board must carry the agent
|
||||
// to goal instead of a false loop halt.
|
||||
expect(taskListInvocation.execute).toHaveBeenCalledTimes(6);
|
||||
expect(mockSendMessageStream).toHaveBeenCalledTimes(7);
|
||||
expect(scope.getTerminateMode()).not.toBe(
|
||||
AgentTerminateMode.LOOP_DETECTED,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not carry a stale loop attribution into a re-executed run (issue #9450)', async () => {
|
||||
const taskListToolDef: FunctionDeclaration = {
|
||||
name: 'task_list',
|
||||
|
||||
Reference in new issue
Block a user