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:
jinjing.zzj committed 2026-08-23 03:22:45 +08:00
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',