From c48b84e0e0f9b350c9b0c0a282948b5342c6bdcf Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Thu, 20 Aug 2026 00:48:03 +0800 Subject: [PATCH 01/51] fix(core): make loop detection result-aware for task_list polls (#9450) Identical task_list arguments do not imply an identical result: teammates mutate the shared task board between calls. The argument-only loop guards falsely halted polling teammates with 'duplicate tool-call loop detected' while the board kept changing. Record executed tool results into LoopDetectionService (agent runtime after processFunctionCalls; main-session continuations via functionResponse callId pairing) as privacy-safe SHA-256 fingerprints. For the stateful read tool (task_list only), the consecutive-identical guard, the global-duplicate heuristic, the adaptive cap stuck signal, and action stagnation now require the observed results to be unchanged too. Missing result evidence fails safe and keeps the pre-fix behavior, preserving the DashScope #5019 protection. Loop stops become attributable: ReasoningLoopResult carries the exact LoopType, the interactive stop message and the headless FINISH event / telemetry completion record include it. --- .../core/src/agents/runtime/agent-core.ts | 59 +++ .../core/src/agents/runtime/agent-events.ts | 6 + .../src/agents/runtime/agent-headless.test.ts | 176 +++++++++ .../core/src/agents/runtime/agent-headless.ts | 5 + .../src/agents/runtime/agent-interactive.ts | 14 +- packages/core/src/core/client.ts | 36 ++ .../src/services/loopDetectionService.test.ts | 367 ++++++++++++++++++ .../core/src/services/loopDetectionService.ts | 272 ++++++++++++- 8 files changed, 921 insertions(+), 14 deletions(-) diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 0ca4bb92bdb..e102d4032c9 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -41,6 +41,7 @@ import { type ToolCallRequestInfo, } from '../../core/turn.js'; import { LoopDetectionService } from '../../services/loopDetectionService.js'; +import type { LoopType } from '../../telemetry/types.js'; import { CoreToolScheduler, type ToolCall, @@ -295,6 +296,12 @@ export interface ReasoningLoopResult { terminateMode: AgentTerminateMode | null; /** Number of model round-trips completed. */ turnsUsed: number; + /** + * Which loop detector fired, when terminateMode is LOOP_DETECTED (issue + * #9450 — attribution for stops that all render as one generic message + * otherwise). null otherwise. + */ + loopType?: LoopType | null; } /** @@ -1149,6 +1156,24 @@ export class AgentCore { terminateMode = AgentTerminateMode.LOOP_DETECTED; break; } + // Result-aware loop guards (issue #9450): stateful reads like + // task_list may legitimately repeat with identical arguments while + // the shared task board changes, so the detector must see each + // executed result before the next round re-emits the call. + for (const toolResult of toolCallResult.results) { + if ( + loopDetector.recordToolResult( + { name: toolResult.toolName, args: toolResult.args }, + toolResult.responseParts, + ) + ) { + terminateMode = AgentTerminateMode.LOOP_DETECTED; + break; + } + } + if (terminateMode === AgentTerminateMode.LOOP_DETECTED) { + break; + } currentMessages = toolCallResult.messages; const externalInputs = this.drainExternalInputs(options); @@ -1266,6 +1291,9 @@ export class AgentCore { text: finalText, terminateMode, turnsUsed: turnCounter, + ...(terminateMode === AgentTerminateMode.LOOP_DETECTED + ? { loopType: loopDetector.getLastLoopType() } + : {}), }; } @@ -1555,6 +1583,14 @@ export class AgentCore { ): Promise<{ messages: Content[]; repeatedDuplicateProviderToolCall: boolean; + /** Executed calls with their model-visible results, in call order. + * Consumed by the loop detector for result-aware stateful-read guards + * (issue #9450). */ + results: Array<{ + toolName: string; + args: Record; + responseParts: Part[]; + }>; }> { const responseByCallId = new Map< string, @@ -1608,6 +1644,7 @@ export class AgentCore { return { messages: [{ role: 'user', parts: [] }], repeatedDuplicateProviderToolCall: true, + results: [], }; } @@ -2082,9 +2119,31 @@ export class AgentCore { timestamp: Date.now(), }); + // Pair each executed call with its model-visible (finalized) result so + // the reasoning loop can feed the loop detector's result-aware guards. + const finalizedByCallId = new Map( + finalizedResponses.map((response) => [response.callId, response]), + ); + const results: Array<{ + toolName: string; + args: Record; + responseParts: Part[]; + }> = []; + for (const fc of uniqueFunctionCalls) { + const callId = callIdByFunctionCall.get(fc) ?? fc.id ?? ''; + const finalized = finalizedByCallId.get(callId); + if (!finalized) continue; + results.push({ + toolName: String(fc.name ?? ''), + args: (fc.args ?? {}) as Record, + responseParts: finalized.responseParts, + }); + } + return { messages: [{ role: 'user', parts: toolResponseParts }], repeatedDuplicateProviderToolCall: false, + results, }; } diff --git a/packages/core/src/agents/runtime/agent-events.ts b/packages/core/src/agents/runtime/agent-events.ts index d332969604f..384b2c00a52 100644 --- a/packages/core/src/agents/runtime/agent-events.ts +++ b/packages/core/src/agents/runtime/agent-events.ts @@ -204,6 +204,12 @@ export interface AgentExternalMessageEvent { export interface AgentFinishEvent { subagentId: string; terminateReason: string; + /** + * Which loop detector fired when terminateReason is LOOP_DETECTED + * (issue #9450), so stops are attributable in journals/telemetry instead + * of collapsing into one generic label. + */ + loopType?: string; timestamp: number; rounds?: number; totalDurationMs?: number; diff --git a/packages/core/src/agents/runtime/agent-headless.test.ts b/packages/core/src/agents/runtime/agent-headless.test.ts index e6705031945..9e11a89d7da 100644 --- a/packages/core/src/agents/runtime/agent-headless.test.ts +++ b/packages/core/src/agents/runtime/agent-headless.test.ts @@ -2236,6 +2236,182 @@ describe('subagent.ts', () => { expect(scope.getTerminateMode()).toBe(AgentTerminateMode.LOOP_DETECTED); }); + it('keeps polling task_list while the task board changes (issue #9450)', async () => { + // Identical task_list arguments do not imply an identical result: + // teammates mutate the shared board between calls. The agent must + // not be halted while the observed results keep changing. + 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 pollCount = 8; // well past the consecutive-identical threshold + const taskListArgs = { + status: 'in_progress', + owner: 'peer-a', + blockedBy: '', + }; + + mockSendMessageStream.mockImplementation( + createMockStream([ + ...Array.from({ length: pollCount }, (_, 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'), + // A peer completes/claims a task between polls, so every result + // differs even though the arguments are identical. + execute: vi.fn().mockImplementation(async () => { + boardVersion += 1; + const status = boardVersion % 2 === 0 ? 'completed' : 'in_progress'; + return { + llmContent: `#7 [${status}] @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, + toolConfig, + ); + + await scope.execute(new ContextState()); + + expect(taskListInvocation.execute).toHaveBeenCalledTimes(pollCount); + expect(mockSendMessageStream).toHaveBeenCalledTimes(pollCount + 1); + expect(scope.getTerminateMode()).not.toBe( + AgentTerminateMode.LOOP_DETECTED, + ); + }); + + it('still halts task_list polling when the board is frozen (issue #9450)', async () => { + 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: '', + }; + + mockSendMessageStream.mockImplementation( + createMockStream([ + ...Array.from({ length: 5 }, (_, index) => [ + { + id: `poll_${index + 1}`, + name: 'task_list', + args: taskListArgs, + }, + ]), + 'stop', + ]), + ); + + const taskListInvocation = { + params: taskListArgs, + getDescription: vi.fn().mockReturnValue('List tasks'), + toolLocations: vi.fn().mockReturnValue([]), + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + // No teammate activity: every poll returns the identical board. + execute: vi.fn().mockResolvedValue({ + llmContent: '#7 [in_progress] @peer-a — task', + 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 finishEvents: Array<{ loopType?: string }> = []; + const eventEmitter = new AgentEventEmitter(); + eventEmitter.on(AgentEventType.FINISH, (event: unknown) => { + finishEvents.push(event as { loopType?: string }); + }); + + const scope = await AgentHeadless.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + defaultRunConfig, + toolConfig, + eventEmitter, + ); + + await scope.execute(new ContextState()); + + expect(mockSendMessageStream).toHaveBeenCalledTimes(5); + expect(taskListInvocation.execute).toHaveBeenCalledTimes(4); + expect(scope.getTerminateMode()).toBe(AgentTerminateMode.LOOP_DETECTED); + // The exact detector is attributable in the finish event (#9450). + expect(finishEvents).toHaveLength(1); + expect(finishEvents[0].loopType).toBe( + 'consecutive_identical_tool_calls', + ); + }); + it('should ignore duplicate provider tool-call ids already present in chat history', async () => { const listFilesToolDef: FunctionDeclaration = { name: 'list_files', diff --git a/packages/core/src/agents/runtime/agent-headless.ts b/packages/core/src/agents/runtime/agent-headless.ts index 2870d4bc7be..493ef9b3ae9 100644 --- a/packages/core/src/agents/runtime/agent-headless.ts +++ b/packages/core/src/agents/runtime/agent-headless.ts @@ -139,6 +139,8 @@ export class AgentHeadless { private readonly core: AgentCore; private finalText: string = ''; private terminateMode: AgentTerminateMode = AgentTerminateMode.ERROR; + // Which loop detector fired when terminateMode is LOOP_DETECTED (#9450). + private loopType: string | null = null; private chat?: GeminiChat; private toolsList?: FunctionDeclaration[]; private executing = false; @@ -377,6 +379,7 @@ export class AgentHeadless { this.finalText = result.text; this.terminateMode = result.terminateMode ?? AgentTerminateMode.GOAL; + this.loopType = result.loopType ?? null; } catch (error) { debugLogger.error('Error during subagent execution:', error); this.terminateMode = AgentTerminateMode.ERROR; @@ -394,6 +397,7 @@ export class AgentHeadless { this.core.eventEmitter?.emit(AgentEventType.FINISH, { subagentId: this.core.subagentId, terminateReason: this.terminateMode, + ...(this.loopType ? { loopType: this.loopType } : {}), timestamp: Date.now(), rounds: summary.rounds, totalDurationMs: summary.totalDurationMs, @@ -412,6 +416,7 @@ export class AgentHeadless { : 'failed', { terminate_reason: this.terminateMode, + ...(this.loopType ? { loop_type: this.loopType } : {}), result: this.finalText, execution_summary: this.core.stats.formatCompact( 'Subagent execution completed', diff --git a/packages/core/src/agents/runtime/agent-interactive.ts b/packages/core/src/agents/runtime/agent-interactive.ts index 0fdb8014989..bccd022f1c2 100644 --- a/packages/core/src/agents/runtime/agent-interactive.ts +++ b/packages/core/src/agents/runtime/agent-interactive.ts @@ -41,6 +41,7 @@ import { type AgentInteractiveConfig, type AgentMessage, } from './agent-types.js'; +import type { LoopType } from '../../telemetry/types.js'; const debugLogger = createDebugLogger('AGENT_INTERACTIVE'); @@ -239,11 +240,13 @@ export class AgentInteractive { result.terminateMode && result.terminateMode !== AgentTerminateMode.GOAL ) { - const msg = terminateModeMessage(result.terminateMode); + const msg = terminateModeMessage(result.terminateMode, result.loopType); if (msg) { this.addMessage('info', msg.text, { metadata: { level: msg.level } }); } - this.lastRoundError = `Terminated: ${result.terminateMode}`; + this.lastRoundError = result.loopType + ? `Terminated: ${result.terminateMode} (${result.loopType})` + : `Terminated: ${result.terminateMode}`; } } catch (err) { // User-initiated cancellation already logged by cancelCurrentRound(). @@ -520,6 +523,7 @@ export class AgentInteractive { */ function terminateModeMessage( mode: AgentTerminateMode, + loopType?: LoopType | null, ): { text: string; level: 'info' | 'warning' | 'error' } | null { switch (mode) { case AgentTerminateMode.MAX_TURNS: @@ -533,7 +537,11 @@ function terminateModeMessage( return { text: 'Agent stopped due to an error.', level: 'error' }; case AgentTerminateMode.LOOP_DETECTED: return { - text: 'Agent stopped: duplicate tool-call loop detected.', + // Name the exact detector so a stop is attributable (issue #9450) + // instead of collapsing every loop type into one generic label. + text: loopType + ? `Agent stopped: duplicate tool-call loop detected (${loopType}).` + : 'Agent stopped: duplicate tool-call loop detected.', level: 'error', }; case AgentTerminateMode.CANCELLED: diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 0077c044b64..2b2959f94b4 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -3344,6 +3344,42 @@ export class GeminiClient { } if (messageType === SendMessageType.ToolResult) { + // Record executed tool results for stateful read tools (task_list) + // so the loop guards can distinguish productive re-polling — the + // shared task board changed between identical calls — from a stuck + // loop (issue #9450). A detection here (the result-aware global + // duplicate count) halts the turn exactly like the event-loop + // guards below. + for (const part of requestToSend) { + if ( + typeof part !== 'object' || + part === null || + !('functionResponse' in part) + ) { + continue; + } + const functionResponseId = (part as Part).functionResponse?.id; + if (!functionResponseId) continue; + if ( + this.loopDetector.recordToolResultByCallId(functionResponseId, [ + part as Part, + ]) + ) { + for (const goalEvent of await finalizeInterruptedGoalTurn()) { + yield goalEvent; + } + const loopType = this.loopDetector.getLastLoopType(); + yield { + type: GeminiEventType.LoopDetected, + ...(loopType && { value: { loopType } }), + }; + this.lastApiCompletionTimestamp = Date.now(); + endCurrentInteraction('error', 'loop detected', 'loop_detected'); + this.cancelPendingMemoryPrefetch('no_safe_delivery_point'); + this.fireLoopDetectedStopFailure(loopType); + return turn; + } + } const toolResultMemory = await this.tryConsumeMemoryPrefetch('tool_result'); if (toolResultMemory?.prompt) { diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index 88db5b72b5a..8efed69d829 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -5,6 +5,7 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Part } from '@google/genai'; import type { Config } from '../config/config.js'; import type { ServerGeminiContentEvent, @@ -46,11 +47,13 @@ describe('LoopDetectionService', () => { const makeConfig = ( cap: number = DEFAULT_MAX_TOOL_CALLS_PER_TURN, explicit = false, + skipLoopDetection = true, ): Config => ({ getTelemetryEnabled: () => true, getMaxToolCallsPerTurn: () => cap, isMaxToolCallsPerTurnExplicit: () => explicit, + getSkipLoopDetection: () => skipLoopDetection, }) as unknown as Config; beforeEach(() => { @@ -2039,4 +2042,368 @@ describe('LoopDetectionService', () => { ); }); }); + + describe('Result-aware guards for stateful read tools (issue #9450)', () => { + // Identical `task_list` arguments do not imply an identical result: + // teammates mutate the shared task board between calls. These tests pin + // the fix for the false positive where a polling teammate was halted by + // the argument-only guards while the board kept changing. + const TASK_LIST_ARGS = { + status: 'in_progress', + owner: 'peer-a', + blockedBy: '', + }; + + const taskListEvent = ( + callId: string, + args: Record = TASK_LIST_ARGS, + ): ServerGeminiToolCallRequestEvent => ({ + type: GeminiEventType.ToolCallRequest, + value: { + name: 'task_list', + args, + callId, + isClientInitiated: false, + prompt_id: 'test-prompt-id', + }, + }); + + const taskListResult = (boardState: string, callId = 'call-x'): Part[] => [ + { + functionResponse: { + id: callId, + name: 'task_list', + response: { output: boardState }, + }, + }, + ]; + + it('still halts at the threshold when no results were recorded (fail-safe)', () => { + // A wiring gap must never loosen the DashScope protection (#5019): + // without result evidence the guard behaves exactly as pre-fix. + const event = taskListEvent('call-1'); + for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD - 1; i++) { + expect(service.checkAlwaysOnSafeties(event)).toBe(false); + } + expect(service.checkAlwaysOnSafeties(event)).toBe(true); + expect(service.getLastLoopType()).toBe( + LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, + ); + }); + + it('still halts at the threshold when every result is unchanged', () => { + const unchanged = '#1 [in_progress] @peer-a — task'; + let fired = false; + for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD; i++) { + fired = service.checkAlwaysOnSafeties(taskListEvent(`call-${i}`)); + if (fired) break; + service.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult(unchanged), + ); + } + expect(fired).toBe(true); + expect(service.getConsecutiveToolCallCount()).toBe( + TOOL_CALL_LOOP_THRESHOLD, + ); + expect(service.getLastLoopType()).toBe( + LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, + ); + }); + + it('does not halt while the task board keeps changing between identical calls', () => { + let fired = false; + // Well past the argument-only threshold: every poll returns a changed + // board (a peer completed/claimed a task between calls), which is the + // productive polling pattern the team prompt encourages. + for (let i = 0; i < 4 * TOOL_CALL_LOOP_THRESHOLD; i++) { + fired = service.checkAlwaysOnSafeties(taskListEvent(`call-${i}`)); + if (fired) break; + service.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult(`board state v${i}`), + ); + } + expect(fired).toBe(false); + expect(loggers.logLoopDetected).not.toHaveBeenCalled(); + }); + + it('keeps productive polling alive past the adaptive per-turn cap', () => { + // With the default (adaptive) cap, a turn beyond the soft cap halts + // only on a stuck-repetition signal. Changed results must not build + // that signal, so polling continues past the soft cap. + const defaultCapService = new LoopDetectionService(makeConfig()); + defaultCapService.reset('cap-prompt'); + let fired = false; + for (let i = 0; i < DEFAULT_MAX_TOOL_CALLS_PER_TURN + 20; i++) { + fired = defaultCapService.checkAlwaysOnSafeties( + taskListEvent(`call-${i}`), + ); + if (fired) break; + defaultCapService.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult(`board state v${i}`), + ); + } + expect(fired).toBe(false); + }); + + it('restarts the streak when a result changed, then halts on a fresh unchanged streak', () => { + const args = TASK_LIST_ARGS; + // R1..R4: the board changes once mid-streak (v2), so R5 must NOT halt. + const states = ['v1', 'v1', 'v2', 'v1']; + for (let i = 0; i < 4; i++) { + expect(service.checkAlwaysOnSafeties(taskListEvent(`call-${i}`))).toBe( + false, + ); + service.recordToolResult( + { name: 'task_list', args }, + taskListResult(states[i]), + ); + } + expect(service.checkAlwaysOnSafeties(taskListEvent('call-4'))).toBe( + false, + ); + service.recordToolResult( + { name: 'task_list', args }, + taskListResult('v1'), + ); + + // The streak restarted at call-4 (the reset made it request #1 of the + // new streak): call-5..call-7 stay below the threshold, and their + // unchanged results corroborate the loop, so call-8 — the 5th request + // of the restarted streak — halts. + for (let i = 5; i <= 7; i++) { + expect(service.checkAlwaysOnSafeties(taskListEvent(`call-${i}`))).toBe( + false, + ); + service.recordToolResult( + { name: 'task_list', args }, + taskListResult('v1'), + ); + } + expect(service.checkAlwaysOnSafeties(taskListEvent('call-8'))).toBe(true); + expect(service.getLastLoopType()).toBe( + LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, + ); + }); + + it('does not change behavior for deterministic (non-stateful) tools', () => { + const event = createToolCallRequestEvent('read_file', { + file_path: '/a', + }); + for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD - 1; i++) { + service.checkAlwaysOnSafeties(event); + // Results are recorded but ignored for non-stateful tools: identical + // args still mean an identical result, so the argument-only guard + // must fire unchanged. + service.recordToolResult( + { name: 'read_file', args: { file_path: '/a' } }, + [ + { + functionResponse: { + id: `call-${i}`, + name: 'read_file', + response: { output: `content v${i}` }, + }, + }, + ], + ); + } + expect(service.checkAlwaysOnSafeties(event)).toBe(true); + expect(service.getLastLoopType()).toBe( + LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, + ); + }); + + it('records results by callId pairing from ToolCallRequest events', () => { + for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD - 1; i++) { + expect(service.checkAlwaysOnSafeties(taskListEvent(`call-${i}`))).toBe( + false, + ); + expect( + service.recordToolResultByCallId( + `call-${i}`, + taskListResult(`board state v${i}`, `call-${i}`), + ), + ).toBe(false); + } + // Changed results arrived through the callId pairing, so the + // threshold-th identical request is accepted. + expect( + service.checkAlwaysOnSafeties( + taskListEvent(`call-${TOOL_CALL_LOOP_THRESHOLD - 1}`), + ), + ).toBe(false); + // Unknown callIds (never streamed through this service) are ignored. + expect( + service.recordToolResultByCallId('never-seen', taskListResult('x')), + ).toBe(false); + }); + + it('counts global duplicates on (call, result) pairs when heuristics run', () => { + const heuristicService = new LoopDetectionService( + makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), + ); + heuristicService.reset('global-dup'); + + // Identical task_list calls whose results CHANGE never reach the + // global-duplicate threshold, no matter how they are interleaved. + const interleaved = ['task_list', 'tool_b', 'tool_c']; + let stateOrdinal = 0; + for (let round = 0; round < 3; round++) { + for (const name of interleaved) { + const args = name === 'task_list' ? TASK_LIST_ARGS : { step: round }; + expect( + heuristicService.addAndCheck( + createToolCallRequestEvent(name, args), + ), + ).toBe(false); + if (name === 'task_list') { + expect( + heuristicService.recordToolResult( + { name, args }, + taskListResult(`state-${stateOrdinal++}`), + ), + ).toBe(false); + } + } + } + + // A genuinely stuck poll — same call, SAME result, interleaved so the + // consecutive guard never fires — trips the result-aware global + // duplicate at the threshold. + const stuckService = new LoopDetectionService( + makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), + ); + stuckService.reset('global-dup-stuck'); + let detected = false; + for ( + let round = 0; + round < GLOBAL_DUPLICATE_THRESHOLD && !detected; + round++ + ) { + for (const name of interleaved) { + const args = name === 'task_list' ? TASK_LIST_ARGS : { step: round }; + if ( + stuckService.addAndCheck(createToolCallRequestEvent(name, args)) + ) { + detected = true; + break; + } + if (name === 'task_list') { + detected = stuckService.recordToolResult( + { name, args }, + taskListResult('frozen board'), + ); + if (detected) break; + } + } + } + expect(detected).toBe(true); + expect(stuckService.getLastLoopType()).toBe( + LoopType.GLOBAL_TOOL_CALL_DUPLICATE, + ); + }); + + it('treats changed results as progress for action stagnation', () => { + const heuristicService = new LoopDetectionService( + makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), + ); + heuristicService.reset('stagnation'); + + // 8+ same-name task_list calls with VARYING args (the consecutive + // guard never fires) and CHANGING results: productive polling, no + // ACTION_STAGNATION halt. + for (let i = 0; i < 12; i++) { + const args = { owner: `peer-${i % 3}` }; + expect( + heuristicService.addAndCheck( + createToolCallRequestEvent('task_list', args), + ), + ).toBe(false); + expect( + heuristicService.recordToolResult( + { name: 'task_list', args }, + taskListResult(`state v${i}`), + ), + ).toBe(false); + } + + // Same shape but the board is FROZEN: the same-name streak is not + // reset and stagnation fires. + const frozenService = new LoopDetectionService( + makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), + ); + frozenService.reset('stagnation-frozen'); + let fired = false; + for (let i = 0; i < 12; i++) { + const args = { owner: `peer-${i % 3}` }; + fired = frozenService.addAndCheck( + createToolCallRequestEvent('task_list', args), + ); + if (fired) break; + frozenService.recordToolResult( + { name: 'task_list', args }, + taskListResult('frozen board'), + ); + } + expect(fired).toBe(true); + expect(frozenService.getLastLoopType()).toBe(LoopType.ACTION_STAGNATION); + }); + + it('resets result evidence on retry so a replay is judged on its own results', () => { + const unchanged = '#1 [in_progress] @peer-a — task'; + for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD - 1; i++) { + service.checkAlwaysOnSafeties(taskListEvent(`call-${i}`)); + service.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult(unchanged), + ); + } + expect( + service.checkAlwaysOnSafeties({ + type: GeminiEventType.Retry, + } as ServerGeminiStreamEvent), + ).toBe(false); + + // After the retry the replayed attempt starts with fresh evidence: + // four unchanged results are not yet enough to halt. + for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD - 1; i++) { + expect( + service.checkAlwaysOnSafeties(taskListEvent(`replay-${i}`)), + ).toBe(false); + service.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult(unchanged), + ); + } + expect(service.checkAlwaysOnSafeties(taskListEvent('replay-4'))).toBe( + true, + ); + }); + + it('clears stateful tracking on reset()', () => { + const unchanged = '#1 [in_progress] @peer-a — task'; + service.checkAlwaysOnSafeties(taskListEvent('call-0')); + service.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult(unchanged), + ); + service.reset('fresh-prompt'); + + // Changed results in the fresh prompt must not be compared against + // the previous prompt's fingerprint. + let fired = false; + for (let i = 0; i < 4 * TOOL_CALL_LOOP_THRESHOLD; i++) { + fired = service.checkAlwaysOnSafeties(taskListEvent(`call-${i}`)); + if (fired) break; + service.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult(`fresh v${i}`), + ); + } + expect(fired).toBe(false); + }); + }); }); diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index a396a4c89aa..ac84db8ffdd 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -5,6 +5,7 @@ */ import { createHash } from 'node:crypto'; +import type { Part } from '@google/genai'; import type { ServerGeminiStreamEvent } from '../core/turn.js'; import { GeminiEventType } from '../core/turn.js'; import type { ThoughtSummary } from '../utils/thoughtUtils.js'; @@ -37,6 +38,21 @@ const CONTENT_LOOP_THRESHOLD = 10; const CONTENT_CHUNK_SIZE = 50; const MAX_HISTORY_LENGTH = 1000; +// Tools whose identical arguments do NOT imply an identical result: they +// read shared state that other agents can mutate between calls (issue +// #9450 — a teammate polling `task_list` while peers keep completing tasks +// was halted by the argument-only guards). For these tools the guards below +// become result-aware: repetition only counts as a loop when the observed +// results are unchanged too. Intentionally narrow — deterministic tools keep +// the argument-only behavior, and other team tools (`send_message`, +// `task_update`) have different mutation/delivery semantics and stay out. +const STATEFUL_READ_TOOLS: ReadonlySet = new Set(['task_list']); + +// Bound for the callId → request map used to pair tool results with their +// requests (recordToolResultByCallId). Parallel tool batches are far smaller; +// the cap only protects against unpaired entries accumulating. +const MAX_TRACKED_TOOL_REQUESTS = 500; + // Thought tracking const THOUGHT_REPEAT_THRESHOLD = 3; const MAX_THOUGHT_HISTORY = 50; @@ -201,6 +217,36 @@ export class LoopDetectionService { private capKeyCounts = new Map(); private capMaxKeyRepeat = 0; + // Result-aware tracking for stateful read tools (see STATEFUL_READ_TOOLS). + // Keyed by the (tool, args) repeat key. `resultsObserved` / + // `unchangedStreak` count results within the CURRENT consecutive-identical + // streak (restarted when the streak breaks); `lastFingerprint` survives + // streak breaks so a state change is still visible across interleaved + // calls (used by the action-stagnation reset). + private statefulRepeatState = new Map< + string, + { + resultsObserved: number; + unchangedStreak: number; + lastFingerprint: string | undefined; + } + >(); + + // Turn-wide counts of (repeat key, result fingerprint) pairs for stateful + // read tools, recorded post-execution. Replaces the request-time + // global-duplicate counting and the cap's stuck-repetition counting for + // these tools: the same call returning changed state is productive and + // must not accumulate toward either halt. + private statefulPairCounts = new Map(); + + // callId → request pairing so results can be matched to their calls when + // the runtime only has the response (populated on ToolCallRequest events, + // consumed by recordToolResultByCallId). + private requestByCallId = new Map< + string, + { name: string; args: object; key: string } + >(); + // Loop type of the most recent firing. Bubbled up through the // LoopDetected event so callers (non-interactive CLI, telemetry) can tell // the user which detector actually fired. @@ -233,6 +279,136 @@ export class LoopDetectionService { ); } + /** + * Records the executed result of a tool call so the guards can treat + * stateful read tools (see STATEFUL_READ_TOOLS) result-aware: identical + * arguments whose results keep changing are productive polling, not a + * loop (issue #9450). Call this once per executed call, after execution + * and before the model is re-prompted with the result. Runtime paths that + * only hold the response (no name/args) can use recordToolResultByCallId. + * + * Returns true when the recorded result itself trips a detector (the + * result-aware global-duplicate count); callers must then halt the turn + * the same way they do for an event-detected loop. + */ + recordToolResult( + toolCall: { name: string; args: object }, + responseParts: readonly Part[], + ): boolean { + if (this.loopDetected) return true; + if (this.disabledForSession) return false; + if (!this.isStatefulReadTool(toolCall.name)) return false; + + const resultText = LoopDetectionService.extractResultText(responseParts); + if (resultText === null) return false; + const fingerprint = createHash('sha256').update(resultText).digest('hex'); + const key = this.getToolCallKey(toolCall); + + // Consecutive-streak evidence for the always-on guard. The state entry + // can predate the streak (lastFingerprint survives streak breaks), so + // create it lazily but only count results while a streak exists. + let state = this.statefulRepeatState.get(key); + if (!state) { + state = { + resultsObserved: 0, + unchangedStreak: 0, + lastFingerprint: undefined, + }; + this.statefulRepeatState.set(key, state); + } + const firstResult = state.lastFingerprint === undefined; + const fingerprintChanged = + !firstResult && state.lastFingerprint !== fingerprint; + if (this.lastToolCallKey === key) { + state.resultsObserved++; + if (firstResult) { + state.lastFingerprint = fingerprint; + } else if (state.lastFingerprint === fingerprint) { + state.unchangedStreak++; + } else { + state.unchangedStreak = 0; + state.lastFingerprint = fingerprint; + } + } else { + state.lastFingerprint = fingerprint; + } + + // A changed result is observable progress: restart the same-name streak + // so ACTION_STAGNATION does not fire on productive polling. + if (fingerprintChanged && this.lastSeenToolName === toolCall.name) { + this.sameNameStreak = 1; + } + + // Turn-wide (repeat key, fingerprint) counting: replaces the + // request-time global-duplicate and cap stuck-repetition counting for + // stateful tools. + const pairKey = `${key}|${fingerprint}`; + const pairCount = (this.statefulPairCounts.get(pairKey) ?? 0) + 1; + this.statefulPairCounts.set(pairKey, pairCount); + if (pairCount > this.capMaxKeyRepeat) { + this.capMaxKeyRepeat = pairCount; + } + + // The global-duplicate detector is gated (skipLoopDetection) exactly as + // its request-time counterpart in addAndCheckHeuristicLoops. + if ( + !this.config.getSkipLoopDetection() && + pairCount >= GLOBAL_DUPLICATE_THRESHOLD + ) { + this.lastLoopType = LoopType.GLOBAL_TOOL_CALL_DUPLICATE; + logLoopDetected( + this.config, + new LoopDetectedEvent( + LoopType.GLOBAL_TOOL_CALL_DUPLICATE, + this.promptId, + ), + ); + this.loopDetected = true; + return true; + } + return false; + } + + /** + * Variant of recordToolResult for runtimes that only have the response: + * the request is resolved through the callId pairing populated on + * ToolCallRequest events. Unknown callIds (e.g. client-initiated calls + * that never streamed through this service) are ignored. + */ + recordToolResultByCallId( + callId: string, + responseParts: readonly Part[], + ): boolean { + const request = this.requestByCallId.get(callId); + if (!request) return false; + this.requestByCallId.delete(callId); + return this.recordToolResult( + { name: request.name, args: request.args }, + responseParts, + ); + } + + private isStatefulReadTool(toolName: string): boolean { + return STATEFUL_READ_TOOLS.has(toolName); + } + + /** + * Reconstructs the model-visible result text from tool response parts. + * Only the fingerprint of this text is retained, never the text itself. + * Returns null when the parts carry no functionResponse content. + */ + private static extractResultText( + responseParts: readonly Part[], + ): string | null { + const chunks: string[] = []; + for (const part of responseParts) { + const functionResponse = part.functionResponse; + if (!functionResponse) continue; + chunks.push(JSON.stringify(functionResponse.response ?? {})); + } + return chunks.length > 0 ? chunks.join('\n') : null; + } + private getToolCallKey(toolCall: { name: string; args: object }): string { return getToolCallRepeatKey(toolCall.name, toolCall.args); } @@ -274,7 +450,12 @@ export class LoopDetectionService { this.trackToolCall(event.value); const toolCallKey = this.getToolCallKey(event.value); - const globalDup = this.checkGlobalDuplicate(toolCallKey); + // Stateful read tools are counted post-execution in + // recordToolResult, keyed on (call, result fingerprint) instead of + // args alone (issue #9450). + const globalDup = this.isStatefulReadTool(event.value.name) + ? false + : this.checkGlobalDuplicate(toolCallKey); const alternating = this.checkAlternatingPattern(toolCallKey); const readFileLoop = this.checkReadFileLoop(); const actionStagnation = this.checkActionStagnation(); @@ -346,6 +527,15 @@ export class LoopDetectionService { this.resetToolCallCount(); this.capKeyCounts.clear(); this.capMaxKeyRepeat = 0; + // A retry replays the failed attempt's tool calls; drop the stateful + // result evidence too so the replayed attempt is judged on its own + // results (pair counts re-accumulate as results land, consistent with + // the capKeyCounts/globalToolCallCounts clears). + this.statefulPairCounts.clear(); + for (const state of this.statefulRepeatState.values()) { + state.resultsObserved = 0; + state.unchangedStreak = 0; + } return false; } @@ -365,19 +555,40 @@ export class LoopDetectionService { // can be large (e.g. write_file content), so avoid recomputing per guard. const key = this.getToolCallKey(event.value); + // Pair requests with their later results (recordToolResultByCallId). + if (event.value.callId) { + this.requestByCallId.set(event.value.callId, { + name: event.value.name, + args: event.value.args, + key, + }); + if (this.requestByCallId.size > MAX_TRACKED_TOOL_REQUESTS) { + const oldest = this.requestByCallId.keys().next().value; + if (oldest !== undefined) this.requestByCallId.delete(oldest); + } + } + + const stateful = this.isStatefulReadTool(event.value.name); + // Always-on stuck-repetition tracking for the adaptive cap (see // checkTurnToolCallCap): lets the cap tell a productive turn from a stuck - // one, regardless of skipLoopDetection. - this.trackCapKeyRepeat(key); + // one, regardless of skipLoopDetection. Stateful read tools are counted + // post-execution instead (recordToolResult): the same call returning + // changed state is productive and must not build the stuck signal. + if (!stateful) { + this.trackCapKeyRepeat(key); + } // Consecutive identical tool calls (same name AND identical args) are the - // one repetition signal precise enough to halt unconditionally — an - // identical call returns an identical result, so it is never productive. - // Promoted here from the opt-in tier so it protects every user regardless - // of the `skipLoopDetection` config default: the DashScope server rejects - // this pattern with a 400 (issue #5019) far below the per-turn cap, so - // the gated default left users unprotected. - if (this.checkToolCallLoop(key)) { + // one repetition signal precise enough to halt unconditionally — for + // deterministic tools an identical call returns an identical result, so + // it is never productive. Promoted here from the opt-in tier so it + // protects every user regardless of the `skipLoopDetection` config + // default: the DashScope server rejects this pattern with a 400 (issue + // #5019) far below the per-turn cap, so the gated default left users + // unprotected. For stateful read tools the guard additionally requires + // the observed results to be unchanged (issue #9450). + if (this.checkToolCallLoop(event.value, key)) { this.loopDetected = true; return true; } @@ -394,14 +605,50 @@ export class LoopDetectionService { return false; } - private checkToolCallLoop(key: string): boolean { + private checkToolCallLoop( + toolCall: { name: string; args: object }, + key: string, + ): boolean { if (this.lastToolCallKey === key) { this.toolCallRepetitionCount++; } else { + // The streak moved on: restart the result evidence for both the old + // and the new key so each consecutive streak is judged on the results + // observed within it. + for (const streakKey of [this.lastToolCallKey, key]) { + if (!streakKey) continue; + const state = this.statefulRepeatState.get(streakKey); + if (state) { + state.resultsObserved = 0; + state.unchangedStreak = 0; + } + } this.lastToolCallKey = key; this.toolCallRepetitionCount = 1; } if (this.toolCallRepetitionCount >= TOOL_CALL_LOOP_THRESHOLD) { + if (this.isStatefulReadTool(toolCall.name)) { + // Result-aware guard (issue #9450): identical arguments to a stateful + // read do not imply an identical result, so only halt when the + // executed results corroborate the loop. By the Nth identical request + // the prior N-1 results have been recorded; if they were ALL observed + // and unchanged, the repetition is genuinely unproductive. If some + // result changed, the model's re-poll was productive — restart the + // streak instead of halting. Missing result evidence (results never + // recorded for this streak) fails safe and keeps the pre-#9450 + // behavior, so the DashScope protection (#5019) is never loosened by + // a wiring gap. + const state = this.statefulRepeatState.get(key); + const expectedResults = this.toolCallRepetitionCount - 1; + if (state && state.resultsObserved >= expectedResults) { + if (state.unchangedStreak < expectedResults - 1) { + this.toolCallRepetitionCount = 1; + state.resultsObserved = 0; + state.unchangedStreak = 0; + return false; + } + } + } this.lastLoopType = LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS; logLoopDetected( this.config, @@ -996,6 +1243,9 @@ export class LoopDetectionService { this.turnToolCallTotalCommitted = 0; this.capKeyCounts.clear(); this.capMaxKeyRepeat = 0; + this.statefulRepeatState.clear(); + this.statefulPairCounts.clear(); + this.requestByCallId.clear(); } private resetToolCallCount(): void { From 91256db132251e2e0a6d8cc5a4ffb8d224829cd3 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Thu, 20 Aug 2026 03:15:10 +0800 Subject: [PATCH 02/51] fix(core): report result-aware loops to arena --- packages/core/src/core/client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 2b2959f94b4..b502c697963 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -3373,6 +3373,7 @@ export class GeminiClient { type: GeminiEventType.LoopDetected, ...(loopType && { value: { loopType } }), }; + await arenaAgentClient?.reportError('Loop detected'); this.lastApiCompletionTimestamp = Date.now(); endCurrentInteraction('error', 'loop detected', 'loop_detected'); this.cancelPendingMemoryPrefetch('no_safe_delivery_point'); From 83d2bbb0529aaae20595700eae7067b6217caa71 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Thu, 20 Aug 2026 06:53:02 +0800 Subject: [PATCH 03/51] fix(core): complete loop attribution plumbing for #9450 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - R1-1: declare loop_type on SubagentExecutionEvent and serialize it in the QwenLogger sink; previously the spread in agent-headless.ts was dropped at construction (excess-property spread), so LOOP_DETECTED stops reached the journal unattributable. - R1-2: reset loopType at the top of AgentHeadless.execute() so a re-executed instance (stop-hook continuation, resident turns) never carries a stale loop attribution into an ERROR/FINISH record. - R1-12: pair only stateful read tools in requestByCallId — every other tool is rejected by recordToolResult anyway, and the write-only entries pin full args objects (write_file contents) up to the eviction cap. - R1-3: drop the dead 'key' field from requestByCallId entries; the consumer reads only name/args and recordToolResult recomputes the key. - Tests: assert loop_type in the telemetry completion record (R1-5) and pin the re-execution attribution reset. --- .../src/agents/runtime/agent-headless.test.ts | 123 ++++++++++++++++++ .../core/src/agents/runtime/agent-headless.ts | 4 + .../core/src/services/loopDetectionService.ts | 14 +- .../src/telemetry/qwen-logger/qwen-logger.ts | 1 + packages/core/src/telemetry/types.ts | 3 + 5 files changed, 137 insertions(+), 8 deletions(-) diff --git a/packages/core/src/agents/runtime/agent-headless.test.ts b/packages/core/src/agents/runtime/agent-headless.test.ts index 9e11a89d7da..641ded320eb 100644 --- a/packages/core/src/agents/runtime/agent-headless.test.ts +++ b/packages/core/src/agents/runtime/agent-headless.test.ts @@ -61,6 +61,8 @@ import { AgentTerminateMode } from './agent-types.js'; import { WriteFileTool } from '../../tools/write-file.js'; import { ToolNames } from '../../tools/tool-names.js'; import { normalizeToolNameForProvider } from '../../utils/tool-name-utils.js'; +import { logSubagentExecution } from '../../telemetry/loggers.js'; +import type { SubagentExecutionEvent } from '../../telemetry/types.js'; vi.mock('../../core/geminiChat.js'); vi.mock('../../core/contentGenerator.js', async (importOriginal) => { @@ -107,6 +109,10 @@ vi.mock('../../utils/environmentContext.js', () => ({ vi.mock('../../core/nonInteractiveToolExecutor.js'); vi.mock('../../ide/ide-client.js'); vi.mock('../../core/client.js'); +vi.mock('../../telemetry/loggers.js', async (importOriginal) => ({ + ...(await importOriginal()), + logSubagentExecution: vi.fn(), +})); vi.mock('../../skills/skill-manager.js', () => { const SkillManagerMock = vi.fn(); @@ -2410,6 +2416,123 @@ describe('subagent.ts', () => { expect(finishEvents[0].loopType).toBe( 'consecutive_identical_tool_calls', ); + // The telemetry completion record carries the same attribution; a + // SubagentExecutionEvent without loop_type would silently drop the + // spread and journal the stop as unattributable. + const completionEvents = vi + .mocked(logSubagentExecution) + .mock.calls.map((call) => call[1]) + .filter( + (event): event is SubagentExecutionEvent => + event.status !== 'started', + ); + expect(completionEvents).toHaveLength(1); + expect(completionEvents[0]?.loop_type).toBe( + 'consecutive_identical_tool_calls', + ); + }); + + it('does not carry a stale loop attribution into a re-executed run (issue #9450)', async () => { + 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: '', + }; + + mockSendMessageStream.mockImplementation( + createMockStream([ + ...Array.from({ length: 5 }, (_, index) => [ + { + id: `poll_${index + 1}`, + name: 'task_list', + args: taskListArgs, + }, + ]), + 'stop', + ]), + ); + + const taskListInvocation = { + params: taskListArgs, + getDescription: vi.fn().mockReturnValue('List tasks'), + toolLocations: vi.fn().mockReturnValue([]), + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + execute: vi.fn().mockResolvedValue({ + llmContent: '#7 [in_progress] @peer-a — task', + 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 finishEvents: Array<{ + loopType?: string; + terminateReason?: string; + }> = []; + const eventEmitter = new AgentEventEmitter(); + eventEmitter.on(AgentEventType.FINISH, (event: unknown) => { + finishEvents.push( + event as { loopType?: string; terminateReason?: string }, + ); + }); + eventEmitter.on(AgentEventType.ERROR, () => undefined); + + const scope = await AgentHeadless.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + defaultRunConfig, + toolConfig, + eventEmitter, + ); + + // Run 1 halts on the frozen board with an attribution. + await scope.execute(new ContextState()); + expect(scope.getTerminateMode()).toBe(AgentTerminateMode.LOOP_DETECTED); + + // Run 2 on the same instance (stop-hook continuation / resident + // turns) errors before any loop fires: it must not carry run 1's + // loopType into its FINISH/telemetry. + mockSendMessageStream.mockRejectedValueOnce( + new Error('simulated model error'), + ); + await expect(scope.execute(new ContextState())).rejects.toThrow( + 'simulated model error', + ); + expect(scope.getTerminateMode()).toBe(AgentTerminateMode.ERROR); + + expect(finishEvents).toHaveLength(2); + expect(finishEvents[0].loopType).toBe( + 'consecutive_identical_tool_calls', + ); + expect(finishEvents[1].loopType).toBeUndefined(); }); it('should ignore duplicate provider tool-call ids already present in chat history', async () => { diff --git a/packages/core/src/agents/runtime/agent-headless.ts b/packages/core/src/agents/runtime/agent-headless.ts index 493ef9b3ae9..1f675520495 100644 --- a/packages/core/src/agents/runtime/agent-headless.ts +++ b/packages/core/src/agents/runtime/agent-headless.ts @@ -219,6 +219,10 @@ export class AgentHeadless { this.executing = true; this.finalText = ''; this.terminateMode = AgentTerminateMode.ERROR; + // A re-executed instance (stop-hook continuation, resident turns) must + // not carry the previous run's loop attribution into an ERROR/FINISH + // spread; the field is only meaningful for a LOOP_DETECTED stop. + this.loopType = null; const resetStats = options.resetStats !== false; if (resetStats) { this.core.resetExecutionStats(); diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index ac84db8ffdd..9fc7e780e1e 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -242,10 +242,7 @@ export class LoopDetectionService { // callId → request pairing so results can be matched to their calls when // the runtime only has the response (populated on ToolCallRequest events, // consumed by recordToolResultByCallId). - private requestByCallId = new Map< - string, - { name: string; args: object; key: string } - >(); + private requestByCallId = new Map(); // Loop type of the most recent firing. Bubbled up through the // LoopDetected event so callers (non-interactive CLI, telemetry) can tell @@ -554,13 +551,16 @@ export class LoopDetectionService { // it (consecutive-identical and the adaptive cap's stuck tracker). Args // can be large (e.g. write_file content), so avoid recomputing per guard. const key = this.getToolCallKey(event.value); + const stateful = this.isStatefulReadTool(event.value.name); // Pair requests with their later results (recordToolResultByCallId). - if (event.value.callId) { + // Only stateful read tools participate: recordToolResult rejects every + // other tool, so tracking them would just accumulate full args objects + // (write_file args can carry whole file contents) until eviction. + if (event.value.callId && stateful) { this.requestByCallId.set(event.value.callId, { name: event.value.name, args: event.value.args, - key, }); if (this.requestByCallId.size > MAX_TRACKED_TOOL_REQUESTS) { const oldest = this.requestByCallId.keys().next().value; @@ -568,8 +568,6 @@ export class LoopDetectionService { } } - const stateful = this.isStatefulReadTool(event.value.name); - // Always-on stuck-repetition tracking for the adaptive cap (see // checkTurnToolCallCap): lets the cap tell a productive turn from a stuck // one, regardless of skipLoopDetection. Stateful read tools are counted diff --git a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts index 502d63d43de..553f33989b0 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts @@ -584,6 +584,7 @@ export class QwenLogger { subagent_name: event.subagent_name, status: event.status, terminate_reason: event.terminate_reason, + ...(event.loop_type ? { loop_type: event.loop_type } : {}), }, snapshots: JSON.stringify({ ...(event.execution_summary diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index b274cd2e310..f2470cb7f78 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -1076,6 +1076,7 @@ export class SubagentExecutionEvent implements BaseTelemetryEvent { terminate_reason?: string; result?: string; execution_summary?: string; + loop_type?: string; constructor( subagent_name: string, @@ -1084,6 +1085,7 @@ export class SubagentExecutionEvent implements BaseTelemetryEvent { terminate_reason?: string; result?: string; execution_summary?: string; + loop_type?: string; }, ) { this['event.name'] = 'subagent_execution'; @@ -1093,6 +1095,7 @@ export class SubagentExecutionEvent implements BaseTelemetryEvent { this.terminate_reason = options?.terminate_reason; this.result = options?.result; this.execution_summary = options?.execution_summary; + this.loop_type = options?.loop_type; } } From b0db43049941ff69a5e3fecf974901445c221f99 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Thu, 20 Aug 2026 08:11:06 +0800 Subject: [PATCH 04/51] test(core): pin result-aware loop guards against silent regressions (#9450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extend the changed-results global-duplicate phase past GLOBAL_DUPLICATE_THRESHOLD rounds so an args-only mutant cannot hide. - Add heuristic-gate tests that Retry and reset() clear result-aware pair counts (cross-prompt and replay false positives). - Add the adaptive-cap positive-side test: an interleaved frozen poller halts with TURN_TOOL_CALL_CAP just past the soft cap. - Add the streak-move fail-safe test: a resumed streak with insufficient fresh result evidence halts instead of trusting stale evidence. - client.test.ts: drive sendMessageStream with paired ToolResult ids — frozen board halts with global_tool_call_duplicate, changing board keeps running. - agent-headless: interleaved frozen task_list polling halts via the result-time guard with FINISH loopType attribution. - agent-interactive: LOOP_DETECTED results surface the exact detector in the stop message and lastRoundError. --- .../src/agents/runtime/agent-headless.test.ts | 123 ++++++++++++ .../agents/runtime/agent-interactive.test.ts | 50 ++++- packages/core/src/core/client.test.ts | 97 ++++++++++ .../src/services/loopDetectionService.test.ts | 176 +++++++++++++++++- 4 files changed, 443 insertions(+), 3 deletions(-) diff --git a/packages/core/src/agents/runtime/agent-headless.test.ts b/packages/core/src/agents/runtime/agent-headless.test.ts index 641ded320eb..9f6c463d114 100644 --- a/packages/core/src/agents/runtime/agent-headless.test.ts +++ b/packages/core/src/agents/runtime/agent-headless.test.ts @@ -2432,6 +2432,129 @@ describe('subagent.ts', () => { ); }); + it('halts interleaved frozen task_list polling via the result-time guard (issue #9450)', async () => { + const taskListToolDef: FunctionDeclaration = { + name: 'task_list', + description: 'Lists team tasks', + parameters: { type: Type.OBJECT, properties: {} }, + }; + const fillerToolDef: FunctionDeclaration = { + name: 'tool_b', + description: 'Distinct filler work', + parameters: { type: Type.OBJECT, properties: {} }, + }; + + const { config } = await createMockConfig({ + getFunctionDeclarationsFiltered: vi + .fn() + .mockReturnValue([taskListToolDef, fillerToolDef]), + getTool: vi.fn().mockReturnValue(undefined), + }); + const toolConfig: ToolConfig = { tools: ['task_list', 'tool_b'] }; + const taskListArgs = { + status: 'in_progress', + owner: 'peer-a', + blockedBy: '', + }; + + // Interleave a DISTINCT call between identical task_list polls: the + // consecutive-identical guard never fires (streaks reset), the + // alternating guard never fires (filler args differ every round), + // and request-time counting is bypassed for stateful tools — only + // the result-time global-duplicate guard in agent-core catches it. + const turns: Array = []; + for (let poll = 1; poll <= 6; poll++) { + turns.push([ + { id: `poll_${poll}`, name: 'task_list', args: taskListArgs }, + ]); + if (poll < 6) { + turns.push([ + { id: `fill_${poll}`, name: 'tool_b', args: { step: poll } }, + ]); + } + } + turns.push('stop'); + mockSendMessageStream.mockImplementation(createMockStream(turns)); + + const taskListInvocation = { + params: taskListArgs, + getDescription: vi.fn().mockReturnValue('List tasks'), + toolLocations: vi.fn().mockReturnValue([]), + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + // No teammate activity: every poll returns the identical board. + execute: vi.fn().mockResolvedValue({ + llmContent: '#7 [in_progress] @peer-a — task', + 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; + const fillerInvocation = { + params: {}, + getDescription: vi.fn().mockReturnValue('Filler'), + toolLocations: vi.fn().mockReturnValue([]), + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + execute: vi.fn().mockResolvedValue({ + llmContent: 'filler done', + returnDisplay: 'Filler done', + }), + }; + const fillerTool = { + name: 'tool_b', + displayName: 'Tool B', + description: 'Distinct filler work', + kind: 'READ' as const, + schema: fillerToolDef, + build: vi.fn().mockImplementation(() => fillerInvocation), + canUpdateOutput: false, + isOutputMarkdown: false, + } as unknown as AnyDeclarativeTool; + vi.mocked( + (config.getToolRegistry() as unknown as ToolRegistry).getTool, + ).mockImplementation((name: string) => + name === 'task_list' + ? taskListTool + : name === 'tool_b' + ? fillerTool + : undefined, + ); + + const finishEvents: Array<{ loopType?: string }> = []; + const eventEmitter = new AgentEventEmitter(); + eventEmitter.on(AgentEventType.FINISH, (event: unknown) => { + finishEvents.push(event as { loopType?: string }); + }); + + const scope = await AgentHeadless.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + { ...defaultRunConfig, max_turns: 20 }, + toolConfig, + eventEmitter, + ); + + await scope.execute(new ContextState()); + + // 11 model turns: six task_list polls interleaved with five filler + // calls; the halt lands when the sixth frozen (call, result) pair is + // recorded, before a twelfth request. + expect(mockSendMessageStream).toHaveBeenCalledTimes(11); + expect(taskListInvocation.execute).toHaveBeenCalledTimes(6); + expect(scope.getTerminateMode()).toBe(AgentTerminateMode.LOOP_DETECTED); + expect(finishEvents).toHaveLength(1); + expect(finishEvents[0].loopType).toBe('global_tool_call_duplicate'); + }); + it('does not carry a stale loop attribution into a re-executed run (issue #9450)', async () => { const taskListToolDef: FunctionDeclaration = { name: 'task_list', diff --git a/packages/core/src/agents/runtime/agent-interactive.test.ts b/packages/core/src/agents/runtime/agent-interactive.test.ts index 2ff6d965bf6..8d21f680f47 100644 --- a/packages/core/src/agents/runtime/agent-interactive.test.ts +++ b/packages/core/src/agents/runtime/agent-interactive.test.ts @@ -16,6 +16,8 @@ import type { } from './agent-events.js'; import { ContextState } from './agent-headless.js'; import type { AgentInteractiveConfig } from './agent-types.js'; +import { AgentTerminateMode } from './agent-types.js'; +import { LoopType } from '../../telemetry/types.js'; import { AgentStatus } from './agent-types.js'; import { getCurrentAgentDepth, @@ -33,7 +35,12 @@ function createMockCore( overrides: { chatValue?: unknown; nullChat?: boolean; - loopResult?: { text: string; terminateMode: null; turnsUsed: number }; + loopResult?: { + text: string; + terminateMode: AgentTerminateMode | null; + loopType?: LoopType | null; + turnsUsed: number; + }; } = {}, ) { const emitter = new AgentEventEmitter(); @@ -307,6 +314,47 @@ describe('AgentInteractive', () => { expect(agent.getStatus()).toBe('completed'); }); + it('surfaces the exact loop detector in the stop message and lastRoundError (#9450)', async () => { + const { core } = createMockCore({ + loopResult: { + text: '', + terminateMode: AgentTerminateMode.LOOP_DETECTED, + loopType: LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, + turnsUsed: 3, + }, + }); + const config = createConfig({ initialTask: 'poll tasks' }); + const agent = new AgentInteractive(config, core); + + await agent.start(context); + await vi.waitFor(() => { + // A round that terminates on a detected loop settles as failed + // (lastRoundError is set), not idle. + expect(agent.getStatus()).toBe('failed'); + }); + + // The user-visible stop message names the detector instead of the + // generic loop label. + const infoTexts = agent + .getMessages() + .filter((m) => m.role === 'info') + .map((m) => String(m.content)); + expect( + infoTexts.some((text) => + text.includes( + `duplicate tool-call loop detected (${LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS})`, + ), + ), + ).toBe(true); + + // Arena-facing attribution keeps the detector too. + expect(agent.getLastRoundError()).toBe( + `Terminated: ${AgentTerminateMode.LOOP_DETECTED} (${LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS})`, + ); + + await agent.shutdown(); + }); + it('should process enqueued messages', async () => { const { core } = createMockCore(); const config = createConfig(); diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 1652f9855d8..ea0584158c5 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -7746,6 +7746,103 @@ hello expect(client['pendingMemoryPrefetch']).toBeUndefined(); }); + // Drives sendMessageStream with ToolResult messages whose + // functionResponse ids match previously streamed ToolCallRequest + // callIds, exercising the result-aware recording branch on the main + // interactive path (issue #9450). + async function runTaskListPollTurns( + board: (round: number) => string, + maxRounds = 9, + ) { + const promptId = 'prompt-task-list-poll'; + const taskListArgs = { status: 'in_progress', owner: 'peer-a' }; + const allEvents: Array<{ type: string; value?: unknown }> = []; + for (let round = 0; round <= maxRounds; round++) { + mockTurnRunFn.mockReturnValueOnce( + (async function* () { + yield { + type: GeminiEventType.ToolCallRequest, + value: { + callId: `tl-${round}`, + name: 'task_list', + args: taskListArgs, + isClientInitiated: false, + prompt_id: promptId, + }, + }; + yield { + type: GeminiEventType.ToolCallRequest, + value: { + callId: `other-${round}`, + name: 'tool_b', + args: { step: round }, + isClientInitiated: false, + prompt_id: promptId, + }, + }; + })(), + ); + const contents = + round === 0 + ? [{ text: 'poll the board' }] + : [ + { + functionResponse: { + id: `tl-${round - 1}`, + name: 'task_list', + response: { output: board(round - 1) }, + }, + }, + { + functionResponse: { + id: `other-${round - 1}`, + name: 'tool_b', + response: { output: `step ${round - 1}` }, + }, + }, + ]; + const events = await fromAsync( + client.sendMessageStream( + contents as never, + new AbortController().signal, + promptId, + { + type: + round === 0 + ? SendMessageType.UserQuery + : SendMessageType.ToolResult, + }, + ), + ); + allEvents.push(...(events as Array<{ type: string; value?: unknown }>)); + if ( + allEvents.some((e) => e.type === GeminiEventType.LoopDetected) || + !events.some((e) => e.type === GeminiEventType.ToolCallRequest) + ) { + return allEvents; + } + } + return allEvents; + } + + it('halts the interactive turn when paired ToolResults show a frozen stateful board (#9450)', async () => { + const events = await runTaskListPollTurns(() => 'frozen board'); + const loopEvent = events.find( + (e) => e.type === GeminiEventType.LoopDetected, + ); + expect(loopEvent).toBeDefined(); + expect( + (loopEvent?.value as { loopType?: string } | undefined)?.loopType, + ).toBe('global_tool_call_duplicate'); + }); + + it('keeps the interactive turn alive while paired ToolResults keep changing (#9450)', async () => { + const events = await runTaskListPollTurns((round) => `board v${round}`); + expect(events.some((e) => e.type === GeminiEventType.LoopDetected)).toBe( + false, + ); + }); + it('should halt via the always-on turn cap before the skipLoopDetection gate', async () => { let abortHandlerInvoked = false; mockMemoryManager.recall.mockImplementation((_root, _query, opts) => { diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index 8efed69d829..0bf6207855d 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -2248,10 +2248,13 @@ describe('LoopDetectionService', () => { heuristicService.reset('global-dup'); // Identical task_list calls whose results CHANGE never reach the - // global-duplicate threshold, no matter how they are interleaved. + // global-duplicate threshold, no matter how they are interleaved. Run + // past GLOBAL_DUPLICATE_THRESHOLD rounds so an args-only mutant (which + // would halt on the 6th same-args request) cannot hide in a short + // phase. const interleaved = ['task_list', 'tool_b', 'tool_c']; let stateOrdinal = 0; - for (let round = 0; round < 3; round++) { + for (let round = 0; round < GLOBAL_DUPLICATE_THRESHOLD + 1; round++) { for (const name of interleaved) { const args = name === 'task_list' ? TASK_LIST_ARGS : { step: round }; expect( @@ -2405,5 +2408,174 @@ describe('LoopDetectionService', () => { } expect(fired).toBe(false); }); + + it('restarts result-aware pair counts on Retry under the heuristic gate', () => { + const heuristicService = new LoopDetectionService( + makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), + ); + heuristicService.reset('retry-pair-reset'); + + // Record GLOBAL_DUPLICATE_THRESHOLD - 1 identical frozen (call, + // result) pairs, interleaved with a distinct tool so the consecutive + // guard never fires. + for (let i = 0; i < GLOBAL_DUPLICATE_THRESHOLD - 1; i++) { + expect( + heuristicService.addAndCheck( + createToolCallRequestEvent('tool_b', { step: i }), + ), + ).toBe(false); + expect(heuristicService.addAndCheck(taskListEvent(`call-${i}`))).toBe( + false, + ); + expect( + heuristicService.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult('frozen board'), + ), + ).toBe(false); + } + + expect( + heuristicService.checkAlwaysOnSafeties({ + type: GeminiEventType.Retry, + } as ServerGeminiStreamEvent), + ).toBe(false); + + // The replayed attempt is judged on its own results: one more frozen + // pair is pair #1 after the Retry clear, not #threshold. + expect( + heuristicService.addAndCheck( + createToolCallRequestEvent('tool_b', { step: 'replay' }), + ), + ).toBe(false); + expect(heuristicService.addAndCheck(taskListEvent('replay-0'))).toBe( + false, + ); + expect( + heuristicService.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult('frozen board'), + ), + ).toBe(false); + }); + + it('clears result-aware pair counts across prompts on reset()', () => { + const heuristicService = new LoopDetectionService( + makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), + ); + heuristicService.reset('prompt-1'); + + for (let i = 0; i < GLOBAL_DUPLICATE_THRESHOLD - 1; i++) { + expect( + heuristicService.addAndCheck( + createToolCallRequestEvent('tool_b', { step: i }), + ), + ).toBe(false); + expect(heuristicService.addAndCheck(taskListEvent(`call-${i}`))).toBe( + false, + ); + expect( + heuristicService.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult('frozen board'), + ), + ).toBe(false); + } + + heuristicService.reset('prompt-2'); + + // A poller that saw the same frozen board five times in prompt 1 must + // not trip the global-duplicate gate on its first poll of prompt 2. + expect( + heuristicService.addAndCheck( + createToolCallRequestEvent('tool_b', { step: 'p2' }), + ), + ).toBe(false); + expect(heuristicService.addAndCheck(taskListEvent('p2-0'))).toBe(false); + expect( + heuristicService.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult('frozen board'), + ), + ).toBe(false); + }); + + it('halts an interleaved frozen poller just past the adaptive soft cap', () => { + // CLI default: skipLoopDetection=true. The request-time cap tracker + // skips stateful tools and the result-time global-duplicate halt is + // gated off, so the cap's stuck signal fed by recordToolResult pair + // counts is the only live halt path for an interleaved frozen poller. + const capService = new LoopDetectionService(makeConfig(20)); + capService.reset('cap-frozen'); + + let fired = false; + let totalCalls = 0; + for (let round = 0; round < 40 && !fired; round++) { + fired = capService.checkAlwaysOnSafeties(taskListEvent(`tl-${round}`)); + totalCalls++; + if (fired) break; + fired = capService.checkAlwaysOnSafeties( + createToolCallRequestEvent('tool_b', { step: round }), + ); + totalCalls++; + if (fired) break; + capService.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult('frozen board'), + ); + } + expect(fired).toBe(true); + expect(capService.getLastLoopType()).toBe(LoopType.TURN_TOOL_CALL_CAP); + // Halts just past the soft cap (20) once the stuck signal is armed, + // far below the hard backstop (20 * 10). + expect(totalCalls).toBeLessThanOrEqual(22); + }); + + it('re-judges a resumed streak on fresh result evidence after a streak break', () => { + const unchanged = '#1 [in_progress] @peer-a — task'; + // A 4-call identical streak with unchanged results. + for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD - 1; i++) { + expect(service.checkAlwaysOnSafeties(taskListEvent(`call-${i}`))).toBe( + false, + ); + service.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult(unchanged), + ); + } + + // A different tool breaks the consecutive streak; the result evidence + // accumulated within it must be discarded for both keys. + expect( + service.checkAlwaysOnSafeties( + createToolCallRequestEvent('tool_b', { step: 1 }), + ), + ).toBe(false); + + // Resume identical polling, recording only ONE changed result. + expect(service.checkAlwaysOnSafeties(taskListEvent('resume-0'))).toBe( + false, + ); + service.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult('board changed'), + ); + for (let i = 1; i <= 3; i++) { + expect( + service.checkAlwaysOnSafeties(taskListEvent(`resume-${i}`)), + ).toBe(false); + } + + // The 5th request of the resumed streak expects 4 recorded results but + // only 1 was observed: missing evidence fails safe and halts, keeping + // the #5019 protection. Stale evidence from the broken streak must not + // satisfy the check and restart instead. + expect(service.checkAlwaysOnSafeties(taskListEvent('resume-4'))).toBe( + true, + ); + expect(service.getLastLoopType()).toBe( + LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, + ); + }); }); }); From 2ea0eeabe3c837dc9ef47b46ad1ee8122d682a5a Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sat, 22 Aug 2026 05:05:39 +0800 Subject: [PATCH 05/51] test(core): pin loop_type journal mapping and stateful callId guard (#9450) --- .../src/services/loopDetectionService.test.ts | 62 +++++++++++++++++++ .../telemetry/qwen-logger/qwen-logger.test.ts | 59 ++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index 0bf6207855d..bb5ede69ea5 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -35,6 +35,7 @@ const FILE_READ_WINDOW = 15; const GLOBAL_DUPLICATE_THRESHOLD = 6; const SHELL_COMMAND_STAGNATION_THRESHOLD = 8; const ALTERNATING_PATTERN_CYCLES = 3; +const MAX_TRACKED_TOOL_REQUESTS = 500; describe('LoopDetectionService', () => { let service: LoopDetectionService; @@ -2241,6 +2242,67 @@ describe('LoopDetectionService', () => { ).toBe(false); }); + it('keeps task_list pairing evidence alive through a flood of non-stateful callIds', () => { + // Pins the `&& stateful` condition of the requestByCallId population + // guard (checkAlwaysOnSafeties). If it is dropped, every callId-carrying + // call of a large turn accumulates its full args in the map, the + // eviction past MAX_TRACKED_TOOL_REQUESTS discards the oldest entry — + // here the task_list request itself — and its result can never pair, + // so the result-aware consecutive guard loses its evidence and halts + // productive polling (the #9450 false positive re-shipped). + const floodEvent = (i: number): ServerGeminiToolCallRequestEvent => ({ + type: GeminiEventType.ToolCallRequest, + value: { + name: 'tool_b', + args: { step: i }, + callId: `flood-${i}`, + isClientInitiated: false, + prompt_id: 'test-prompt-id', + }, + }); + + // Request #1 of the task_list streak. + expect(service.checkAlwaysOnSafeties(taskListEvent('tl-1'))).toBe(false); + + // A turn large enough to overflow the callId pairing map with + // non-stateful entries — only possible if the stateful condition goes. + for (let i = 0; i < MAX_TRACKED_TOOL_REQUESTS + 10; i++) { + expect(service.checkAlwaysOnSafeties(floodEvent(i))).toBe(false); + } + + // Resume the identical task_list streak; the interrupted streak + // restarts its result evidence. + expect(service.checkAlwaysOnSafeties(taskListEvent('tl-2'))).toBe(false); + + // Results arrive through the callId pairing, each poll returning a + // changed board. The pre-flood request (tl-1) must still pair even + // though the flood filled the map past its cap. + expect( + service.recordToolResultByCallId('tl-1', taskListResult('v1', 'tl-1')), + ).toBe(false); + expect( + service.recordToolResultByCallId('tl-2', taskListResult('v2', 'tl-2')), + ).toBe(false); + expect(service.checkAlwaysOnSafeties(taskListEvent('tl-3'))).toBe(false); + expect( + service.recordToolResultByCallId('tl-3', taskListResult('v3', 'tl-3')), + ).toBe(false); + expect(service.checkAlwaysOnSafeties(taskListEvent('tl-4'))).toBe(false); + expect( + service.recordToolResultByCallId('tl-4', taskListResult('v4', 'tl-4')), + ).toBe(false); + expect(service.checkAlwaysOnSafeties(taskListEvent('tl-5'))).toBe(false); + + // 5th request of the resumed streak: the result-aware guard wants all + // 4 prior results as evidence. With the stateful condition intact, + // tl-1's changed result survived the flood, the evidence is complete, + // and the changed results keep the polling alive. Without `&& stateful` + // tl-1 was evicted by the flood, evidence falls to 3 < 4, and the + // guard fails safe into a halt. + expect(service.checkAlwaysOnSafeties(taskListEvent('tl-6'))).toBe(false); + expect(loggers.logLoopDetected).not.toHaveBeenCalled(); + }); + it('counts global duplicates on (call, result) pairs when heuristics run', () => { const heuristicService = new LoopDetectionService( makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), diff --git a/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts index d494f12cf62..c2047589027 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts @@ -27,6 +27,7 @@ import { SkillLaunchEvent, ProtocolTagSanitizedEvent, RipgrepRuntimeRecoveryEvent, + SubagentExecutionEvent, type ToolCallEvent, } from '../types.js'; import type { RumEvent, RumPayload } from './event-types.js'; @@ -1070,4 +1071,62 @@ describe('QwenLogger', () => { expect(rumEvent.properties).not.toHaveProperty('mcp_server_name'); }); }); + + describe('logSubagentExecutionEvent', () => { + it('carries loop_type into the subagent_execution journal record', () => { + // Pins the final hop of the #9450 attribution chain: if the + // conditional loop_type spread is dropped or the key is misnamed, + // journal records again log loop stops as unattributable. + const logger = QwenLogger.getInstance(mockConfig)!; + const enqueueSpy = vi.spyOn(logger, 'enqueueLogEvent'); + + const event = new SubagentExecutionEvent('general-purpose', 'failed', { + terminate_reason: 'LOOP_DETECTED', + loop_type: 'consecutive_identical_tool_calls', + }); + + logger.logSubagentExecutionEvent(event); + + expect(enqueueSpy).toHaveBeenCalledWith( + expect.objectContaining({ + event_type: 'action', + type: 'tool', + name: 'subagent_execution', + properties: { + subagent_name: 'general-purpose', + status: 'failed', + terminate_reason: 'LOOP_DETECTED', + loop_type: 'consecutive_identical_tool_calls', + }, + }), + ); + }); + + it('omits loop_type when the run ended without a loop attribution', () => { + const logger = QwenLogger.getInstance(mockConfig)!; + const enqueueSpy = vi.spyOn(logger, 'enqueueLogEvent'); + + const event = new SubagentExecutionEvent('general-purpose', 'completed', { + terminate_reason: 'COMPLETED', + }); + + logger.logSubagentExecutionEvent(event); + + expect(enqueueSpy).toHaveBeenCalledWith( + expect.objectContaining({ + event_type: 'action', + type: 'tool', + name: 'subagent_execution', + properties: { + subagent_name: 'general-purpose', + status: 'completed', + terminate_reason: 'COMPLETED', + }, + }), + ); + expect(enqueueSpy.mock.calls[0][0].properties).not.toHaveProperty( + 'loop_type', + ); + }); + }); }); From 8b5b610d2ecfb9d6e23c0e1a84def3c44cc14af9 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sat, 22 Aug 2026 11:05:44 +0800 Subject: [PATCH 06/51] fix(core): keep result-aware loop guards alive for persisted oversized results (#9450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Oversized results are rewritten into persistence stubs whose envelope embeds a per-call unique file path (/.txt), so fingerprinting the whole model-visible response made every fingerprint unique and silently disabled every result-aware guard for exactly the largest results: a frozen task_list board over the persistence gate was never halted. Fingerprint the semantic payload instead — strip the stub envelope and hash the preview/truncated content that follows the stable marker. --- .../src/services/loopDetectionService.test.ts | 130 ++++++++++++++++++ .../core/src/services/loopDetectionService.ts | 48 ++++++- 2 files changed, 177 insertions(+), 1 deletion(-) diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index bb5ede69ea5..5c1c6113c95 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -2639,5 +2639,135 @@ describe('LoopDetectionService', () => { LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, ); }); + + describe('persisted oversized results (issue #9450 follow-up)', () => { + // Results over the response-finalizer budget are rewritten into + // persistence stubs (utils/truncation.ts buildStub) whose envelope + // embeds a per-call unique file path (`.txt`). The guards + // fingerprint the model-visible finalized parts, so hashing the + // envelope would make every fingerprint unique and silently disable + // every result-aware guard for exactly the largest results. These + // tests mirror buildStub's file-path shape with a unique path per + // poll. + const FROZEN_BOARD = 'task row for a frozen board\n'.repeat(1500); // ~41KB + + const persistedStub = (callId: string, board: string): string => + ` +Output too large (40 KB). Full output saved to: /tmp/qwen/tool-results/${callId}.txt +Note: this file may be cleaned up after 24 hours. +To read the complete output, use the read_file tool with the absolute file path above. + +Preview (up to 2000 chars): +${board} +`; + + const stubResult = (callId: string, board: string): Part[] => + taskListResult(persistedStub(callId, board), callId); + + it('halts on a frozen oversized board despite per-call unique stub paths', () => { + let fired = false; + for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD; i++) { + fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); + if (fired) break; + service.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + stubResult(`poll_${i}`, FROZEN_BOARD), + ); + } + expect(fired).toBe(true); + expect(service.getLastLoopType()).toBe( + LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, + ); + }); + + it('keeps oversized polling alive while the board keeps changing', () => { + // Guards against over-collapsing: the envelope must be stripped, not + // the preview — changed boards inside unique-path stubs are still + // observable progress. + let fired = false; + for (let i = 0; i < 4 * TOOL_CALL_LOOP_THRESHOLD; i++) { + fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); + if (fired) break; + service.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + stubResult(`poll_${i}`, `board state v${i}`), + ); + } + expect(fired).toBe(false); + expect(loggers.logLoopDetected).not.toHaveBeenCalled(); + }); + + it('counts global duplicates on frozen oversized results when heuristics run', () => { + const heuristicService = new LoopDetectionService( + makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), + ); + heuristicService.reset('global-dup-persisted'); + + const interleaved = ['task_list', 'tool_b', 'tool_c']; + let detected = false; + for ( + let round = 0; + round < GLOBAL_DUPLICATE_THRESHOLD && !detected; + round++ + ) { + for (const name of interleaved) { + const args = + name === 'task_list' ? TASK_LIST_ARGS : { step: round }; + if ( + heuristicService.addAndCheck( + createToolCallRequestEvent(name, args), + ) + ) { + detected = true; + break; + } + if (name === 'task_list') { + detected = heuristicService.recordToolResult( + { name, args }, + stubResult(`poll_${round}`, FROZEN_BOARD), + ); + if (detected) break; + } + } + } + expect(detected).toBe(true); + expect(heuristicService.getLastLoopType()).toBe( + LoopType.GLOBAL_TOOL_CALL_DUPLICATE, + ); + }); + + it('halts an interleaved frozen oversized poller just past the adaptive soft cap', () => { + // CLI default: skipLoopDetection=true. The cap's stuck signal fed by + // recordToolResult pair counts is then the only live halt path for + // an interleaved frozen poller; unique stub paths must not keep it + // judging the turn productive until the hard backstop. + const capService = new LoopDetectionService(makeConfig(20)); + capService.reset('cap-frozen-persisted'); + + let fired = false; + let totalCalls = 0; + for (let round = 0; round < 40 && !fired; round++) { + fired = capService.checkAlwaysOnSafeties( + taskListEvent(`tl-${round}`), + ); + totalCalls++; + if (fired) break; + fired = capService.checkAlwaysOnSafeties( + createToolCallRequestEvent('tool_b', { step: round }), + ); + totalCalls++; + if (fired) break; + capService.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + stubResult(`tl-${round}`, FROZEN_BOARD), + ); + } + expect(fired).toBe(true); + expect(capService.getLastLoopType()).toBe(LoopType.TURN_TOOL_CALL_CAP); + // Halts just past the soft cap (20) once the stuck signal is armed, + // far below the hard backstop (20 * 10). + expect(totalCalls).toBeLessThanOrEqual(22); + }); + }); }); }); diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 9fc7e780e1e..41d17d523bf 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -401,11 +401,57 @@ export class LoopDetectionService { for (const part of responseParts) { const functionResponse = part.functionResponse; if (!functionResponse) continue; - chunks.push(JSON.stringify(functionResponse.response ?? {})); + // Oversized results arrive as persistence stubs whose envelope embeds + // a per-call unique file path; fingerprint the semantic payload only + // (see stripPersistenceEnvelope) so identical underlying results stay + // identical no matter where they were persisted. + chunks.push( + JSON.stringify(functionResponse.response ?? {}, (_key, value) => + typeof value === 'string' + ? LoopDetectionService.stripPersistenceEnvelope(value) + : value, + ), + ); } return chunks.length > 0 ? chunks.join('\n') : null; } + /** + * Oversized tool results are rewritten by the response finalizer into + * truncation stubs (see utils/truncation.ts): a `` + * envelope embedding the unique `/.txt` path, an + * unwrapped `Output too large (...)` envelope whose session-dependent note + * can also vary between calls, or the `truncateAndSaveToFile` fallback + * embedding a random temp-file name. Hashing the envelope would make every + * fingerprint unique per call — silently disabling every result-aware + * guard for exactly the largest results — so reduce a stub to its + * semantic payload: the preview/truncated content that follows the stable + * marker. The `` sentinel keeps a stub fingerprint from + * ever colliding with a small literal output that matches the payload. + */ + private static stripPersistenceEnvelope(value: string): string { + const isPreviewStub = + value.includes('') || + value.startsWith('Output too large ('); + if (isPreviewStub) { + const marker = /Preview \(up to \d+ chars\):\n/.exec(value); + if (marker) { + return `${value.slice( + marker.index + marker[0].length, + )}`; + } + return value; + } + if (value.startsWith('Tool output was too large and has been truncated')) { + const marker = '\nTruncated part of the output:\n'; + const index = value.indexOf(marker); + if (index >= 0) { + return `${value.slice(index + marker.length)}`; + } + } + return value; + } + private getToolCallKey(toolCall: { name: string; args: object }): string { return getToolCallRepeatKey(toolCall.name, toolCall.args); } From 96eeb5cb2affbc9eb3bdffc7ed70e116af7e0a96 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sat, 22 Aug 2026 14:33:14 +0800 Subject: [PATCH 07/51] refactor(core): keep loop-guard stub parsing in lockstep with the stub builder (#9450) --- .../src/services/loopDetectionService.test.ts | 19 +++++----- .../core/src/services/loopDetectionService.ts | 30 +++++++++------ packages/core/src/utils/truncation.ts | 37 ++++++++++++++----- 3 files changed, 55 insertions(+), 31 deletions(-) diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index 5c1c6113c95..fe5cf0420e9 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -16,6 +16,7 @@ import type { import { GeminiEventType } from '../core/turn.js'; import * as loggers from '../telemetry/loggers.js'; import { LoopType } from '../telemetry/types.js'; +import { buildStub } from '../utils/truncation.js'; import { DEFAULT_MAX_TOOL_CALLS_PER_TURN, LoopDetectionService, @@ -2647,19 +2648,17 @@ describe('LoopDetectionService', () => { // fingerprint the model-visible finalized parts, so hashing the // envelope would make every fingerprint unique and silently disable // every result-aware guard for exactly the largest results. These - // tests mirror buildStub's file-path shape with a unique path per - // poll. + // tests build their stubs with the real builder so a format change + // in truncation.ts fails here loudly instead of leaving the guards + // parsing stale hand-mirrored shapes. const FROZEN_BOARD = 'task row for a frozen board\n'.repeat(1500); // ~41KB const persistedStub = (callId: string, board: string): string => - ` -Output too large (40 KB). Full output saved to: /tmp/qwen/tool-results/${callId}.txt -Note: this file may be cleaned up after 24 hours. -To read the complete output, use the read_file tool with the absolute file path above. - -Preview (up to 2000 chars): -${board} -`; + buildStub( + board, + Buffer.byteLength(board, 'utf-8'), + `/tmp/qwen/tool-results/${callId}.txt`, + ); const stubResult = (callId: string, board: string): Part[] => taskListResult(persistedStub(callId, board), callId); diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 41d17d523bf..30a9af21e10 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -20,6 +20,13 @@ import { } from '../telemetry/types.js'; import type { Config } from '../config/config.js'; import { getToolCallRepeatKey } from '../utils/tool-call-repeat-key.js'; +import { + OUTPUT_TOO_LARGE_PREFIX, + PERSISTED_OUTPUT_OPEN_TAG, + PERSISTED_PREVIEW_MARKER, + TOOL_OUTPUT_TRUNCATED_PREFIX, + TRUNCATED_PART_MARKER, +} from '../utils/truncation.js'; // Re-exported for existing importers (daemon turn-loop guard); the // implementation lives in a leaf module so replay detection in @@ -426,24 +433,25 @@ export class LoopDetectionService { * fingerprint unique per call — silently disabling every result-aware * guard for exactly the largest results — so reduce a stub to its * semantic payload: the preview/truncated content that follows the stable - * marker. The `` sentinel keeps a stub fingerprint from - * ever colliding with a small literal output that matches the payload. + * marker. The markers are the shared constants from utils/truncation.ts + * so the parser cannot drift from the producer. The `` + * sentinel keeps a stub fingerprint from ever colliding with a small + * literal output that matches the payload. */ private static stripPersistenceEnvelope(value: string): string { const isPreviewStub = - value.includes('') || - value.startsWith('Output too large ('); + value.includes(PERSISTED_OUTPUT_OPEN_TAG) || + value.startsWith(OUTPUT_TOO_LARGE_PREFIX); if (isPreviewStub) { - const marker = /Preview \(up to \d+ chars\):\n/.exec(value); - if (marker) { - return `${value.slice( - marker.index + marker[0].length, - )}`; + const marker = `${PERSISTED_PREVIEW_MARKER}\n`; + const index = value.indexOf(marker); + if (index >= 0) { + return `${value.slice(index + marker.length)}`; } return value; } - if (value.startsWith('Tool output was too large and has been truncated')) { - const marker = '\nTruncated part of the output:\n'; + if (value.startsWith(TOOL_OUTPUT_TRUNCATED_PREFIX)) { + const marker = `\n${TRUNCATED_PART_MARKER}`; const index = value.indexOf(marker); if (index >= 0) { return `${value.slice(index + marker.length)}`; diff --git a/packages/core/src/utils/truncation.ts b/packages/core/src/utils/truncation.ts index 5979db096fa..276112b0370 100644 --- a/packages/core/src/utils/truncation.ts +++ b/packages/core/src/utils/truncation.ts @@ -17,7 +17,7 @@ import { ToolOutputTruncatedEvent } from '../telemetry/types.js'; const debugLogger = createDebugLogger('TRUNCATION'); -const PREVIEW_SIZE_CHARS = 2000; +export const PREVIEW_SIZE_CHARS = 2000; const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50MB export const MAX_SESSION_BYTES = 500 * 1024 * 1024; // 500MB @@ -30,6 +30,17 @@ export const MAX_SESSION_BYTES = 500 * 1024 * 1024; // 500MB export const TOOL_OUTPUT_TRUNCATED_PREFIX = 'Tool output was too large and has been truncated'; +/** + * Format markers of the oversized-result stubs this module emits. Exported + * so consumers that parse stubs (the loop guards in + * services/loopDetectionService.ts) share the producer's constants instead + * of hand-mirroring literals that can silently drift. + */ +export const PERSISTED_OUTPUT_OPEN_TAG = ''; +export const OUTPUT_TOO_LARGE_PREFIX = 'Output too large ('; +export const PERSISTED_PREVIEW_MARKER = `Preview (up to ${PREVIEW_SIZE_CHARS} chars):`; +export const TRUNCATED_PART_MARKER = 'Truncated part of the output:\n'; + /** * Tolerance factor applied by the scheduler's combined (second) pass: * metadata appended after truncation is only re-bounded above 2x the @@ -187,8 +198,7 @@ The full output has been saved to: ${outputFile} To read the complete output, use the ${ReadFileTool.Name} tool with the absolute file path above. The truncated output below shows the beginning and end of the content. The marker '... [CONTENT TRUNCATED] ...' indicates where content was removed. -Truncated part of the output: -${truncatedContent}`; +${TRUNCATED_PART_MARKER}${truncatedContent}`; // Token-aware fallback: if the wrapped (truncated + instructions) output is // not actually smaller than the original, truncating wastes effort and @@ -378,7 +388,7 @@ export async function truncateLlmContent( export function isAlreadyTruncated(content: string): boolean { return ( content.includes('... [CONTENT TRUNCATED] ...') || - content.startsWith('') + content.startsWith(PERSISTED_OUTPUT_OPEN_TAG) ); } @@ -505,7 +515,14 @@ export async function persistAndTruncateToolResult( } } -function buildStub( +/** + * Builds the model-visible stub that replaces an oversized tool result. + * `filePathOrNote` is either the absolute path of the persisted full output + * (wrapped `` envelope) or a short note explaining why it + * was not persisted (unwrapped stub). Exported so tests build stubs with + * the real producer instead of hand-mirroring its format. + */ +export function buildStub( content: string, byteSize: number, filePathOrNote: string, @@ -515,18 +532,18 @@ function buildStub( const isFilePath = path.isAbsolute(filePathOrNote); if (isFilePath) { - return ` -Output too large (${sizeKb} KB). Full output saved to: ${filePathOrNote} + return `${PERSISTED_OUTPUT_OPEN_TAG} +${OUTPUT_TOO_LARGE_PREFIX}${sizeKb} KB). Full output saved to: ${filePathOrNote} Note: this file may be cleaned up after 24 hours. To read the complete output, use the ${ReadFileTool.Name} tool with the absolute file path above. -Preview (up to ${PREVIEW_SIZE_CHARS} chars): +${PERSISTED_PREVIEW_MARKER} ${preview} `; } - return `Output too large (${sizeKb} KB). ${filePathOrNote} + return `${OUTPUT_TOO_LARGE_PREFIX}${sizeKb} KB). ${filePathOrNote} -Preview (up to ${PREVIEW_SIZE_CHARS} chars): +${PERSISTED_PREVIEW_MARKER} ${preview}`; } From 164a9a8e22c5a3863c98b66ef3b4f537362c8f89 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sat, 22 Aug 2026 14:35:01 +0800 Subject: [PATCH 08/51] fix(core): fingerprint the full output in persistence stubs (#9450) --- .../src/services/loopDetectionService.test.ts | 26 ++++++++++++++++++- .../core/src/services/loopDetectionService.ts | 23 ++++++++++++---- packages/core/src/utils/truncation.ts | 24 ++++++++++++++--- 3 files changed, 63 insertions(+), 10 deletions(-) diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index fe5cf0420e9..f1ca72d1dfc 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -16,7 +16,7 @@ import type { import { GeminiEventType } from '../core/turn.js'; import * as loggers from '../telemetry/loggers.js'; import { LoopType } from '../telemetry/types.js'; -import { buildStub } from '../utils/truncation.js'; +import { buildStub, PREVIEW_SIZE_CHARS } from '../utils/truncation.js'; import { DEFAULT_MAX_TOOL_CALLS_PER_TURN, LoopDetectionService, @@ -2696,6 +2696,30 @@ describe('LoopDetectionService', () => { expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); + it('keeps oversized polling alive when the board changes beyond the preview window', () => { + // buildStub previews only the first PREVIEW_SIZE_CHARS chars, so a + // board whose mutations land beyond that window hashes to an + // identical preview on every poll. The full-output digest embedded + // in the stub must keep the fingerprints distinct; without it the + // always-on guard halts this productive poller at the 5th identical + // request. + const headerLine = 'task row header line\n'; + const header = headerLine.repeat( + Math.ceil(PREVIEW_SIZE_CHARS / headerLine.length) + 10, + ); + let fired = false; + for (let i = 0; i < 4 * TOOL_CALL_LOOP_THRESHOLD; i++) { + fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); + if (fired) break; + service.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + stubResult(`poll_${i}`, `${header}tail state v${i}`), + ); + } + expect(fired).toBe(false); + expect(loggers.logLoopDetected).not.toHaveBeenCalled(); + }); + it('counts global duplicates on frozen oversized results when heuristics run', () => { const heuristicService = new LoopDetectionService( makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 30a9af21e10..904bf8a93b2 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -21,6 +21,7 @@ import { import type { Config } from '../config/config.js'; import { getToolCallRepeatKey } from '../utils/tool-call-repeat-key.js'; import { + FULL_OUTPUT_DIGEST_LABEL, OUTPUT_TOO_LARGE_PREFIX, PERSISTED_OUTPUT_OPEN_TAG, PERSISTED_PREVIEW_MARKER, @@ -432,17 +433,29 @@ export class LoopDetectionService { * embedding a random temp-file name. Hashing the envelope would make every * fingerprint unique per call — silently disabling every result-aware * guard for exactly the largest results — so reduce a stub to its - * semantic payload: the preview/truncated content that follows the stable - * marker. The markers are the shared constants from utils/truncation.ts - * so the parser cannot drift from the producer. The `` - * sentinel keeps a stub fingerprint from ever colliding with a small - * literal output that matches the payload. + * semantic payload. buildStub embeds a sha256 of the full pre-truncation + * output (FULL_OUTPUT_DIGEST_LABEL); prefer it, because the preview only + * covers the first PREVIEW_SIZE_CHARS chars — a board mutating beyond + * that window must still fingerprint differently each poll (and a frozen + * board identically). Stubs without a digest line fall back to the + * preview/truncated content after the stable marker. The markers are the + * shared constants from utils/truncation.ts so the parser cannot drift + * from the producer. The `` sentinel keeps a stub + * fingerprint from ever colliding with a small literal output that + * matches the payload. */ private static stripPersistenceEnvelope(value: string): string { const isPreviewStub = value.includes(PERSISTED_OUTPUT_OPEN_TAG) || value.startsWith(OUTPUT_TOO_LARGE_PREFIX); if (isPreviewStub) { + const digestIndex = value.indexOf(FULL_OUTPUT_DIGEST_LABEL); + if (digestIndex >= 0) { + const digestStart = digestIndex + FULL_OUTPUT_DIGEST_LABEL.length; + // sha256 hex digest length + const digest = value.slice(digestStart, digestStart + 64); + return `sha256:${digest}`; + } const marker = `${PERSISTED_PREVIEW_MARKER}\n`; const index = value.indexOf(marker); if (index >= 0) { diff --git a/packages/core/src/utils/truncation.ts b/packages/core/src/utils/truncation.ts index 276112b0370..e54dce5db47 100644 --- a/packages/core/src/utils/truncation.ts +++ b/packages/core/src/utils/truncation.ts @@ -41,6 +41,16 @@ export const OUTPUT_TOO_LARGE_PREFIX = 'Output too large ('; export const PERSISTED_PREVIEW_MARKER = `Preview (up to ${PREVIEW_SIZE_CHARS} chars):`; export const TRUNCATED_PART_MARKER = 'Truncated part of the output:\n'; +/** + * Label of the line `buildStub` embeds carrying a sha256 of the FULL + * pre-truncation output. The preview only covers the first + * PREVIEW_SIZE_CHARS chars, so consumers that fingerprint results (the loop + * guards) preserve this digest to stay sensitive to mutations that land + * beyond the preview window (a task board whose changes sit past char 2000 + * would otherwise fingerprint identically on every poll). + */ +export const FULL_OUTPUT_DIGEST_LABEL = 'Full output sha256: '; + /** * Tolerance factor applied by the scheduler's combined (second) pass: * metadata appended after truncation is only re-bounded above 2x the @@ -517,10 +527,12 @@ export async function persistAndTruncateToolResult( /** * Builds the model-visible stub that replaces an oversized tool result. - * `filePathOrNote` is either the absolute path of the persisted full output - * (wrapped `` envelope) or a short note explaining why it - * was not persisted (unwrapped stub). Exported so tests build stubs with - * the real producer instead of hand-mirroring its format. + * Embeds a sha256 of the full `content` (see FULL_OUTPUT_DIGEST_LABEL) so + * result fingerprinting stays faithful to mutations the head-only preview + * cuts off. `filePathOrNote` is either the absolute path of the persisted + * full output (wrapped `` envelope) or a short note + * explaining why it was not persisted (unwrapped stub). Exported so tests + * build stubs with the real producer instead of hand-mirroring its format. */ export function buildStub( content: string, @@ -529,6 +541,8 @@ export function buildStub( ): string { const preview = generatePreview(content); const sizeKb = Math.round(byteSize / 1024); + const digest = crypto.createHash('sha256').update(content).digest('hex'); + const digestLine = `${FULL_OUTPUT_DIGEST_LABEL}${digest}`; const isFilePath = path.isAbsolute(filePathOrNote); if (isFilePath) { @@ -536,6 +550,7 @@ export function buildStub( ${OUTPUT_TOO_LARGE_PREFIX}${sizeKb} KB). Full output saved to: ${filePathOrNote} Note: this file may be cleaned up after 24 hours. To read the complete output, use the ${ReadFileTool.Name} tool with the absolute file path above. +${digestLine} ${PERSISTED_PREVIEW_MARKER} ${preview} @@ -543,6 +558,7 @@ ${preview} } return `${OUTPUT_TOO_LARGE_PREFIX}${sizeKb} KB). ${filePathOrNote} +${digestLine} ${PERSISTED_PREVIEW_MARKER} ${preview}`; From 0b619f570bd36bffb8940c12a06fd595cc3adda3 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sat, 22 Aug 2026 14:36:50 +0800 Subject: [PATCH 09/51] test(core): cover unwrapped and truncated oversized stub shapes (#9450) --- .../src/services/loopDetectionService.test.ts | 73 ++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index f1ca72d1dfc..b3c31859c96 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -5,6 +5,9 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; import type { Part } from '@google/genai'; import type { Config } from '../config/config.js'; import type { @@ -16,7 +19,11 @@ import type { import { GeminiEventType } from '../core/turn.js'; import * as loggers from '../telemetry/loggers.js'; import { LoopType } from '../telemetry/types.js'; -import { buildStub, PREVIEW_SIZE_CHARS } from '../utils/truncation.js'; +import { + buildStub, + PREVIEW_SIZE_CHARS, + truncateAndSaveToFile, +} from '../utils/truncation.js'; import { DEFAULT_MAX_TOOL_CALLS_PER_TURN, LoopDetectionService, @@ -2791,6 +2798,70 @@ describe('LoopDetectionService', () => { // far below the hard backstop (20 * 10). expect(totalCalls).toBeLessThanOrEqual(22); }); + + it('halts on a frozen unwrapped oversized stub (disk unavailable)', () => { + // buildStub's unwrapped shape (no `` tag) is + // emitted when disk persistence is unavailable. Its note depends on + // the failure mode, so alternate the two notes across polls: only a + // guard that recognizes the shape and reduces it to the payload can + // see through the varying envelope to the frozen board. + const notes = [ + '(file too large to persist)', + '(session disk budget exhausted)', + ]; + let fired = false; + for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD; i++) { + fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); + if (fired) break; + const stub = buildStub( + FROZEN_BOARD, + Buffer.byteLength(FROZEN_BOARD, 'utf-8'), + notes[i % notes.length], + ); + service.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult(stub, `poll_${i}`), + ); + } + expect(fired).toBe(true); + expect(service.getLastLoopType()).toBe( + LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, + ); + }); + + it('halts on a frozen truncated-output stub despite per-call unique file paths', async () => { + // The truncateAndSaveToFile shape (TOOL_OUTPUT_TRUNCATED_PREFIX) + // embeds a per-call file path in its envelope; a frozen board must + // still halt. The real builder spills its file, so give it a + // throwaway directory. + const spillDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'loop-detection-stub-'), + ); + try { + let fired = false; + for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD; i++) { + fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); + if (fired) break; + const { content } = await truncateAndSaveToFile( + FROZEN_BOARD, + `task_list_poll_${i}`, + spillDir, + 1024, + 20, + ); + service.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult(content, `poll_${i}`), + ); + } + expect(fired).toBe(true); + expect(service.getLastLoopType()).toBe( + LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, + ); + } finally { + await fs.rm(spillDir, { recursive: true, force: true }); + } + }); }); }); }); From f4e85c7417ae9e25e50bdf958b3f974881905668 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sat, 22 Aug 2026 19:05:09 +0800 Subject: [PATCH 10/51] fix(core): fingerprint the full output in truncated stubs (#9450) truncateAndSaveToFile's stub shape carried no full-output digest (unlike buildStub), so a shared board mutating in the truncated middle band fingerprinted identically on every poll and the result-aware guards halted a productive poller. Embed the sha256 of the full pre-truncation output in both the wrapped and the unsaved (disk-failure) shapes, and make the loop guards' stub parser prefer the digest in every shape before falling back to the visible payload. --- .../src/services/loopDetectionService.test.ts | 39 +++++++++++++ .../core/src/services/loopDetectionService.ts | 55 ++++++++++--------- packages/core/src/utils/truncation.ts | 11 +++- 3 files changed, 78 insertions(+), 27 deletions(-) diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index b3c31859c96..5c4b25fcbea 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -2862,6 +2862,45 @@ describe('LoopDetectionService', () => { await fs.rm(spillDir, { recursive: true, force: true }); } }); + + it('keeps truncated-output polling alive when the board changes in the truncated middle band', async () => { + // truncateAndSaveToFile retains a head and a tail and drops the + // middle band, so a board mutating inside that band hashes to an + // identical head+tail payload on every poll. The full-output digest + // embedded in the envelope must keep the fingerprints distinct; + // without it the always-on guard halts this productive poller at + // the 5th identical request. + const spillDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'loop-detection-stub-'), + ); + const head = 'task row head line\n'.repeat(30); + const tail = 'task row tail line\n'.repeat(30); + try { + let fired = false; + for (let i = 0; i < 4 * TOOL_CALL_LOOP_THRESHOLD; i++) { + fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); + if (fired) break; + const middle = `middle band state v${i}\n`.repeat(400); + const { content } = await truncateAndSaveToFile( + `${head}${middle}${tail}`, + `task_list_poll_${i}`, + spillDir, + 1024, + Number.POSITIVE_INFINITY, + 'both', + 400, + ); + service.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult(content, `poll_${i}`), + ); + } + expect(fired).toBe(false); + expect(loggers.logLoopDetected).not.toHaveBeenCalled(); + } finally { + await fs.rm(spillDir, { recursive: true, force: true }); + } + }); }); }); }); diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 904bf8a93b2..3a57bd805da 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -425,37 +425,42 @@ export class LoopDetectionService { } /** - * Oversized tool results are rewritten by the response finalizer into - * truncation stubs (see utils/truncation.ts): a `` - * envelope embedding the unique `/.txt` path, an - * unwrapped `Output too large (...)` envelope whose session-dependent note - * can also vary between calls, or the `truncateAndSaveToFile` fallback - * embedding a random temp-file name. Hashing the envelope would make every - * fingerprint unique per call — silently disabling every result-aware - * guard for exactly the largest results — so reduce a stub to its - * semantic payload. buildStub embeds a sha256 of the full pre-truncation - * output (FULL_OUTPUT_DIGEST_LABEL); prefer it, because the preview only - * covers the first PREVIEW_SIZE_CHARS chars — a board mutating beyond - * that window must still fingerprint differently each poll (and a frozen - * board identically). Stubs without a digest line fall back to the - * preview/truncated content after the stable marker. The markers are the - * shared constants from utils/truncation.ts so the parser cannot drift - * from the producer. The `` sentinel keeps a stub - * fingerprint from ever colliding with a small literal output that - * matches the payload. + * Oversized tool results are rewritten into truncation stubs (see + * utils/truncation.ts and the batch-budget finalizer): a + * `` envelope embedding the unique + * `/.txt` path, an unwrapped `Output too large + * (...)` envelope whose session-dependent note can also vary between + * calls, the `truncateAndSaveToFile` shape embedding a random temp-file + * name, or a batch-budget fit whose header embeds a per-call artifact + * path. Hashing the envelope would make every fingerprint unique per call + * — silently disabling every result-aware guard for exactly the largest + * results — so reduce a stub to its semantic payload. The producers embed + * a sha256 of the full pre-truncation output (FULL_OUTPUT_DIGEST_LABEL); + * prefer it over any visible content, because previews and head+tail + * payloads only cover the first/last chars — a board mutating in the + * dropped band must still fingerprint differently each poll (and a + * frozen board identically) no matter which stub shape carries it. The + * digest-first rule also covers stubs nested inside a further batch-budget + * fit, where the outer digest fingerprints the inner stub as a whole. + * Stubs without a digest line fall back to the shape's visible payload + * after its stable marker. The markers are the shared constants from + * utils/truncation.ts so the parser cannot drift from the producer. The + * `` sentinel keeps a stub fingerprint from ever + * colliding with a small literal output that matches the payload. */ private static stripPersistenceEnvelope(value: string): string { + const digestIndex = value.indexOf(FULL_OUTPUT_DIGEST_LABEL); + if (digestIndex >= 0) { + const digestStart = digestIndex + FULL_OUTPUT_DIGEST_LABEL.length; + // sha256 hex digest length + const digest = value.slice(digestStart, digestStart + 64); + return `sha256:${digest}`; + } + const isPreviewStub = value.includes(PERSISTED_OUTPUT_OPEN_TAG) || value.startsWith(OUTPUT_TOO_LARGE_PREFIX); if (isPreviewStub) { - const digestIndex = value.indexOf(FULL_OUTPUT_DIGEST_LABEL); - if (digestIndex >= 0) { - const digestStart = digestIndex + FULL_OUTPUT_DIGEST_LABEL.length; - // sha256 hex digest length - const digest = value.slice(digestStart, digestStart + 64); - return `sha256:${digest}`; - } const marker = `${PERSISTED_PREVIEW_MARKER}\n`; const index = value.indexOf(marker); if (index >= 0) { diff --git a/packages/core/src/utils/truncation.ts b/packages/core/src/utils/truncation.ts index e54dce5db47..397d3e69efa 100644 --- a/packages/core/src/utils/truncation.ts +++ b/packages/core/src/utils/truncation.ts @@ -203,10 +203,16 @@ export async function truncateAndSaveToFile( // Sanitize fileName to prevent path traversal. const safeFileName = `${path.basename(fileName)}.output`; const outputFile = path.join(projectTempDir, safeFileName); + // sha256 of the FULL pre-truncation output (see FULL_OUTPUT_DIGEST_LABEL): + // the head+tail below drops the middle band, so consumers that fingerprint + // results (the loop guards) need the digest to stay sensitive to mutations + // landing in that band (issue #9450). + const fullDigest = crypto.createHash('sha256').update(content).digest('hex'); const wrappedMessage = `${TOOL_OUTPUT_TRUNCATED_PREFIX}. The full output has been saved to: ${outputFile} To read the complete output, use the ${ReadFileTool.Name} tool with the absolute file path above. The truncated output below shows the beginning and end of the content. The marker '... [CONTENT TRUNCATED] ...' indicates where content was removed. +${FULL_OUTPUT_DIGEST_LABEL}${fullDigest} ${TRUNCATED_PART_MARKER}${truncatedContent}`; @@ -235,9 +241,10 @@ ${TRUNCATED_PART_MARKER}${truncatedContent}`; `Failed to save truncated output to ${outputFile}:`, error, ); + // Keep the digest even on the unsaved path: the fingerprinting + // consumers must not regress to the head+tail-only payload here. return { - content: - truncatedContent + `\n[Note: Could not save full output to file]`, + content: `${FULL_OUTPUT_DIGEST_LABEL}${fullDigest}\n${truncatedContent}\n[Note: Could not save full output to file]`, }; } } From 167b0af5c8327d28bd8fe43eb2c9bd2e06d7a1e3 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sat, 22 Aug 2026 19:09:27 +0800 Subject: [PATCH 11/51] fix(core): fingerprint batch-budget fits for the loop guards (#9450) The batch-budget finalizer's fitText header embeds a per-call artifact path, so every oversized batch-budget result fingerprinted uniquely and the result-aware loop guards were silently disabled for exactly those results. Embed the sha256 of the full pre-fit text in the header (right after the constant prefix so tiny allocations that slice the header still keep it); the guards' digest-first stub parsing picks it up. --- .../src/services/loopDetectionService.test.ts | 68 +++++++++++++++++++ .../core/src/utils/tool-response-finalizer.ts | 21 ++++-- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index 5c4b25fcbea..9d454fe11f0 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -19,6 +19,7 @@ import type { import { GeminiEventType } from '../core/turn.js'; import * as loggers from '../telemetry/loggers.js'; import { LoopType } from '../telemetry/types.js'; +import { enforceFunctionResponseBudget } from '../utils/tool-response-finalizer.js'; import { buildStub, PREVIEW_SIZE_CHARS, @@ -2901,6 +2902,73 @@ describe('LoopDetectionService', () => { await fs.rm(spillDir, { recursive: true, force: true }); } }); + + // The batch-budget finalizer (fitText) rewrites oversized results into + // a header embedding a per-call artifact path plus a head/tail fit. + // Built through the real budget enforcer so the guard is tested + // against the producer's actual shape. + const batchBudgetResult = (callId: string, board: string): Part[] => { + const fitted = enforceFunctionResponseBudget( + [ + { + callId, + toolName: 'task_list', + responseParts: [ + { + functionResponse: { + id: callId, + name: 'task_list', + response: { output: board }, + }, + }, + ], + persistedOutputFiles: [`/tmp/qwen/tool-results/${callId}.txt`], + }, + ], + 1500, + ); + return fitted[0].responseParts; + }; + + it('halts on a frozen batch-budget result despite per-call unique artifact paths', () => { + // Without the full-output digest in the fitText header, the unique + // artifact path fingerprints every poll uniquely and the guard + // never sees the frozen board. + let fired = false; + for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD; i++) { + fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); + if (fired) break; + service.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + batchBudgetResult(`poll_${i}`, FROZEN_BOARD), + ); + } + expect(fired).toBe(true); + expect(service.getLastLoopType()).toBe( + LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, + ); + }); + + it('keeps batch-budget polling alive when the board changes beyond the fitted window', () => { + // fitText retains a head and a tail and drops the middle band, so a + // board mutating there fits to an identical head+tail on every + // poll. The digest must cover the FULL pre-fit text (not the + // fitted payload), or the guard halts this productive poller. + const head = 'task row head line\n'.repeat(30); + const tail = 'task row tail line\n'.repeat(80); + let fired = false; + for (let i = 0; i < 4 * TOOL_CALL_LOOP_THRESHOLD; i++) { + fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); + if (fired) break; + const middle = `middle band state v${i}\n`.repeat(800); + service.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + batchBudgetResult(`poll_${i}`, `${head}${middle}${tail}`), + ); + } + expect(fired).toBe(false); + expect(loggers.logLoopDetected).not.toHaveBeenCalled(); + }); }); }); }); diff --git a/packages/core/src/utils/tool-response-finalizer.ts b/packages/core/src/utils/tool-response-finalizer.ts index a522f428281..ffe588ecebc 100644 --- a/packages/core/src/utils/tool-response-finalizer.ts +++ b/packages/core/src/utils/tool-response-finalizer.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { createHash } from 'node:crypto'; import type { Part } from '@google/genai'; import type { Config } from '../config/config.js'; import type { ToolArtifact } from '../tools/tools.js'; @@ -16,6 +17,7 @@ import { type ToolResultBoundaryStage, } from './tool-result-boundary-diagnostics.js'; import { + FULL_OUTPUT_DIGEST_LABEL, normalizeToolResultCallId, persistAndTruncateToolResult, } from './truncation.js'; @@ -215,14 +217,25 @@ function fitText( if (text.length <= maxChars) return text; if (maxChars <= 0) return ''; - const header = + // sha256 of the full pre-fit text (FULL_OUTPUT_DIGEST_LABEL). The header + // embeds a per-call artifact path, so hashing the fitted output would + // fingerprint every call uniquely and silently disable the result-aware + // loop guards for exactly these oversized batch-budget results (issue + // #9450). The digest sits right after the constant prefix so it survives + // even when a tiny allocation slices the header. + const digest = createHash('sha256').update(text).digest('hex'); + const digestLine = `${FULL_OUTPUT_DIGEST_LABEL}${digest}`; + const artifactNote = persistedOutputFiles && persistedOutputFiles.length > 0 ? persistedOutputFiles.length === 1 - ? `Tool output truncated. Persisted tool-output artifact: ${persistedOutputFiles[0]}` - : `Tool output truncated. Persisted tool-output artifacts:\n${persistedOutputFiles + ? `Persisted tool-output artifact: ${persistedOutputFiles[0]}` + : `Persisted tool-output artifacts:\n${persistedOutputFiles .map((file) => `- ${file}`) .join('\n')}` - : 'Tool output truncated.'; + : undefined; + const header = artifactNote + ? `Tool output truncated.\n${digestLine}\n${artifactNote}` + : `Tool output truncated.\n${digestLine}`; if (header.length >= maxChars) { return sliceStartWithoutBrokenSurrogate(header, maxChars); } From 77a4c884d88e3643356289b1ccf8caeac22343cf Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sat, 22 Aug 2026 19:12:12 +0800 Subject: [PATCH 12/51] fix(core): make stateful pair counts order-aware (#9450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The result-aware (repeat key, result fingerprint) counter accumulated turn-wide, so a board oscillating between two byte-identical states reached GLOBAL_DUPLICATE_THRESHOLD on one state and halted a poller whose every result differs from its predecessor — contradicting the recorded invariant that a changed result is observable progress. Count consecutive identical results per key instead: interleaved frozen polls still accumulate, but any result differing from the key's predecessor restarts the count. --- .../src/services/loopDetectionService.test.ts | 65 +++++++++++++++++++ .../core/src/services/loopDetectionService.ts | 45 +++++++------ 2 files changed, 90 insertions(+), 20 deletions(-) diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index 9d454fe11f0..b9273579168 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -2380,6 +2380,71 @@ describe('LoopDetectionService', () => { ); }); + it('does not halt a board oscillating between two states (order-aware pair counts)', () => { + // A board flipping between two byte-identical states returns a result + // that differs from its predecessor on EVERY poll. Turn-wide (key, + // fingerprint) counting would accumulate each state to the + // global-duplicate threshold and halt this productive poller; the + // count must restart on every changed result. Run well past + // GLOBAL_DUPLICATE_THRESHOLD rounds so the accumulation is visible. + const heuristicService = new LoopDetectionService( + makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), + ); + heuristicService.reset('oscillating-board'); + + const interleaved = ['task_list', 'tool_b', 'tool_c']; + const states = ['state-a', 'state-b']; + let stateIndex = 0; + for (let round = 0; round < 2 * GLOBAL_DUPLICATE_THRESHOLD + 1; round++) { + for (const name of interleaved) { + const args = name === 'task_list' ? TASK_LIST_ARGS : { step: round }; + expect( + heuristicService.addAndCheck( + createToolCallRequestEvent(name, args), + ), + ).toBe(false); + if (name === 'task_list') { + expect( + heuristicService.recordToolResult( + { name, args }, + taskListResult(states[stateIndex++ % states.length]), + ), + ).toBe(false); + } + } + } + expect(loggers.logLoopDetected).not.toHaveBeenCalled(); + }); + + it('keeps the adaptive cap from arming on oscillating results', () => { + // CLI default: skipLoopDetection=true, so the cap's stuck signal fed + // by recordToolResult is the live halt path. An oscillating board + // must not build the stuck signal; the turn then sails past the soft + // cap toward the hard backstop instead of halting just above it. + const capService = new LoopDetectionService(makeConfig(20)); + capService.reset('cap-oscillating'); + + const states = ['state-a', 'state-b']; + let fired = false; + let totalCalls = 0; + for (let round = 0; round < 40 && !fired; round++) { + fired = capService.checkAlwaysOnSafeties(taskListEvent(`tl-${round}`)); + totalCalls++; + if (fired) break; + fired = capService.checkAlwaysOnSafeties( + createToolCallRequestEvent('tool_b', { step: round }), + ); + totalCalls++; + if (fired) break; + capService.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult(states[round % states.length]), + ); + } + expect(fired).toBe(false); + expect(totalCalls).toBe(80); + }); + it('treats changed results as progress for action stagnation', () => { const heuristicService = new LoopDetectionService( makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 3a57bd805da..3e5ead751f0 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -231,22 +231,26 @@ export class LoopDetectionService { // streak (restarted when the streak breaks); `lastFingerprint` survives // streak breaks so a state change is still visible across interleaved // calls (used by the action-stagnation reset). + // `consecutiveIdenticalResults` is the stuck-repetition evidence for the + // global-duplicate detector and the adaptive cap (replacing the + // request-time global-duplicate counting and the cap's stuck-repetition + // counting for these tools): it counts results that repeat the key's + // IMMEDIATELY PRECEDING result (interleaved calls still accumulate) and + // restarts at 1 whenever a result differs from its predecessor — the same + // call returning changed state is productive and must not accumulate + // toward either halt. Counting turn-wide (key, fingerprint) totals + // instead would halt a board oscillating between two byte-identical + // states even though every result there differs from its predecessor. private statefulRepeatState = new Map< string, { resultsObserved: number; unchangedStreak: number; + consecutiveIdenticalResults: number; lastFingerprint: string | undefined; } >(); - // Turn-wide counts of (repeat key, result fingerprint) pairs for stateful - // read tools, recorded post-execution. Replaces the request-time - // global-duplicate counting and the cap's stuck-repetition counting for - // these tools: the same call returning changed state is productive and - // must not accumulate toward either halt. - private statefulPairCounts = new Map(); - // callId → request pairing so results can be matched to their calls when // the runtime only has the response (populated on ToolCallRequest events, // consumed by recordToolResultByCallId). @@ -317,6 +321,7 @@ export class LoopDetectionService { state = { resultsObserved: 0, unchangedStreak: 0, + consecutiveIdenticalResults: 0, lastFingerprint: undefined, }; this.statefulRepeatState.set(key, state); @@ -344,21 +349,22 @@ export class LoopDetectionService { this.sameNameStreak = 1; } - // Turn-wide (repeat key, fingerprint) counting: replaces the - // request-time global-duplicate and cap stuck-repetition counting for - // stateful tools. - const pairKey = `${key}|${fingerprint}`; - const pairCount = (this.statefulPairCounts.get(pairKey) ?? 0) + 1; - this.statefulPairCounts.set(pairKey, pairCount); - if (pairCount > this.capMaxKeyRepeat) { - this.capMaxKeyRepeat = pairCount; + // Consecutive identical-result counting (see statefulRepeatState): a + // result that differs from the key's predecessor restarts the count, so + // an oscillating board never accumulates toward either halt. + const consecutiveIdentical = fingerprintChanged + ? 1 + : state.consecutiveIdenticalResults + 1; + state.consecutiveIdenticalResults = consecutiveIdentical; + if (consecutiveIdentical > this.capMaxKeyRepeat) { + this.capMaxKeyRepeat = consecutiveIdentical; } // The global-duplicate detector is gated (skipLoopDetection) exactly as // its request-time counterpart in addAndCheckHeuristicLoops. if ( !this.config.getSkipLoopDetection() && - pairCount >= GLOBAL_DUPLICATE_THRESHOLD + consecutiveIdentical >= GLOBAL_DUPLICATE_THRESHOLD ) { this.lastLoopType = LoopType.GLOBAL_TOOL_CALL_DUPLICATE; logLoopDetected( @@ -598,12 +604,12 @@ export class LoopDetectionService { this.capMaxKeyRepeat = 0; // A retry replays the failed attempt's tool calls; drop the stateful // result evidence too so the replayed attempt is judged on its own - // results (pair counts re-accumulate as results land, consistent with - // the capKeyCounts/globalToolCallCounts clears). - this.statefulPairCounts.clear(); + // results (the consecutive counts re-accumulate as results land, + // consistent with the capKeyCounts/globalToolCallCounts clears). for (const state of this.statefulRepeatState.values()) { state.resultsObserved = 0; state.unchangedStreak = 0; + state.consecutiveIdenticalResults = 0; } return false; } @@ -1314,7 +1320,6 @@ export class LoopDetectionService { this.capKeyCounts.clear(); this.capMaxKeyRepeat = 0; this.statefulRepeatState.clear(); - this.statefulPairCounts.clear(); this.requestByCallId.clear(); } From 76621a15c6bc5111a513e0a36a22fa3c65c3d29e Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sat, 22 Aug 2026 19:19:13 +0800 Subject: [PATCH 13/51] fix(core): make the alternating-pattern guard result-aware (#9450) checkAlternatingPattern still counted task_list args-only at request time, so a poller alternating task_list with another call (the ABAB shape of check-board-do-work) was halted on the first full window even while the board kept changing. Apply the same result-aware carve-out the sibling detectors got: a stateful participant's rolling results are checked when the window fills, any changed result restarts the window, and missing result evidence fails safe into the argument-only halt. --- .../src/services/loopDetectionService.test.ts | 90 +++++++++++++++++++ .../core/src/services/loopDetectionService.ts | 68 +++++++++++++- 2 files changed, 156 insertions(+), 2 deletions(-) diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index b9273579168..44a895c8d38 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -2445,6 +2445,96 @@ describe('LoopDetectionService', () => { expect(totalCalls).toBe(80); }); + it('does not halt an ABAB task_list poller whose results keep changing', () => { + // A teammate alternating task_list with another call (check board, + // do work, check board…) is exactly the ABAB shape this detector + // hunts. With changing board results it is productive polling; the + // result-aware carve-out must restart the window instead of halting + // at the first full ABAB window (6th request). tool_b keeps constant + // args so the window holds a stable B key; the run stays short + // enough that tool_b's own request count stays below the + // global-duplicate threshold. + const heuristicService = new LoopDetectionService( + makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), + ); + heuristicService.reset('alternating-productive'); + + let fired = false; + for ( + let round = 0; + round < ALTERNATING_PATTERN_CYCLES + 1 && !fired; + round++ + ) { + fired = heuristicService.addAndCheck( + createToolCallRequestEvent('task_list', TASK_LIST_ARGS), + ); + if (fired) break; + fired = heuristicService.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult(`board state v${round}`), + ); + if (fired) break; + fired = heuristicService.addAndCheck( + createToolCallRequestEvent('tool_b', { step: 'work' }), + ); + } + expect(fired).toBe(false); + expect(loggers.logLoopDetected).not.toHaveBeenCalled(); + }); + + it('still halts an ABAB pattern with a stateful participant on frozen results', () => { + // Same alternation, but the board never changes: the recorded + // results corroborate the loop, so the halt stands. + const heuristicService = new LoopDetectionService( + makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), + ); + heuristicService.reset('alternating-frozen'); + + let fired = false; + for (let round = 0; round < 8 && !fired; round++) { + fired = heuristicService.addAndCheck( + createToolCallRequestEvent('task_list', TASK_LIST_ARGS), + ); + if (fired) break; + fired = heuristicService.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult('frozen board'), + ); + if (fired) break; + fired = heuristicService.addAndCheck( + createToolCallRequestEvent('tool_b', { step: 'work' }), + ); + } + expect(fired).toBe(true); + expect(heuristicService.getLastLoopType()).toBe( + LoopType.ALTERNATING_TOOL_CALL_PATTERN, + ); + }); + + it('still halts ABAB with a stateful participant when no results were recorded (fail-safe)', () => { + // A wiring gap must never loosen the guard: without result evidence + // the argument-only halt fires exactly as pre-fix. + const heuristicService = new LoopDetectionService( + makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), + ); + heuristicService.reset('alternating-no-evidence'); + + let fired = false; + for (let round = 0; round < 8 && !fired; round++) { + fired = heuristicService.addAndCheck( + createToolCallRequestEvent('task_list', TASK_LIST_ARGS), + ); + if (fired) break; + fired = heuristicService.addAndCheck( + createToolCallRequestEvent('tool_b', { step: 'work' }), + ); + } + expect(fired).toBe(true); + expect(heuristicService.getLastLoopType()).toBe( + LoopType.ALTERNATING_TOOL_CALL_PATTERN, + ); + }); + it('treats changed results as progress for action stagnation', () => { const heuristicService = new LoopDetectionService( makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 3e5ead751f0..0f9c9224fe2 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -256,6 +256,17 @@ export class LoopDetectionService { // consumed by recordToolResultByCallId). private requestByCallId = new Map(); + // Repeat keys known to belong to a stateful read tool, so the + // alternating-pattern carve-out can tell which window participants are + // stateful (repeat keys are hashes and do not carry the tool name). + private statefulRepeatKeys = new Set(); + + // Rolling per-key result fingerprints for the alternating-pattern + // carve-out (see checkAlternatingPattern), capped at one window's worth + // of occurrences per key so a full ABAB window is judged on the results + // its own requests produced. + private statefulAlternationHistory = new Map(); + // Loop type of the most recent firing. Bubbled up through the // LoopDetected event so callers (non-interactive CLI, telemetry) can tell // the user which detector actually fired. @@ -313,6 +324,15 @@ export class LoopDetectionService { const fingerprint = createHash('sha256').update(resultText).digest('hex'); const key = this.getToolCallKey(toolCall); + // Rolling result history for the alternating-pattern carve-out (see + // checkAlternatingPattern), capped at one window's occurrences per key. + const history = this.statefulAlternationHistory.get(key) ?? []; + history.push(fingerprint); + if (history.length > ALTERNATING_PATTERN_CYCLES) { + history.shift(); + } + this.statefulAlternationHistory.set(key, history); + // Consecutive-streak evidence for the always-on guard. The state entry // can predate the streak (lastFingerprint survives streak breaks), so // create it lazily but only count results while a streak exists. @@ -528,7 +548,11 @@ export class LoopDetectionService { // Stateful read tools are counted post-execution in // recordToolResult, keyed on (call, result fingerprint) instead of // args alone (issue #9450). - const globalDup = this.isStatefulReadTool(event.value.name) + const stateful = this.isStatefulReadTool(event.value.name); + if (stateful) { + this.statefulRepeatKeys.add(toolCallKey); + } + const globalDup = stateful ? false : this.checkGlobalDuplicate(toolCallKey); const alternating = this.checkAlternatingPattern(toolCallKey); @@ -549,6 +573,8 @@ export class LoopDetectionService { // streak reset). this.globalToolCallCounts.clear(); this.recentToolCallKeys = []; + this.statefulAlternationHistory.clear(); + this.statefulRepeatKeys.clear(); break; } case GeminiEventType.Content: { @@ -611,6 +637,8 @@ export class LoopDetectionService { state.unchangedStreak = 0; state.consecutiveIdenticalResults = 0; } + this.statefulAlternationHistory.clear(); + this.statefulRepeatKeys.clear(); return false; } @@ -630,6 +658,9 @@ export class LoopDetectionService { // can be large (e.g. write_file content), so avoid recomputing per guard. const key = this.getToolCallKey(event.value); const stateful = this.isStatefulReadTool(event.value.name); + if (stateful) { + this.statefulRepeatKeys.add(key); + } // Pair requests with their later results (recordToolResultByCallId). // Only stateful read tools participate: recordToolResult rejects every @@ -1261,7 +1292,8 @@ export class LoopDetectionService { * Alternating-pattern detection: catches ABABAB… patterns where the model * flips between two distinct tool calls. Tracked via a sliding window of * tool-call keys; when the window fills with alternating A/B values the - * turn is halted. + * turn is halted — except for stateful read participants whose observed + * results keep changing (issue #9450), see the carve-out below. */ private checkAlternatingPattern(toolCallKey: string): boolean { const maxLen = 2 * ALTERNATING_PATTERN_CYCLES; @@ -1286,6 +1318,36 @@ export class LoopDetectionService { } } + // Result-aware carve-out for stateful read tools (issue #9450): + // identical arguments do not imply an identical result, so an ABAB + // poller is only stuck when its observed results corroborate it. For + // every stateful participant require the results produced by the + // window's own prior requests (all of them for the key that opened the + // window, all but the in-flight last request for the other); if ANY + // recorded result changed, the alternation is making observable + // progress and the window restarts. Missing result evidence (results + // never recorded) fails safe and keeps the argument-only halt, so a + // wiring gap never loosens the guard. + const windowTail = this.recentToolCallKeys[maxLen - 1]; + for (const altKey of [a, b]) { + if (!this.statefulRepeatKeys.has(altKey)) continue; + const occurrences = this.recentToolCallKeys.filter( + (windowKey) => windowKey === altKey, + ).length; + const expectedResults = + altKey === windowTail ? occurrences - 1 : occurrences; + if (expectedResults <= 0) continue; + const history = this.statefulAlternationHistory.get(altKey); + if (!history || history.length < expectedResults) { + continue; + } + const recent = history.slice(-expectedResults); + if (recent.some((fp) => fp !== recent[0])) { + this.recentToolCallKeys = []; + return false; + } + } + this.lastLoopType = LoopType.ALTERNATING_TOOL_CALL_PATTERN; logLoopDetected( this.config, @@ -1320,6 +1382,8 @@ export class LoopDetectionService { this.capKeyCounts.clear(); this.capMaxKeyRepeat = 0; this.statefulRepeatState.clear(); + this.statefulRepeatKeys.clear(); + this.statefulAlternationHistory.clear(); this.requestByCallId.clear(); } From 61dd8c5535f362c28d6b293e21d7e8e31c228421 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sun, 23 Aug 2026 03:22:31 +0800 Subject: [PATCH 14/51] fix(core): disarm the cap on thawed boards and anchor stub digests (#9450) Two result-aware guard hardenings from review: 1. The adaptive cap's stateful stuck signal no longer latches a high-water peak. recordToolResult now feeds the cap a statefulCapKeyRepeat that falls back to the keys' current streaks when a result changes, so a frozen-then-thawed task board releases the cap exactly as it releases the result-time global-duplicate count. A permanently frozen board still arms the cap and halts just past the soft cap. 2. stripPersistenceEnvelope no longer honors the FULL_OUTPUT_DIGEST_LABEL marker anywhere in a string. Stub recognition is gated on the producer prefixes (persisted-output / output-too-large / truncated / batch-budget fit) and the digest must be line-anchored with a full 64-hex payload, so board content that merely quotes a stub's digest line is fingerprinted verbatim instead of collapsing to (or varying with) the quoted window. The batch-budget fit prefix is now an exported constant (BATCH_BUDGET_FIT_PREFIX) so the parser shares the producer's literal. extractToolResultText / fingerprintToolResult / isStatefulReadTool are exported so the daemon turn-loop guard fingerprints results identically (issue #9450 requirement #6). --- packages/core/src/index.ts | 2 + .../src/services/loopDetectionService.test.ts | 123 ++++++++ .../core/src/services/loopDetectionService.ts | 287 ++++++++++++------ .../core/src/utils/tool-response-finalizer.ts | 12 +- 4 files changed, 334 insertions(+), 90 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bedbc92d344..f1d857d58d5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -304,6 +304,8 @@ export { DEFAULT_MAX_TOOL_CALLS_PER_TURN, GLOBAL_DUPLICATE_THRESHOLD, getToolCallRepeatKey, + isStatefulReadTool, + fingerprintToolResult, shouldHaltOnTurnToolCallCap, } from './services/loopDetectionService.js'; export * from './services/visionBridge/vision-bridge-service.js'; diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index 44a895c8d38..54668069628 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -2804,6 +2804,129 @@ describe('LoopDetectionService', () => { ); }); + it('disarms the adaptive cap when a frozen board thaws (no latched peak)', () => { + // The cap's stateful stuck signal must NOT be a high-water ratchet: a + // frozen phase builds it, but once results change it must fall back to + // the current streak so a thawed board keeps polling past the soft cap. + const capService = new LoopDetectionService(makeConfig(20)); + capService.reset('cap-thaw'); + + let fired = false; + let totalCalls = 0; + const poll = (board: string, round: number) => { + fired ||= capService.checkAlwaysOnSafeties( + taskListEvent(`tl-${round}`), + ); + totalCalls++; + if (fired) return; + fired ||= capService.checkAlwaysOnSafeties( + createToolCallRequestEvent('tool_b', { step: round }), + ); + totalCalls++; + if (fired) return; + fired = capService.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult(board), + ); + }; + + // 6 frozen results (interleaved so the consecutive guard never fires): + // the stateful stuck signal reaches GLOBAL_DUPLICATE_THRESHOLD. + for ( + let round = 0; + round < GLOBAL_DUPLICATE_THRESHOLD && !fired; + round++ + ) { + poll('frozen board', round); + } + expect(fired).toBe(false); + + // Board thaws: every subsequent result differs. The stuck signal must + // disarm, so polling sails past the soft cap of 20 without halting. + for ( + let round = GLOBAL_DUPLICATE_THRESHOLD; + round < 40 && !fired; + round++ + ) { + poll(`thawed board v${round}`, round); + } + expect(fired).toBe(false); + expect(totalCalls).toBeGreaterThanOrEqual(40); + }); + + it('still arms the adaptive cap on a permanently frozen board', () => { + // Regression guard for the disarm fix: a board that NEVER changes keeps + // the stateful stuck signal at the threshold, so the adaptive cap still + // halts the stuck poller just past the soft cap. + const capService = new LoopDetectionService(makeConfig(20)); + capService.reset('cap-frozen'); + + let fired = false; + for (let round = 0; round < 40 && !fired; round++) { + fired = capService.checkAlwaysOnSafeties(taskListEvent(`tl-${round}`)); + if (fired) break; + fired = capService.checkAlwaysOnSafeties( + createToolCallRequestEvent('tool_b', { step: round }), + ); + if (fired) break; + fired = capService.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult('frozen board'), + ); + } + expect(fired).toBe(true); + expect(capService.getLastLoopType()).toBe(LoopType.TURN_TOOL_CALL_CAP); + }); + + it('does not collapse the fingerprint when board content merely quotes the digest label', () => { + // task_list embeds peer-authored text verbatim, and agents quote stub + // text (including the `Full output sha256: ` line this PR adds to + // every oversized output) into board state. A board whose quoted label + // + digest window stays constant while the REST of the board changes + // must fingerprint by its full content — collapsing to the quoted + // 64-char window would halt this productive poller at the 5th request. + const quotedDigest = 'deadbeef'.repeat(8); // constant 64-hex window + let fired = false; + for (let i = 0; i < 4 * TOOL_CALL_LOOP_THRESHOLD; i++) { + fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); + if (fired) break; + const board = + `board row changing ${i}\n` + + `Full output sha256: ${quotedDigest}\n` + + `more changing content ${i}`; + service.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult(board, `poll_${i}`), + ); + } + expect(fired).toBe(false); + expect(loggers.logLoopDetected).not.toHaveBeenCalled(); + }); + + it('still halts when board content quoting the digest label is frozen', () => { + // Inverse of the injection guard: quoting the label does not grant + // immunity. A fully frozen board (quoted window AND the rest constant) + // must still corroborate the consecutive-identical halt. + const quotedDigest = 'deadbeef'.repeat(8); + const board = + 'frozen board row\n' + + `Full output sha256: ${quotedDigest}\n` + + 'frozen tail'; + let fired = false; + for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD; i++) { + fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); + if (fired) break; + service.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult(board, `poll_${i}`), + ); + } + expect(fired).toBe(true); + expect(service.getLastLoopType()).toBe( + LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, + ); + }); + describe('persisted oversized results (issue #9450 follow-up)', () => { // Results over the response-finalizer budget are rewritten into // persistence stubs (utils/truncation.ts buildStub) whose envelope diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 0f9c9224fe2..c83893217d9 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -20,6 +20,7 @@ import { } from '../telemetry/types.js'; import type { Config } from '../config/config.js'; import { getToolCallRepeatKey } from '../utils/tool-call-repeat-key.js'; +import { BATCH_BUDGET_FIT_PREFIX } from '../utils/tool-response-finalizer.js'; import { FULL_OUTPUT_DIGEST_LABEL, OUTPUT_TOO_LARGE_PREFIX, @@ -56,6 +57,16 @@ const MAX_HISTORY_LENGTH = 1000; // `task_update`) have different mutation/delivery semantics and stay out. const STATEFUL_READ_TOOLS: ReadonlySet = new Set(['task_list']); +/** + * Whether a tool is a stateful read tool (see STATEFUL_READ_TOOLS). + * Exported so the daemon's turn-loop guard (ACP Session) applies the same + * result-aware treatment as this service — the two runtimes must not drift + * (issue #9450 requirement #6). + */ +export function isStatefulReadTool(toolName: string): boolean { + return STATEFUL_READ_TOOLS.has(toolName); +} + // Bound for the callId → request map used to pair tool results with their // requests (recordToolResultByCallId). Parallel tool batches are far smaller; // the cap only protects against unpaired entries accumulating. @@ -147,6 +158,159 @@ export function shouldHaltOnTurnToolCallCap( return isExplicitCap || totalCalls > hardCap || stuck; } +// Producer shapes of the oversized-result stubs (see utils/truncation.ts +// and the batch-budget finalizer). Recognition is anchored on these +// prefixes: task results such as task_list embed peer-authored text +// verbatim, and that text can quote stub markers (this PR puts a +// `Full output sha256: ` line into every oversized output, and agents +// quote stubs into board state). Honoring a marker found MID-string would +// let quoted content collapse or vary the whole-board fingerprint — +// re-shipping the #9450 false halt via content. Every shape here embeds a +// per-call unique artifact path in its envelope, which is exactly why it +// must be reduced to its digest; content that merely contains (or even +// starts with) the digest label carries no per-call path and is +// fingerprinted verbatim instead. +const STUB_PRODUCER_PREFIXES: readonly string[] = [ + PERSISTED_OUTPUT_OPEN_TAG, + OUTPUT_TOO_LARGE_PREFIX, + TOOL_OUTPUT_TRUNCATED_PREFIX, + // The batch-budget finalizer's fitText header. + BATCH_BUDGET_FIT_PREFIX, +]; + +/** + * Extracts the sha256 digest a stub producer embedded for the FULL + * pre-truncation output, anchored to a producer line: the label must start + * its line and be followed by exactly 64 hex chars ending the line. A + * mid-string mention of the label (e.g. board content quoting a stub) never + * matches. Returns null when no anchored digest is present. + */ +function extractAnchoredStubDigest(value: string): string | null { + let searchFrom = 0; + for (;;) { + const index = value.indexOf(FULL_OUTPUT_DIGEST_LABEL, searchFrom); + if (index < 0) return null; + const digestStart = index + FULL_OUTPUT_DIGEST_LABEL.length; + const lineAnchored = index === 0 || value[index - 1] === '\n'; + if (lineAnchored) { + const digest = value.slice(digestStart, digestStart + 64); + const terminator = value[digestStart + 64]; + if ( + /^[0-9a-f]{64}$/.test(digest) && + (terminator === undefined || terminator === '\n' || terminator === '\r') + ) { + return digest; + } + } + searchFrom = index + 1; + } +} + +/** + * Reduces an oversized-result stub to its semantic payload for + * fingerprinting. Oversized tool results are rewritten into truncation + * stubs: a `` envelope embedding the unique + * `/.txt` path, an unwrapped `Output too large + * (...)` envelope whose session-dependent note can also vary between + * calls, the `truncateAndSaveToFile` shape embedding a random temp-file + * name, or a batch-budget fit whose header embeds a per-call artifact + * path. Hashing the envelope would make every fingerprint unique per call + * — silently disabling every result-aware guard for exactly the largest + * results. The producers embed a sha256 of the full pre-truncation output + * (FULL_OUTPUT_DIGEST_LABEL); prefer it over any visible content, because + * previews and head+tail payloads only cover the first/last chars — a + * board mutating in the dropped band must still fingerprint differently + * each poll (and a frozen board identically) no matter which stub shape + * carries it. The digest-first rule also covers stubs nested inside a + * further batch-budget fit, where the outer digest fingerprints the inner + * stub as a whole. Stubs without a digest line fall back to the shape's + * visible payload after its stable marker. The markers are the shared + * constants from utils/truncation.ts and the batch-budget finalizer so the + * parser cannot drift from the producer. The `` sentinel + * keeps a stub fingerprint from ever colliding with a small literal output + * that matches the payload. + * + * Stub recognition is gated on the producer prefixes (see + * STUB_PRODUCER_PREFIXES) and the digest must be line-anchored with a full + * 64-hex payload, so arbitrary result text that merely contains the label + * is fingerprinted verbatim instead of being collapsed to a quoted window. + */ +function stripPersistenceEnvelope(value: string): string { + const isProducerStub = STUB_PRODUCER_PREFIXES.some((prefix) => + value.startsWith(prefix), + ); + if (!isProducerStub) { + return value; + } + + const digest = extractAnchoredStubDigest(value); + if (digest !== null) { + return `sha256:${digest}`; + } + + const isPreviewStub = + value.startsWith(PERSISTED_OUTPUT_OPEN_TAG) || + value.startsWith(OUTPUT_TOO_LARGE_PREFIX); + if (isPreviewStub) { + const marker = `${PERSISTED_PREVIEW_MARKER}\n`; + const index = value.indexOf(marker); + if (index >= 0) { + return `${value.slice(index + marker.length)}`; + } + return value; + } + if (value.startsWith(TOOL_OUTPUT_TRUNCATED_PREFIX)) { + const marker = `\n${TRUNCATED_PART_MARKER}`; + const index = value.indexOf(marker); + if (index >= 0) { + return `${value.slice(index + marker.length)}`; + } + } + return value; +} + +/** + * Reconstructs the model-visible result text from tool response parts. + * Only the fingerprint of this text is retained by the guards, never the + * text itself. Returns null when the parts carry no functionResponse + * content. Shared by this service and the daemon's turn-loop guard (ACP + * Session) so both runtimes fingerprint results identically and cannot + * drift (issue #9450 requirement #6). + */ +export function extractToolResultText( + responseParts: readonly Part[], +): string | null { + const chunks: string[] = []; + for (const part of responseParts) { + const functionResponse = part.functionResponse; + if (!functionResponse) continue; + // Oversized results arrive as persistence stubs whose envelope embeds + // a per-call unique file path; fingerprint the semantic payload only + // (see stripPersistenceEnvelope) so identical underlying results stay + // identical no matter where they were persisted. + chunks.push( + JSON.stringify(functionResponse.response ?? {}, (_key, value) => + typeof value === 'string' ? stripPersistenceEnvelope(value) : value, + ), + ); + } + return chunks.length > 0 ? chunks.join('\n') : null; +} + +/** + * sha256 fingerprint of a tool result's model-visible text (see + * extractToolResultText), or null when the parts carry no functionResponse + * content. Shared with the daemon's turn-loop guard for the same + * cannot-drift reason as extractToolResultText. + */ +export function fingerprintToolResult( + responseParts: readonly Part[], +): string | null { + const resultText = extractToolResultText(responseParts); + if (resultText === null) return null; + return createHash('sha256').update(resultText).digest('hex'); +} + /** * Service for detecting and preventing infinite loops in AI responses. * Monitors tool call repetitions and content sentence repetitions. @@ -225,6 +389,17 @@ export class LoopDetectionService { private capKeyCounts = new Map(); private capMaxKeyRepeat = 0; + // Stateful-read contribution to the cap's stuck signal: the running max of + // the CURRENT consecutive-identical-result streaks (see + // statefulRepeatState). Unlike capMaxKeyRepeat this disarms when a result + // changes — a frozen-then-thawed board must release the cap exactly as it + // releases the result-time global-duplicate count, so the adaptive cap + // cannot latch a stale peak from a frozen phase and halt productive + // polling just past the soft cap. Kept separate from capMaxKeyRepeat + // (which stays a high-water mark for deterministic tools, where a 6x + // repeat is never productive even if the model later varies its calls). + private statefulCapKeyRepeat = 0; + // Result-aware tracking for stateful read tools (see STATEFUL_READ_TOOLS). // Keyed by the (tool, args) repeat key. `resultsObserved` / // `unchangedStreak` count results within the CURRENT consecutive-identical @@ -319,9 +494,8 @@ export class LoopDetectionService { if (this.disabledForSession) return false; if (!this.isStatefulReadTool(toolCall.name)) return false; - const resultText = LoopDetectionService.extractResultText(responseParts); - if (resultText === null) return false; - const fingerprint = createHash('sha256').update(resultText).digest('hex'); + const fingerprint = fingerprintToolResult(responseParts); + if (fingerprint === null) return false; const key = this.getToolCallKey(toolCall); // Rolling result history for the alternating-pattern carve-out (see @@ -376,8 +550,21 @@ export class LoopDetectionService { ? 1 : state.consecutiveIdenticalResults + 1; state.consecutiveIdenticalResults = consecutiveIdentical; - if (consecutiveIdentical > this.capMaxKeyRepeat) { - this.capMaxKeyRepeat = consecutiveIdentical; + + // Cap stuck signal from result evidence (see statefulCapKeyRepeat). A + // raised peak must NOT latch: when a result changes, recompute the peak + // from the keys' CURRENT streaks so a thawed board disarms the adaptive + // cap exactly as it disarms the result-time global-duplicate count. + if (consecutiveIdentical > this.statefulCapKeyRepeat) { + this.statefulCapKeyRepeat = consecutiveIdentical; + } else if (fingerprintChanged) { + let peak = consecutiveIdentical; + for (const other of this.statefulRepeatState.values()) { + if (other.consecutiveIdenticalResults > peak) { + peak = other.consecutiveIdenticalResults; + } + } + this.statefulCapKeyRepeat = peak; } // The global-duplicate detector is gated (skipLoopDetection) exactly as @@ -420,88 +607,7 @@ export class LoopDetectionService { } private isStatefulReadTool(toolName: string): boolean { - return STATEFUL_READ_TOOLS.has(toolName); - } - - /** - * Reconstructs the model-visible result text from tool response parts. - * Only the fingerprint of this text is retained, never the text itself. - * Returns null when the parts carry no functionResponse content. - */ - private static extractResultText( - responseParts: readonly Part[], - ): string | null { - const chunks: string[] = []; - for (const part of responseParts) { - const functionResponse = part.functionResponse; - if (!functionResponse) continue; - // Oversized results arrive as persistence stubs whose envelope embeds - // a per-call unique file path; fingerprint the semantic payload only - // (see stripPersistenceEnvelope) so identical underlying results stay - // identical no matter where they were persisted. - chunks.push( - JSON.stringify(functionResponse.response ?? {}, (_key, value) => - typeof value === 'string' - ? LoopDetectionService.stripPersistenceEnvelope(value) - : value, - ), - ); - } - return chunks.length > 0 ? chunks.join('\n') : null; - } - - /** - * Oversized tool results are rewritten into truncation stubs (see - * utils/truncation.ts and the batch-budget finalizer): a - * `` envelope embedding the unique - * `/.txt` path, an unwrapped `Output too large - * (...)` envelope whose session-dependent note can also vary between - * calls, the `truncateAndSaveToFile` shape embedding a random temp-file - * name, or a batch-budget fit whose header embeds a per-call artifact - * path. Hashing the envelope would make every fingerprint unique per call - * — silently disabling every result-aware guard for exactly the largest - * results — so reduce a stub to its semantic payload. The producers embed - * a sha256 of the full pre-truncation output (FULL_OUTPUT_DIGEST_LABEL); - * prefer it over any visible content, because previews and head+tail - * payloads only cover the first/last chars — a board mutating in the - * dropped band must still fingerprint differently each poll (and a - * frozen board identically) no matter which stub shape carries it. The - * digest-first rule also covers stubs nested inside a further batch-budget - * fit, where the outer digest fingerprints the inner stub as a whole. - * Stubs without a digest line fall back to the shape's visible payload - * after its stable marker. The markers are the shared constants from - * utils/truncation.ts so the parser cannot drift from the producer. The - * `` sentinel keeps a stub fingerprint from ever - * colliding with a small literal output that matches the payload. - */ - private static stripPersistenceEnvelope(value: string): string { - const digestIndex = value.indexOf(FULL_OUTPUT_DIGEST_LABEL); - if (digestIndex >= 0) { - const digestStart = digestIndex + FULL_OUTPUT_DIGEST_LABEL.length; - // sha256 hex digest length - const digest = value.slice(digestStart, digestStart + 64); - return `sha256:${digest}`; - } - - const isPreviewStub = - value.includes(PERSISTED_OUTPUT_OPEN_TAG) || - value.startsWith(OUTPUT_TOO_LARGE_PREFIX); - if (isPreviewStub) { - const marker = `${PERSISTED_PREVIEW_MARKER}\n`; - const index = value.indexOf(marker); - if (index >= 0) { - return `${value.slice(index + marker.length)}`; - } - return value; - } - if (value.startsWith(TOOL_OUTPUT_TRUNCATED_PREFIX)) { - const marker = `\n${TRUNCATED_PART_MARKER}`; - const index = value.indexOf(marker); - if (index >= 0) { - return `${value.slice(index + marker.length)}`; - } - } - return value; + return isStatefulReadTool(toolName); } private getToolCallKey(toolCall: { name: string; args: object }): string { @@ -628,6 +734,7 @@ export class LoopDetectionService { this.resetToolCallCount(); this.capKeyCounts.clear(); this.capMaxKeyRepeat = 0; + this.statefulCapKeyRepeat = 0; // A retry replays the failed attempt's tool calls; drop the stateful // result evidence too so the replayed attempt is judged on its own // results (the consecutive counts re-accumulate as results land, @@ -1249,7 +1356,10 @@ export class LoopDetectionService { if ( !shouldHaltOnTurnToolCallCap( this.turnToolCallTotal, - this.capMaxKeyRepeat, + // Request-time evidence (deterministic tools) and result-time + // evidence (stateful reads) feed the same stuck signal; the + // stateful half disarms when results change (statefulCapKeyRepeat). + Math.max(this.capMaxKeyRepeat, this.statefulCapKeyRepeat), this.config.getMaxToolCallsPerTurn(), this.config.isMaxToolCallsPerTurnExplicit(), ) @@ -1381,6 +1491,7 @@ export class LoopDetectionService { this.turnToolCallTotalCommitted = 0; this.capKeyCounts.clear(); this.capMaxKeyRepeat = 0; + this.statefulCapKeyRepeat = 0; this.statefulRepeatState.clear(); this.statefulRepeatKeys.clear(); this.statefulAlternationHistory.clear(); diff --git a/packages/core/src/utils/tool-response-finalizer.ts b/packages/core/src/utils/tool-response-finalizer.ts index ffe588ecebc..0135e44b02d 100644 --- a/packages/core/src/utils/tool-response-finalizer.ts +++ b/packages/core/src/utils/tool-response-finalizer.ts @@ -209,6 +209,14 @@ function sliceEndWithoutBrokenSurrogate(text: string, length: number): string { return text.slice(start); } +/** + * First line of the header fitText prepends to every batch-budget fit. + * Exported so consumers that parse stubs (the loop guards in + * services/loopDetectionService.ts) recognize the shape with the + * producer's constant instead of a hand-mirrored literal that can drift. + */ +export const BATCH_BUDGET_FIT_PREFIX = 'Tool output truncated.'; + function fitText( text: string, maxChars: number, @@ -234,8 +242,8 @@ function fitText( .join('\n')}` : undefined; const header = artifactNote - ? `Tool output truncated.\n${digestLine}\n${artifactNote}` - : `Tool output truncated.\n${digestLine}`; + ? `${BATCH_BUDGET_FIT_PREFIX}\n${digestLine}\n${artifactNote}` + : `${BATCH_BUDGET_FIT_PREFIX}\n${digestLine}`; if (header.length >= maxChars) { return sliceStartWithoutBrokenSurrogate(header, maxChars); } From 1ac2a8104efd640712095714e3d12e1dbab1164c Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sun, 23 Aug 2026 03:22:45 +0800 Subject: [PATCH 15/51] fix(core): count deduped provider call ids once in the subagent loop guard (#9450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../core/src/agents/runtime/agent-core.ts | 19 ++++ .../src/agents/runtime/agent-headless.test.ts | 98 +++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index e102d4032c9..9fd7bfca0aa 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -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(); 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, diff --git a/packages/core/src/agents/runtime/agent-headless.test.ts b/packages/core/src/agents/runtime/agent-headless.test.ts index 9f6c463d114..780b52a166e 100644 --- a/packages/core/src/agents/runtime/agent-headless.test.ts +++ b/packages/core/src/agents/runtime/agent-headless.test.ts @@ -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', From e7085eca8feff066828bbe4bca84e16c5bae394f Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sun, 23 Aug 2026 03:23:00 +0800 Subject: [PATCH 16/51] fix(core): surface subagent loop attribution in the FINISH display (#9450) AgentFinishEvent.loopType was write-only: agent-headless emits it on every loop-halted run, but no production code read it, so the FINISH surface collapsed every loop stop into the generic LOOP_DETECTED label. AgentTool's FINISH handler now reads loopType and appends it to the failed task card's terminateReason (the same `(loopType)` attribution agent-interactive already renders); the journaled sink remains SubagentExecutionEvent.loop_type. --- packages/core/src/agents/runtime/agent-events.ts | 6 ++++-- packages/core/src/tools/agent/agent.ts | 7 ++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/core/src/agents/runtime/agent-events.ts b/packages/core/src/agents/runtime/agent-events.ts index 384b2c00a52..e63c42e367c 100644 --- a/packages/core/src/agents/runtime/agent-events.ts +++ b/packages/core/src/agents/runtime/agent-events.ts @@ -206,8 +206,10 @@ export interface AgentFinishEvent { terminateReason: string; /** * Which loop detector fired when terminateReason is LOOP_DETECTED - * (issue #9450), so stops are attributable in journals/telemetry instead - * of collapsing into one generic label. + * (issue #9450), so stops are attributable instead of collapsing into + * one generic label. Read by AgentTool's FINISH handler, which appends + * it to the failed task card's terminateReason; the journaled sink is + * SubagentExecutionEvent.loop_type (see agent-headless.ts). */ loopType?: string; timestamp: number; diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index 2f1c0ce7b03..7625d572a15 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -1490,7 +1490,12 @@ class AgentToolInvocation extends BaseToolInvocation { this.updateDisplay( { status: event.terminateReason === 'GOAL' ? 'completed' : 'failed', - terminateReason: event.terminateReason, + // Surface which loop detector stopped the subagent (issue #9450) + // so the failed task card is attributable instead of collapsing + // every loop stop into the generic LOOP_DETECTED label. + terminateReason: event.loopType + ? `${event.terminateReason} (${event.loopType})` + : event.terminateReason, }, updateOutput, ); From dd2510d5187ad06cc56086045075d444e41a7c93 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sun, 23 Aug 2026 03:23:14 +0800 Subject: [PATCH 17/51] fix(cli): make the daemon loop guard result-aware for task_list polls (#9450) The daemon/ACP mirror (recordDaemonToolCalls) still counted task_list args-only at request time, so the #9450 false positive survived on that runtime: a daemon-served leader polling task_list with identical arguments while peers kept completing tasks hit the global-duplicate mirror at the 6th request (skipLoopDetection=false) or the adaptive cap's stuck signal past the soft cap, even though every executed result differed. Issue #9450 requirement #6 asks main-session, subagent, and ACP paths to behave equivalently where they support the same tool. recordDaemonToolCalls now skips stateful read tools at request time, and a new recordDaemonToolResult records each executed result post-execution (wired where executed results are queued), keyed on (call, result fingerprint) via the shared fingerprintToolResult. It feeds the cap's stuck signal (statefulMaxResultRepeat, disarmed on a changed result, mirroring core's statefulCapKeyRepeat) and a result-time global-duplicate count (gated on skipLoopDetection, mirroring core's recordToolResult). Batch loops and the final finalize observe loopState.loopDetected so a result-time detection stops the turn. --- .../acp-integration/session/Session.test.ts | 326 ++++++++++++++++++ .../src/acp-integration/session/Session.ts | 167 ++++++++- 2 files changed, 488 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 5854b6884c1..ac84844cdff 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -9240,6 +9240,14 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, + statefulResultStreaks: new Map< + string, + { + consecutiveIdenticalResults: number; + lastFingerprint: string | undefined; + } + >(), + statefulMaxResultRepeat: 0, loopDetected: false, }; @@ -9303,6 +9311,14 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, + statefulResultStreaks: new Map< + string, + { + consecutiveIdenticalResults: number; + lastFingerprint: string | undefined; + } + >(), + statefulMaxResultRepeat: 0, loopDetected: false, }; @@ -9369,6 +9385,14 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, + statefulResultStreaks: new Map< + string, + { + consecutiveIdenticalResults: number; + lastFingerprint: string | undefined; + } + >(), + statefulMaxResultRepeat: 0, loopDetected: false, }; @@ -9595,6 +9619,14 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, + statefulResultStreaks: new Map< + string, + { + consecutiveIdenticalResults: number; + lastFingerprint: string | undefined; + } + >(), + statefulMaxResultRepeat: 0, loopDetected: false, }; const calls = Array.from({ length: 5 }, (_, index) => ({ @@ -9658,6 +9690,14 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, + statefulResultStreaks: new Map< + string, + { + consecutiveIdenticalResults: number; + lastFingerprint: string | undefined; + } + >(), + statefulMaxResultRepeat: 0, loopDetected: false, }; // Six identical (tool, args) calls below the cap. Core's always-on @@ -9727,6 +9767,14 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, + statefulResultStreaks: new Map< + string, + { + consecutiveIdenticalResults: number; + lastFingerprint: string | undefined; + } + >(), + statefulMaxResultRepeat: 0, loopDetected: false, }; const calls = Array.from({ length: 6 }, (_, index) => ({ @@ -9806,6 +9854,14 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, + statefulResultStreaks: new Map< + string, + { + consecutiveIdenticalResults: number; + lastFingerprint: string | undefined; + } + >(), + statefulMaxResultRepeat: 0, loopDetected: false, }; // The prompt loop calls runToolCalls once per model response against @@ -9901,6 +9957,14 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, + statefulResultStreaks: new Map< + string, + { + consecutiveIdenticalResults: number; + lastFingerprint: string | undefined; + } + >(), + statefulMaxResultRepeat: 0, loopDetected: false, }; // Six identical (tool, args) calls in one batch — three execute, and @@ -10001,6 +10065,14 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, + statefulResultStreaks: new Map< + string, + { + consecutiveIdenticalResults: number; + lastFingerprint: string | undefined; + } + >(), + statefulMaxResultRepeat: 0, loopDetected: false, }; // One diverse call plus six identical ones push the turn past the @@ -10096,6 +10168,14 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, + statefulResultStreaks: new Map< + string, + { + consecutiveIdenticalResults: number; + lastFingerprint: string | undefined; + } + >(), + statefulMaxResultRepeat: 0, loopDetected: false, }; const calls = [ @@ -10184,6 +10264,14 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, + statefulResultStreaks: new Map< + string, + { + consecutiveIdenticalResults: number; + lastFingerprint: string | undefined; + } + >(), + statefulMaxResultRepeat: 0, loopDetected: false, }; // 29 prior calls + a batch of 2 diverse calls crosses the backstop @@ -10290,6 +10378,14 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, + statefulResultStreaks: new Map< + string, + { + consecutiveIdenticalResults: number; + lastFingerprint: string | undefined; + } + >(), + statefulMaxResultRepeat: 0, loopDetected: false, }; const result = await ( @@ -10382,6 +10478,14 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, + statefulResultStreaks: new Map< + string, + { + consecutiveIdenticalResults: number; + lastFingerprint: string | undefined; + } + >(), + statefulMaxResultRepeat: 0, loopDetected: false, }; @@ -10469,6 +10573,14 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, + statefulResultStreaks: new Map< + string, + { + consecutiveIdenticalResults: number; + lastFingerprint: string | undefined; + } + >(), + statefulMaxResultRepeat: 0, loopDetected: false, }; @@ -10602,6 +10714,172 @@ describe('Session', () => { secondFollowUp.message[0].functionResponse?.response?.['error'], ).toContain('Duplicate provider tool call id "shell_1"'); }); + + describe('result-aware daemon guard for stateful reads (issue #9450)', () => { + const TASK_LIST_ARGS = { + status: 'in_progress', + owner: 'peer-a', + blockedBy: '', + }; + + const freshLoopState = () => ({ + totalToolCalls: 0, + invalidToolParamErrors: new Map(), + toolCallKeyCounts: new Map(), + maxToolCallKeyRepeat: 0, + statefulResultStreaks: new Map< + string, + { + consecutiveIdenticalResults: number; + lastFingerprint: string | undefined; + } + >(), + statefulMaxResultRepeat: 0, + loopDetected: false, + loopType: undefined as core.LoopType | undefined, + }); + + // A task_list mock whose executed result changes (or freezes) per + // call, mirroring a task board peers keep mutating. + const installTaskListTool = (boards: () => string) => { + const execute = vi.fn().mockImplementation(async () => ({ + llmContent: boards(), + returnDisplay: 'ok', + })); + mockToolRegistry.getTool.mockImplementation((name: string) => + name === 'task_list' + ? { + name: 'task_list', + kind: core.Kind.Read, + displayName: 'TaskList', + description: 'TaskList', + build: vi.fn().mockImplementation((args) => ({ + params: args, + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + getDescription: vi.fn().mockReturnValue('task_list'), + toolLocations: vi.fn().mockReturnValue([]), + execute, + })), + canUpdateOutput: false, + isOutputMarkdown: false, + } + : undefined, + ); + return execute; + }; + + const runTaskListPoll = ( + loopState: ReturnType, + round: number, + ) => + ( + session as unknown as { + runToolCalls: ( + abortSignal: AbortSignal, + promptId: string, + calls: unknown[], + loopState: ReturnType, + ) => Promise<{ loopDetected?: boolean; parts: Part[] }>; + } + ).runToolCalls( + new AbortController().signal, + `prompt-task-list-${round}`, + [ + { + id: `task_list_${round}`, + name: 'task_list', + args: TASK_LIST_ARGS, + }, + ], + loopState, + ); + + it('does not halt a daemon task_list poller while the board keeps changing (skipLoopDetection=false)', async () => { + // The exact #9450 shape on the daemon runtime: identical args, + // every executed result differing. Pre-fix the request-time + // global-duplicate mirror halted at the 6th identical-args poll. + mockConfig.getApprovalMode = vi + .fn() + .mockReturnValue(ApprovalMode.YOLO); + mockConfig.getMaxToolCallsPerTurn = vi.fn().mockReturnValue(100); + mockConfig.isMaxToolCallsPerTurnExplicit = vi + .fn() + .mockReturnValue(false); + mockConfig.getSkipLoopDetection = vi.fn().mockReturnValue(false); + let boardVersion = 0; + const execute = installTaskListTool( + () => `board state v${++boardVersion}`, + ); + const loopState = freshLoopState(); + + for (let round = 0; round < 8; round++) { + const result = await runTaskListPoll(loopState, round); + expect(result.loopDetected ?? false).toBe(false); + } + expect(execute).toHaveBeenCalledTimes(8); + expect(loopState.maxToolCallKeyRepeat).toBe(0); + expect(loopState.loopDetected).toBe(false); + }); + + it('still halts a daemon task_list poller on a frozen board (skipLoopDetection=false)', async () => { + mockConfig.getApprovalMode = vi + .fn() + .mockReturnValue(ApprovalMode.YOLO); + mockConfig.getMaxToolCallsPerTurn = vi.fn().mockReturnValue(100); + mockConfig.isMaxToolCallsPerTurnExplicit = vi + .fn() + .mockReturnValue(false); + mockConfig.getSkipLoopDetection = vi.fn().mockReturnValue(false); + installTaskListTool(() => 'frozen board'); + const loopState = freshLoopState(); + + let haltedAt = -1; + for (let round = 0; round < 8 && haltedAt < 0; round++) { + const result = await runTaskListPoll(loopState, round); + if (result.loopDetected) haltedAt = round; + } + // The 6th identical result trips the result-time global-duplicate + // mirror (GLOBAL_DUPLICATE_THRESHOLD = 6); the 6th request is not + // executed because the batch is skipped whole. + expect(haltedAt).toBeGreaterThanOrEqual(0); + expect(loopState.loopType).toBe( + core.LoopType.GLOBAL_TOOL_CALL_DUPLICATE, + ); + }); + + it('keeps a frozen-then-thawed daemon poller alive past the adaptive cap', async () => { + // CLI defaults: skipLoopDetection=true, adaptive soft cap. A frozen + // phase builds the result-time stuck signal; once the board thaws + // the signal must disarm so productive polling continues past the + // soft cap (the cap-ratchet regression on the daemon mirror). + mockConfig.getApprovalMode = vi + .fn() + .mockReturnValue(ApprovalMode.YOLO); + mockConfig.getMaxToolCallsPerTurn = vi.fn().mockReturnValue(20); + mockConfig.isMaxToolCallsPerTurnExplicit = vi + .fn() + .mockReturnValue(false); + mockConfig.getSkipLoopDetection = vi.fn().mockReturnValue(true); + const boards: string[] = []; + for (let i = 0; i < 6; i++) boards.push('frozen board'); + let thawed = false; + const execute = installTaskListTool(() => { + if (boards.length > 0) return boards.shift()!; + thawed = true; + return `thawed board v${execute.mock.calls.length}`; + }); + const loopState = freshLoopState(); + + let fired = false; + for (let round = 0; round < 40 && !fired; round++) { + const result = await runTaskListPoll(loopState, round); + fired = result.loopDetected ?? false; + } + expect(fired).toBe(false); + expect(thawed).toBe(true); + expect(loopState.loopDetected).toBe(false); + }); + }); }); describe('repeated tool execution failure guard', () => { @@ -11138,6 +11416,14 @@ describe('Session', () => { invalidToolParamErrors: Map; toolCallKeyCounts: Map; maxToolCallKeyRepeat: number; + statefulResultStreaks: Map< + string, + { + consecutiveIdenticalResults: number; + lastFingerprint: string | undefined; + } + >; + statefulMaxResultRepeat: number; loopDetected: boolean; }, ) => Promise; @@ -11151,6 +11437,14 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, + statefulResultStreaks: new Map< + string, + { + consecutiveIdenticalResults: number; + lastFingerprint: string | undefined; + } + >(), + statefulMaxResultRepeat: 0, loopDetected: false, }, ); @@ -27067,6 +27361,14 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, + statefulResultStreaks: new Map< + string, + { + consecutiveIdenticalResults: number; + lastFingerprint: string | undefined; + } + >(), + statefulMaxResultRepeat: 0, loopDetected: false, repeatedToolFailureMode: 'off', repeatedToolFailureState: createRepeatedToolFailureGuardState(), @@ -27808,6 +28110,14 @@ describe('Session', () => { invalidToolParamErrors: new Map([[core.ToolNames.AGENT, 2]]), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, + statefulResultStreaks: new Map< + string, + { + consecutiveIdenticalResults: number; + lastFingerprint: string | undefined; + } + >(), + statefulMaxResultRepeat: 0, loopDetected: false, repeatedToolFailureMode: 'off', repeatedToolFailureState: createRepeatedToolFailureGuardState(), @@ -27907,6 +28217,14 @@ describe('Session', () => { invalidToolParamErrors: new Map([[core.ToolNames.AGENT, 2]]), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, + statefulResultStreaks: new Map< + string, + { + consecutiveIdenticalResults: number; + lastFingerprint: string | undefined; + } + >(), + statefulMaxResultRepeat: 0, loopDetected: false, repeatedToolFailureMode: 'off', repeatedToolFailureState: createRepeatedToolFailureGuardState(), @@ -29021,6 +29339,14 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, + statefulResultStreaks: new Map< + string, + { + consecutiveIdenticalResults: number; + lastFingerprint: string | undefined; + } + >(), + statefulMaxResultRepeat: 0, loopDetected: false, repeatedToolFailureMode: 'off', repeatedToolFailureState: createRepeatedToolFailureGuardState(), diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 6d8ac89c101..a0c3428bbd1 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -156,7 +156,9 @@ import { ConversationFinishedEvent, GLOBAL_DUPLICATE_THRESHOLD, canonicalToolName, + fingerprintToolResult, getToolCallRepeatKey, + isStatefulReadTool, shouldHaltOnTurnToolCallCap, logLoopDetected, logRepeatedToolFailureGuard, @@ -653,6 +655,26 @@ export type DaemonToolLoopState = { toolCallKeyCounts: Map; /** Highest repeat count of any single (tool, args) pair this turn. */ maxToolCallKeyRepeat: number; + /** + * Result-aware evidence for stateful read tools (issue #9450), keyed by + * repeat key — mirrors core's LoopDetectionService.statefulRepeatState. + * `consecutiveIdenticalResults` counts executed results that repeat the + * key's immediately preceding result and restarts at 1 on a changed + * result; `lastFingerprint` is the preceding result's fingerprint. + */ + statefulResultStreaks: Map< + string, + { + consecutiveIdenticalResults: number; + lastFingerprint: string | undefined; + } + >; + /** + * Running max of the CURRENT stateful result streaks — the cap's stuck + * signal for stateful reads (mirrors core's statefulCapKeyRepeat). + * Disarmed when a result changes, so a thawed board releases the cap. + */ + statefulMaxResultRepeat: number; loopDetected: boolean; loopType?: LoopType; repeatedToolFailureMode: RepeatedToolFailureGuardMode; @@ -681,6 +703,8 @@ function createDaemonToolLoopState( invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, + statefulResultStreaks: new Map(), + statefulMaxResultRepeat: 0, loopDetected: false, repeatedToolFailureMode, repeatedToolFailureState: createRepeatedToolFailureGuardState(), @@ -831,6 +855,13 @@ function recordDaemonToolCalls( return loopState?.loopDetected ?? false; loopState.totalToolCalls += calls.length; for (const call of calls) { + // Stateful read tools are counted post-execution in + // recordDaemonToolResult, keyed on (call, result fingerprint) instead + // of args alone (issue #9450) — identical arguments to task_list do + // not imply an identical result while peers keep mutating the board. + // Mirrors core's checkAlwaysOnSafeties exemption; the two runtimes + // must not drift (requirement #6). + if (isStatefulReadTool(call.name ?? '')) continue; const key = getToolCallRepeatKey(call.name ?? '', call.args ?? {}); const count = (loopState.toolCallKeyCounts.get(key) ?? 0) + 1; loopState.toolCallKeyCounts.set(key, count); @@ -857,7 +888,14 @@ function recordDaemonToolCalls( if ( shouldHaltOnTurnToolCallCap( loopState.totalToolCalls, - loopState.maxToolCallKeyRepeat, + // Request-time evidence (deterministic tools) and result-time + // evidence (stateful reads) feed the same stuck signal, exactly as + // core's checkTurnToolCallCap. The stateful half disarms when + // results change (recordDaemonToolResult). + Math.max( + loopState.maxToolCallKeyRepeat, + loopState.statefulMaxResultRepeat, + ), config.getMaxToolCallsPerTurn(), config.isMaxToolCallsPerTurnExplicit(), ) @@ -880,7 +918,9 @@ function recordDaemonToolCalls( // always-on regardless. "Off by default" depends on the CLI layer: core's // Config defaults skipLoopDetection to false and loadCliConfig applies // `?? true` (cli config.ts), so a Config constructed without that layer - // would ship this halt on. + // would ship this halt on. Stateful read tools are counted + // post-execution in recordDaemonToolResult instead (their repetition is + // only meaningful when the results are unchanged too). if ( !config.getSkipLoopDetection() && loopState.maxToolCallKeyRepeat >= GLOBAL_DUPLICATE_THRESHOLD @@ -896,6 +936,82 @@ function recordDaemonToolCalls( return false; } +/** + * Result-aware mirror of core's LoopDetectionService.recordToolResult for + * the daemon/ACP runtime (issue #9450 requirement #6). Records the executed + * result of a stateful read tool so identical arguments whose results keep + * changing are treated as productive polling, not a loop. Feeds both the + * adaptive cap's stuck signal (statefulMaxResultRepeat, which disarms on a + * changed result) and the result-time global-duplicate count (gated on + * skipLoopDetection, exactly as in core). Call once per executed call. + */ +function recordDaemonToolResult( + config: Config, + promptId: string, + loopState: DaemonToolLoopState | undefined, + toolCall: { name: string; args: object }, + responseParts: readonly Part[], +): boolean { + if (!loopState || loopState.loopDetected) + return loopState?.loopDetected ?? false; + if (!isStatefulReadTool(toolCall.name)) return false; + + const fingerprint = fingerprintToolResult(responseParts); + if (fingerprint === null) return false; + const key = getToolCallRepeatKey(toolCall.name, toolCall.args); + + let state = loopState.statefulResultStreaks.get(key); + if (!state) { + state = { consecutiveIdenticalResults: 0, lastFingerprint: undefined }; + loopState.statefulResultStreaks.set(key, state); + } + const firstResult = state.lastFingerprint === undefined; + const fingerprintChanged = + !firstResult && state.lastFingerprint !== fingerprint; + + // Consecutive identical-result counting (mirrors core): a result that + // differs from the key's predecessor restarts the count, so an + // oscillating board never accumulates toward either halt. + const consecutiveIdentical = fingerprintChanged + ? 1 + : state.consecutiveIdenticalResults + 1; + state.consecutiveIdenticalResults = consecutiveIdentical; + state.lastFingerprint = fingerprint; + + // Cap stuck signal from result evidence. A raised peak must NOT latch: + // when a result changes, recompute the peak from the keys' CURRENT + // streaks so a thawed board disarms the adaptive cap exactly as it + // disarms the result-time global-duplicate count. Mirrors core's + // statefulCapKeyRepeat. + if (consecutiveIdentical > loopState.statefulMaxResultRepeat) { + loopState.statefulMaxResultRepeat = consecutiveIdentical; + } else if (fingerprintChanged) { + let peak = consecutiveIdentical; + for (const other of loopState.statefulResultStreaks.values()) { + if (other.consecutiveIdenticalResults > peak) { + peak = other.consecutiveIdenticalResults; + } + } + loopState.statefulMaxResultRepeat = peak; + } + + // The result-time global-duplicate detector is gated on skipLoopDetection + // exactly as its core counterpart in recordToolResult. + if ( + !config.getSkipLoopDetection() && + consecutiveIdentical >= GLOBAL_DUPLICATE_THRESHOLD + ) { + return recordDaemonLoopDetected( + config, + promptId, + LoopType.GLOBAL_TOOL_CALL_DUPLICATE, + `Stopping ACP turn after the same ${toolCall.name} result repeated ${consecutiveIdentical} times.`, + loopState, + ); + } + return false; +} + function recordDaemonInvalidToolParams( config: Config, promptId: string, @@ -9119,6 +9235,29 @@ export class Session implements SessionContext { ordinal: dedupedFunctionCalls.indexOf(fc), sequence: toolResultRecordSequence++, }); + // Result-aware loop guards (issue #9450): feed EXECUTED stateful-read + // results to the daemon guard so identical task_list arguments whose + // results keep changing stay productive. Skipped/duplicate records + // (executionStatus 'not_started', providerDuplicate) never executed, + // so they carry no result evidence. A detection sets + // loopState.loopDetected; the batch loops and runTool entry checks + // below observe it and stop the turn. + if ( + toolLoopState && + !record.providerDuplicate && + record.metadata.executionStatus !== 'not_started' + ) { + recordDaemonToolResult( + this.config, + promptId, + toolLoopState, + { + name: record.toolName, + args: (fc.args ?? {}) as object, + }, + record.responseParts, + ); + } }; const finalizeRunToolResult = async ( result: RunToolResult, @@ -9579,7 +9718,13 @@ export class Session implements SessionContext { executing.add(p); if (executing.size >= maxConcurrency) { await Promise.race(executing); - if (results.some((result) => result?.loopDetected)) { + // toolLoopState.loopDetected also covers result-time detections + // (recordDaemonToolResult) that the settled result object does + // not carry. + if ( + results.some((result) => result?.loopDetected) || + toolLoopState?.loopDetected + ) { await Promise.all(executing); await fillLoopSkippedFrom(idx + 1); return results; @@ -9591,7 +9736,10 @@ export class Session implements SessionContext { ); if (invalidToolErrorNearThreshold && executing.size > 0) { await Promise.all(executing); - if (results.some((result) => result?.loopDetected)) { + if ( + results.some((result) => result?.loopDetected) || + toolLoopState?.loopDetected + ) { await fillLoopSkippedFrom(idx + 1); return results; } @@ -9662,6 +9810,9 @@ export class Session implements SessionContext { shouldStop ||= r.stopAfterPermissionCancel; shouldStopForLoop ||= r.loopDetected === true; } + // Result-time detections (recordDaemonToolResult) land on the + // shared loop state, not on an individual result object. + shouldStopForLoop ||= toolLoopState?.loopDetected === true; if (shouldStopForLoop) { await appendSkippedAfter( parts, @@ -9702,7 +9853,10 @@ export class Session implements SessionContext { ); parts.push(...r.parts); collectMemoryWriteCandidates(r); - if (r.loopDetected) { + // toolLoopState.loopDetected also covers result-time detections + // (recordDaemonToolResult) fired while this call's result was + // queued — the result object itself does not carry them. + if (r.loopDetected || toolLoopState?.loopDetected) { await appendSkippedAfter( parts, fc, @@ -9730,6 +9884,9 @@ export class Session implements SessionContext { return await finalizeRunToolResult({ parts, stopAfterPermissionCancel: false, + // A result-time detection on the LAST executed call leaves no later + // call to observe it; surface it so the turn loop still stops. + ...(toolLoopState?.loopDetected ? { loopDetected: true } : {}), memoryWriteCandidates, }); } finally { From 7cc2bff87861d7db053db2284c8409c51c76115d Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sun, 23 Aug 2026 05:43:09 +0800 Subject: [PATCH 18/51] fix(core): count deduped provider call ids once in the main-session loop guard (#9450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main-session twin of the subagent fix (1ac2a8104e): request counts and result evidence were fed from different populations. Turn yields one ToolCallRequest event per raw streamed function call (no dedup), so a provider-emitted duplicate call id advanced the consecutive-identical counter twice, while execution collapses duplicates (scheduler dedupeRequestsByCallId / interactive duplicate-call-id suppression) and recordToolResultByCallId consumes once per call id — leaving the request counter permanently one ahead of the result evidence within a streak. The result-aware exemption then failed safe and halted a productive task_list poller whose board changed on every poll. The main-session stream loop now feeds the loop guards (always-on and heuristic tiers) one event per call id per attempt — a per-attempt Set cleared on retry/model-fallback alongside the attempt's accumulated state, mirroring dedupeRequestsByCallId (id-less requests are never deduped). The events themselves still flow to stream consumers; only the guard feed is deduped. --- packages/core/src/core/client.test.ts | 174 ++++++++++++++++++++++++++ packages/core/src/core/client.ts | 31 ++++- 2 files changed, 204 insertions(+), 1 deletion(-) diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 791c6581c54..dea68cd6ba8 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -50,6 +50,7 @@ import { GeminiEventType, Turn, type ServerGeminiStreamEvent, + type ServerGeminiToolCallRequestEvent, } from './turn.js'; import { LoopType } from '../telemetry/types.js'; import { logMemoryRecallDelivery } from '../telemetry/index.js'; @@ -7932,6 +7933,179 @@ hello ); }); + // Variant of runTaskListPollTurns that polls ONLY task_list (no + // interleaved tool), so identical (name, args) build one unbroken + // consecutive streak across rounds. Round 0 streams the same call id + // twice — the provider-duplicate emission dedupeRequestsByCallId + // collapses into one executed call and one functionResponse — so request + // counts and result evidence desync unless the loop-guard feed counts one + // event per call id per attempt (issue #9450). + async function runDuplicateIdTaskListPollTurns( + board: (round: number) => string, + maxRounds = 6, + ) { + const promptId = 'prompt-task-list-dup-poll'; + const taskListArgs = { status: 'in_progress', owner: 'peer-a' }; + const allEvents: Array<{ type: string; value?: unknown }> = []; + for (let round = 0; round <= maxRounds; round++) { + const request = (callId: string) => ({ + type: GeminiEventType.ToolCallRequest, + value: { + callId, + name: 'task_list', + args: taskListArgs, + isClientInitiated: false, + prompt_id: promptId, + }, + }); + mockTurnRunFn.mockReturnValueOnce( + (async function* () { + yield request(`tl-${round}`); + if (round === 0) { + // Provider-duplicate emission of the same call id: execution + // collapses it (one functionResponse comes back below), so the + // loop-guard feed must count it once. + yield request(`tl-${round}`); + } + })(), + ); + const contents = + round === 0 + ? [{ text: 'poll the board' }] + : [ + { + functionResponse: { + id: `tl-${round - 1}`, + name: 'task_list', + response: { output: board(round - 1) }, + }, + }, + ]; + const events = await fromAsync( + client.sendMessageStream( + contents as never, + new AbortController().signal, + promptId, + { + type: + round === 0 + ? SendMessageType.UserQuery + : SendMessageType.ToolResult, + }, + ), + ); + allEvents.push(...(events as Array<{ type: string; value?: unknown }>)); + if ( + allEvents.some((e) => e.type === GeminiEventType.LoopDetected) || + !events.some((e) => e.type === GeminiEventType.ToolCallRequest) + ) { + return allEvents; + } + } + return allEvents; + } + + it('counts a provider-duplicate call id once so changed-board polls never halt (#9450)', async () => { + const events = await runDuplicateIdTaskListPollTurns( + (round) => `board v${round}`, + ); + expect(events.some((e) => e.type === GeminiEventType.LoopDetected)).toBe( + false, + ); + // The turn kept polling through every round: 7 rounds, 7 unique call + // ids, 8 streamed events (round 0's id is emitted twice and both + // emissions still reach consumers — only the guard feed is deduped). + // Without the feed dedup the duplicate round-0 emission desyncs the + // request counter one ahead of the result evidence and the guard halts + // the streak mid-poll. + const taskListRequests = events.filter( + (e) => + e.type === GeminiEventType.ToolCallRequest && + (e.value as { name?: string }).name === 'task_list', + ); + expect(taskListRequests).toHaveLength(8); + expect( + new Set( + taskListRequests.map((e) => (e.value as { callId: string }).callId), + ).size, + ).toBe(7); + }); + + it('still halts a frozen board despite the duplicate-call-id feed dedup (#9450)', async () => { + const events = await runDuplicateIdTaskListPollTurns( + () => 'frozen board', + ); + const loopEvent = events.find( + (e) => e.type === GeminiEventType.LoopDetected, + ); + expect(loopEvent).toBeDefined(); + expect( + (loopEvent?.value as { loopType?: string } | undefined)?.loopType, + ).toBe('consecutive_identical_tool_calls'); + }); + + it('feeds the loop guards one event per call id per attempt, re-feeding after retries (#9450)', async () => { + const loopDetector = client['loopDetector']; + const alwaysOnSpy = vi + .spyOn(loopDetector, 'checkAlwaysOnSafeties') + .mockReturnValue(false); + const heuristicSpy = vi + .spyOn(loopDetector, 'addAndCheckHeuristicLoops') + .mockReturnValue(false); + + const request = (callId: string) => ({ + type: GeminiEventType.ToolCallRequest, + value: { + callId, + name: 'task_list', + args: { status: 'in_progress' }, + isClientInitiated: false, + prompt_id: 'prompt-dup-feed', + }, + }); + mockTurnRunFn.mockReturnValue( + (async function* () { + yield request('dup-1'); + // Provider-duplicate emission within the same attempt. + yield request('dup-1'); + yield { type: GeminiEventType.Retry }; + // Fresh attempt: the attempt boundary re-feeds the same id. + yield request('dup-1'); + yield request('unique-2'); + })(), + ); + + const events = await fromAsync( + client.sendMessageStream( + [{ text: 'poll' }] as never, + new AbortController().signal, + 'prompt-dup-feed', + { type: SendMessageType.UserQuery }, + ), + ); + + const fedCallIds = (spy: typeof alwaysOnSpy) => + spy.mock.calls + .map((call) => call[0]) + .filter( + (e): e is ServerGeminiToolCallRequestEvent => + e.type === GeminiEventType.ToolCallRequest, + ) + .map((e) => e.value.callId); + + // One feed per call id per attempt: the in-attempt duplicate is + // skipped, and the retry clears the attempt boundary so the re-streamed + // id is fed again. + expect(fedCallIds(alwaysOnSpy)).toEqual(['dup-1', 'dup-1', 'unique-2']); + expect(fedCallIds(heuristicSpy)).toEqual(['dup-1', 'dup-1', 'unique-2']); + + // The dedup happens only at the guard feed: every emission still + // reaches stream consumers. + expect( + events.filter((e) => e.type === GeminiEventType.ToolCallRequest), + ).toHaveLength(4); + }); + it('should halt via the always-on turn cap before the skipLoopDetection gate', async () => { let abortHandlerInvoked = false; mockMemoryManager.recall.mockImplementation((_root, _query, opts) => { diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index f9304d68c2f..678ba06976c 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -3482,6 +3482,16 @@ export class GeminiClient { const resultStream = turn.run(model, requestToSend, signal); let didUpdateIdeContextState = false; let steerInputSettled = false; + // callIds already fed to the loop guards this attempt. Mirrors the + // execution-side dedup (coreToolScheduler.dedupeRequestsByCallId / the + // interactive duplicate-call-id suppression), which collapses + // provider-duplicate emissions into one executed call and one result: + // feeding the guards once per call id keeps request counts and result + // evidence on the same population (main-session twin of the agent-core + // fix, issue #9450). Id-less requests are never deduped, mirroring + // dedupeRequestsByCallId. Cleared on retry/fallback alongside the + // attempt's accumulated state. + const loopGuardFedCallIds = new Set(); try { for await (const event of resultStream) { if (!steerInputSettled) { @@ -3501,6 +3511,7 @@ export class GeminiClient { event.type === GeminiEventType.ModelFallback ) { hasToolCalls = false; + loopGuardFedCallIds.clear(); agentOutput.restartAttempt( event.type === GeminiEventType.Retry && event.isContinuation === true, @@ -3520,11 +3531,28 @@ export class GeminiClient { didUpdateIdeContextState = true; } + // A provider-duplicate emission of an already-fed call id executes + // once (the schedulers collapse it), so feed the loop guards once — + // counting both emissions would leave the request counters one ahead + // of the executed result evidence and fail-safe-halt a productive + // stateful poller (issue #9450). The event itself still flows to + // consumers below; only the guard feed is deduped. + let duplicateLoopGuardRequest = false; + if (event.type === GeminiEventType.ToolCallRequest) { + const fedCallId = event.value.callId; + if (fedCallId) { + duplicateLoopGuardRequest = loopGuardFedCallIds.has(fedCallId); + loopGuardFedCallIds.add(fedCallId); + } + } + // Always-on safety checks (consecutive-identical tool-call guard, // shell inspection stagnation, and per-turn tool-call cap). These fire // before the skipLoopDetection gate so they cannot be bypassed by // configuration. - const alwaysOnLoop = this.loopDetector.checkAlwaysOnSafeties(event); + const alwaysOnLoop = + !duplicateLoopGuardRequest && + this.loopDetector.checkAlwaysOnSafeties(event); if (alwaysOnLoop) { // Drop every tool call collected before the guard fired so the run // halts here instead of spawning a continuation that re-trips it. @@ -3562,6 +3590,7 @@ export class GeminiClient { // relaxes the heuristics (see nonInteractiveCli.ts). const skipLoopDetection = this.config.getSkipLoopDetection(); const heuristicLoop = + !duplicateLoopGuardRequest && !skipLoopDetection && this.loopDetector.addAndCheckHeuristicLoops(event); if (heuristicLoop) { From 20ce795110c6772512a5cae62486866bd9875da3 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sun, 23 Aug 2026 14:18:43 +0800 Subject: [PATCH 19/51] fix(core): carry the inner stub digest through batch-budget fitting (#9450) --- .../src/utils/tool-response-finalizer.test.ts | 112 +++++++++++++++++- .../core/src/utils/tool-response-finalizer.ts | 19 ++- packages/core/src/utils/truncation.ts | 54 +++++++++ 3 files changed, 183 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/tool-response-finalizer.test.ts b/packages/core/src/utils/tool-response-finalizer.test.ts index 6fd38400b75..a2f81121a08 100644 --- a/packages/core/src/utils/tool-response-finalizer.test.ts +++ b/packages/core/src/utils/tool-response-finalizer.test.ts @@ -5,17 +5,19 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createHash } from 'node:crypto'; import type { Part } from '@google/genai'; import type { Config } from '../config/config.js'; import { getPlanModeSystemReminder } from '../core/prompts.js'; import { ToolNames } from '../tools/tool-names.js'; import { + BATCH_BUDGET_FIT_PREFIX, enforceFunctionResponseBudget, finalizeToolResponses, toolResponseTextLength, type ToolResponseBudgetEntry, } from './tool-response-finalizer.js'; -import { persistAndTruncateToolResult } from './truncation.js'; +import { buildStub, persistAndTruncateToolResult } from './truncation.js'; const debugLogger = vi.hoisted(() => ({ debug: vi.fn(), @@ -799,4 +801,112 @@ describe('tool response finalization', () => { expect(output.startsWith(reminder)).toBe(true); expect(output.length).toBeLessThanOrEqual(reminder.length + 2 + 100); }); + + describe('batch-budget fits over already-persisted stubs (issue #9450)', () => { + // The scheduler persists oversized results BEFORE the batch budget runs, + // so the text a fit wraps can itself be a `` stub whose + // envelope embeds the per-call unique `/.txt` + // path. Hashing that envelope would fingerprint every poll of an + // unchanged board uniquely and silently disable the result-aware loop + // guards; the fit must carry the stub's inner digest instead. + const boardDigest = (board: string): string => + createHash('sha256').update(board).digest('hex'); + + const fittedDigest = (text: string | undefined): string => { + const match = /Full output sha256: ([0-9a-f]{64})/.exec(text ?? ''); + return match?.[1] ?? ''; + }; + + const stubEntry = (callId: string, board: string) => { + const stub = buildStub( + board, + Buffer.byteLength(board), + `/tmp/tool-results/${callId}.txt`, + ); + return entry( + callId, + [ + { + functionResponse: { + id: callId, + name: 'task_list', + response: { output: stub }, + }, + }, + ], + [`/tmp/tool-results/${callId}.txt`], + ); + }; + + it('carries the inner stub digest through the fit instead of hashing the unique envelope', async () => { + const board = `#1 [in_progress] @peer-a — ship it\n${'board line\n'.repeat(300)}`; + const entries = [stubEntry('call-a', board), stubEntry('call-b', board)]; + + // Budget 600 over two ~2.3K stubs → each allocation (300) is below the + // stub length, so both are fitted. + const finalized = await finalizeToolResponses( + config(600), + entries, + new Map(), + ); + const outputs = finalized.map( + (finalizedEntry) => + finalizedEntry.responseParts[0].functionResponse?.response?.[ + 'output' + ], + ); + for (const output of outputs) { + expect(typeof output).toBe('string'); + expect(output as string).toContain(BATCH_BUDGET_FIT_PREFIX); + } + // Both polls of the frozen board must carry the board's digest — the + // per-envelope hashes differ (unique callId paths), so pre-fix each + // header carried a unique digest and the two assertions below failed. + expect(fittedDigest(outputs[0] as string)).toBe(boardDigest(board)); + expect(fittedDigest(outputs[1] as string)).toBe(boardDigest(board)); + }); + + it('keeps the carried digest stable when a fit is fitted again', async () => { + // geminiChat's send guard runs the same allocator on the fitted output + // of a later batch; the reduction must stay idempotent across that + // second nesting (the first fit's header is per-call unique via its + // artifact note, so it too must be reduced to the carried digest). + const board = `#2 [in_progress] @peer-b — verify\n${'board line\n'.repeat(300)}`; + + const once = await finalizeToolResponses( + config(400), + [stubEntry('call-a', board)], + new Map(), + ); + const firstFit = once[0].responseParts[0].functionResponse?.response?.[ + 'output' + ] as string; + expect(firstFit).toContain(BATCH_BUDGET_FIT_PREFIX); + + const twice = await finalizeToolResponses( + config(250), + [ + entry( + 'call-a', + [ + { + functionResponse: { + id: 'call-a', + name: 'task_list', + response: { output: firstFit }, + }, + }, + ], + ['/tmp/tool-results/call-a.txt'], + ), + ], + new Map(), + ); + const secondFit = twice[0].responseParts[0].functionResponse?.response?.[ + 'output' + ] as string; + expect(secondFit).toContain(BATCH_BUDGET_FIT_PREFIX); + expect(fittedDigest(secondFit)).toBe(boardDigest(board)); + }); + }); }); diff --git a/packages/core/src/utils/tool-response-finalizer.ts b/packages/core/src/utils/tool-response-finalizer.ts index 0135e44b02d..539fe6d0c71 100644 --- a/packages/core/src/utils/tool-response-finalizer.ts +++ b/packages/core/src/utils/tool-response-finalizer.ts @@ -17,6 +17,8 @@ import { type ToolResultBoundaryStage, } from './tool-result-boundary-diagnostics.js'; import { + extractAnchoredStubDigest, + extractPersistedStubDigest, FULL_OUTPUT_DIGEST_LABEL, normalizeToolResultCallId, persistAndTruncateToolResult, @@ -231,7 +233,22 @@ function fitText( // loop guards for exactly these oversized batch-budget results (issue // #9450). The digest sits right after the constant prefix so it survives // even when a tiny allocation slices the header. - const digest = createHash('sha256').update(text).digest('hex'); + // + // Idempotence across nesting: the scheduler persists oversized results + // BEFORE the batch budget runs, so the text fitted here can itself be an + // already-persisted stub whose envelope embeds the per-call unique + // `/.txt` path. Hashing THAT envelope would + // fingerprint every poll of an unchanged board uniquely and disable the + // result-aware guards again (the guards' digest-first reduction would take + // this header's outer digest), so carry the inner stub's own digest + // instead — and likewise the digest of a prior batch-budget fit, whose + // header is per-call unique via its artifact note. + const digest = + extractPersistedStubDigest(text) ?? + (text.startsWith(BATCH_BUDGET_FIT_PREFIX) + ? extractAnchoredStubDigest(text) + : null) ?? + createHash('sha256').update(text).digest('hex'); const digestLine = `${FULL_OUTPUT_DIGEST_LABEL}${digest}`; const artifactNote = persistedOutputFiles && persistedOutputFiles.length > 0 diff --git a/packages/core/src/utils/truncation.ts b/packages/core/src/utils/truncation.ts index 397d3e69efa..e874f3ffe17 100644 --- a/packages/core/src/utils/truncation.ts +++ b/packages/core/src/utils/truncation.ts @@ -51,6 +51,60 @@ export const TRUNCATED_PART_MARKER = 'Truncated part of the output:\n'; */ export const FULL_OUTPUT_DIGEST_LABEL = 'Full output sha256: '; +/** + * Extracts the sha256 digest a stub producer embedded for the FULL + * pre-truncation output, anchored to a producer line: the label must start + * its line and be followed by exactly 64 hex chars ending the line. A + * mid-string mention of the label (e.g. board content quoting a stub) never + * matches. Returns null when no anchored digest is present. Exported so the + * batch-budget finalizer can carry a nested stub's digest through a fit + * (see fitText there) instead of hashing the per-call unique envelope + * (issue #9450); the loop guards keep their own private copy because they + * already import from this module and the recognition must not drift from + * the producer constants above. + */ +export function extractAnchoredStubDigest(value: string): string | null { + let searchFrom = 0; + for (;;) { + const index = value.indexOf(FULL_OUTPUT_DIGEST_LABEL, searchFrom); + if (index < 0) return null; + const digestStart = index + FULL_OUTPUT_DIGEST_LABEL.length; + const lineAnchored = index === 0 || value[index - 1] === '\n'; + if (lineAnchored) { + const digest = value.slice(digestStart, digestStart + 64); + const terminator = value[digestStart + 64]; + if ( + /^[0-9a-f]{64}$/.test(digest) && + (terminator === undefined || terminator === '\n' || terminator === '\r') + ) { + return digest; + } + } + searchFrom = index + 1; + } +} + +/** + * Returns the embedded full-output digest when `text` itself is an + * oversized-result stub produced by this module: a `` + * envelope, an unwrapped `Output too large (...)` stub, or a + * `truncateAndSaveToFile` wrapper. Returns null for any other text. Used by + * the batch-budget finalizer's fitText to make stub reduction idempotent + * across nesting: the scheduler persists oversized results BEFORE the batch + * budget runs, so a fit wrapping an already-persisted stub must carry the + * stub's inner digest into its header instead of hashing the stub envelope, + * which embeds a per-call unique `/.txt` path and + * would fingerprint every poll of an unchanged board uniquely (issue #9450). + */ +export function extractPersistedStubDigest(text: string): string | null { + const isProducerStub = + text.startsWith(PERSISTED_OUTPUT_OPEN_TAG) || + text.startsWith(OUTPUT_TOO_LARGE_PREFIX) || + text.startsWith(TOOL_OUTPUT_TRUNCATED_PREFIX); + if (!isProducerStub) return null; + return extractAnchoredStubDigest(text); +} + /** * Tolerance factor applied by the scheduler's combined (second) pass: * metadata appended after truncation is only re-bounded above 2x the From edb1f30c6e9cb31ab18e6ecb60998ff3e0528603 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sun, 23 Aug 2026 14:22:46 +0800 Subject: [PATCH 20/51] fix(core): decay abandoned stateful streaks at round-trip boundaries (#9450) --- .../src/services/loopDetectionService.test.ts | 85 +++++++++++++++++++ .../core/src/services/loopDetectionService.ts | 80 +++++++++++++++-- 2 files changed, 158 insertions(+), 7 deletions(-) diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index 54668069628..345503d489b 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -2878,6 +2878,91 @@ describe('LoopDetectionService', () => { expect(capService.getLastLoopType()).toBe(LoopType.TURN_TOOL_CALL_CAP); }); + it('still halts a continuously frozen poller across Finished round-trips', () => { + // The Finished-boundary decay must only release ABANDONED keys: a board + // that stays frozen while the model keeps polling every round-trip keeps + // its stuck signal, so the adaptive cap still halts it just past the + // soft cap (fail-safe twin of the abandon regression below). + const capService = new LoopDetectionService(makeConfig(20)); + capService.reset('cap-frozen-rounds'); + const finishedEvent = { + type: GeminiEventType.Finished, + value: { reason: 'STOP' }, + } as unknown as ServerGeminiStreamEvent; + + let fired = false; + let totalCalls = 0; + for (let round = 0; round < 40 && !fired; round++) { + fired = capService.checkAlwaysOnSafeties(taskListEvent(`tl-${round}`)); + totalCalls++; + if (fired) break; + fired = capService.checkAlwaysOnSafeties( + createToolCallRequestEvent('tool_b', { step: round }), + ); + totalCalls++; + if (fired) break; + fired = capService.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult('frozen board'), + ); + if (fired) break; + capService.checkAlwaysOnSafeties(finishedEvent); + } + expect(fired).toBe(true); + expect(capService.getLastLoopType()).toBe(LoopType.TURN_TOOL_CALL_CAP); + expect(totalCalls).toBeLessThanOrEqual(22); + }); + + it('releases the adaptive cap when a frozen poller is abandoned for productive work', () => { + // The cap's stateful stuck signal must not latch a stale peak from an + // abandoned key: interleaved frozen polls peak the signal, then the + // model stops polling and does diverse productive work. Pre-fix the + // add-only key map kept the peak for the whole prompt, so the turn was + // halted as TURN_TOOL_CALL_CAP just past the soft cap (issue #9450). + // CLI default skipLoopDetection=true: the cap is the only live path. + const capService = new LoopDetectionService(makeConfig(20)); + capService.reset('cap-abandon'); + const finishedEvent = { + type: GeminiEventType.Finished, + value: { reason: 'STOP' }, + } as unknown as ServerGeminiStreamEvent; + + let fired = false; + // 8 interleaved frozen task_list polls, each its own round-trip: the + // stateful stuck signal peaks at 8 without halting (still under cap). + for (let round = 0; round < 8 && !fired; round++) { + fired ||= capService.checkAlwaysOnSafeties( + taskListEvent(`tl-${round}`), + ); + fired ||= capService.checkAlwaysOnSafeties( + createToolCallRequestEvent('tool_b', { step: round }), + ); + if (fired) break; + fired = capService.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult('frozen board'), + ); + if (fired) break; + capService.checkAlwaysOnSafeties(finishedEvent); + } + expect(fired).toBe(false); + + // The model abandons polling and does diverse productive work well past + // the soft cap of 20. The abandoned key's peak must decay at the + // Finished boundaries, so no TURN_TOOL_CALL_CAP halt fires. + for (let i = 0; i < 30 && !fired; i++) { + fired = capService.checkAlwaysOnSafeties( + createToolCallRequestEvent('tool_c', { i }), + ); + if (fired) break; + if (i % 3 === 2) { + capService.checkAlwaysOnSafeties(finishedEvent); + } + } + expect(fired).toBe(false); + expect(capService.getLastLoopType()).toBeNull(); + }); + it('does not collapse the fingerprint when board content merely quotes the digest label', () => { // task_list embeds peer-authored text verbatim, and agents quote stub // text (including the `Full output sha256: ` line this PR adds to diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index c83893217d9..02f86259219 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -426,6 +426,16 @@ export class LoopDetectionService { } >(); + // Stateful keys that recorded a result since the last Finished round-trip + // boundary. At each Finished, keys NOT in this set produced no result for + // a whole round-trip: the model moved on to other work, so their streak + // evidence is abandoned and must stop feeding the cap's stuck signal — + // otherwise a key abandoned after a frozen phase keeps its peak for the + // whole prompt and the adaptive cap halts a productive turn just past the + // soft cap (issue #9450). Keys that keep polling appear in every round's + // results and are never decayed. + private statefulResultKeysSinceLastFinished = new Set(); + // callId → request pairing so results can be matched to their calls when // the runtime only has the response (populated on ToolCallRequest events, // consumed by recordToolResultByCallId). @@ -498,6 +508,11 @@ export class LoopDetectionService { if (fingerprint === null) return false; const key = this.getToolCallKey(toolCall); + // Round-trip boundary bookkeeping: this key produced a result in the current + // round, so the Finished-boundary decay must not treat it as abandoned + // (see statefulResultKeysSinceLastFinished). + this.statefulResultKeysSinceLastFinished.add(key); + // Rolling result history for the alternating-pattern carve-out (see // checkAlternatingPattern), capped at one window's occurrences per key. const history = this.statefulAlternationHistory.get(key) ?? []; @@ -558,13 +573,7 @@ export class LoopDetectionService { if (consecutiveIdentical > this.statefulCapKeyRepeat) { this.statefulCapKeyRepeat = consecutiveIdentical; } else if (fingerprintChanged) { - let peak = consecutiveIdentical; - for (const other of this.statefulRepeatState.values()) { - if (other.consecutiveIdenticalResults > peak) { - peak = other.consecutiveIdenticalResults; - } - } - this.statefulCapKeyRepeat = peak; + this.recomputeStatefulCapPeak(); } // The global-duplicate detector is gated (skipLoopDetection) exactly as @@ -718,6 +727,12 @@ export class LoopDetectionService { // round-trip rather than resetting to zero. if (event.type === GeminiEventType.Finished) { this.turnToolCallTotalCommitted = this.turnToolCallTotal; + // Results are recorded between round-trips (after the Finished event + // of the stream that emitted their calls), so at this boundary the + // results recorded since the previous Finished are exactly the prior + // round's executed results — the safe point to decay stateful keys + // absent from them (see decayAbandonedStatefulStreaks). + this.decayAbandonedStatefulStreaks(); return false; } @@ -744,6 +759,7 @@ export class LoopDetectionService { state.unchangedStreak = 0; state.consecutiveIdenticalResults = 0; } + this.statefulResultKeysSinceLastFinished.clear(); this.statefulAlternationHistory.clear(); this.statefulRepeatKeys.clear(); return false; @@ -1325,6 +1341,55 @@ export class LoopDetectionService { return false; } + /** + * Recomputes the cap's stateful stuck signal (statefulCapKeyRepeat) from + * the keys' CURRENT consecutive-identical-result streaks, dropping any + * latched peak that no longer reflects live evidence. + */ + private recomputeStatefulCapPeak(): void { + let peak = 0; + for (const state of this.statefulRepeatState.values()) { + if (state.consecutiveIdenticalResults > peak) { + peak = state.consecutiveIdenticalResults; + } + } + this.statefulCapKeyRepeat = peak; + } + + /** + * Round-trip boundary decay for the cap's stateful stuck signal. A key + * that produced no result for a whole round-trip was abandoned: the model + * moved on to other work, so its frozen-phase streak must stop feeding the + * stuck signal. Without this the key map is add-only and the peak latches + * for the whole prompt — the adaptive cap would then halt a productive + * turn just past the soft cap on the abandoned key's stale peak (issue + * #9450). Keys polled in every round-trip appear in the set and keep their + * streaks, so a continuously frozen board still arms the cap. lastFingerprint + * survives the decay: when polling resumes, the first fresh result is + * still judged against the last observed one (changed → productive, + * unchanged → the count re-accumulates toward the halt). + */ + private decayAbandonedStatefulStreaks(): void { + let decayed = false; + for (const [key, state] of this.statefulRepeatState) { + if (this.statefulResultKeysSinceLastFinished.has(key)) continue; + if ( + state.consecutiveIdenticalResults > 0 || + state.resultsObserved > 0 || + state.unchangedStreak > 0 + ) { + state.consecutiveIdenticalResults = 0; + state.resultsObserved = 0; + state.unchangedStreak = 0; + decayed = true; + } + } + this.statefulResultKeysSinceLastFinished.clear(); + if (decayed) { + this.recomputeStatefulCapPeak(); + } + } + /** * Records a (tool,args) occurrence for the adaptive cap and updates the * running max repeat count. Always-on (called from checkAlwaysOnSafeties @@ -1493,6 +1558,7 @@ export class LoopDetectionService { this.capMaxKeyRepeat = 0; this.statefulCapKeyRepeat = 0; this.statefulRepeatState.clear(); + this.statefulResultKeysSinceLastFinished.clear(); this.statefulRepeatKeys.clear(); this.statefulAlternationHistory.clear(); this.requestByCallId.clear(); From dd309b329589092997e7ebbf480be86f5f4b877a Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sun, 23 Aug 2026 14:29:49 +0800 Subject: [PATCH 21/51] fix(cli): decay abandoned stateful streaks at daemon batch boundaries (#9450) --- .../acp-integration/session/Session.test.ts | 97 +++++++++++++++++++ .../src/acp-integration/session/Session.ts | 54 +++++++++++ 2 files changed, 151 insertions(+) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index ac84844cdff..70261624e4c 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -10768,6 +10768,39 @@ describe('Session', () => { return execute; }; + // Like installTaskListTool, but every non-task_list tool also + // resolves to an executable mock so diverse productive calls run + // instead of tripping the missing-tool guard. + const installTaskListAndGenericTools = (boards: () => string) => { + mockToolRegistry.getTool.mockImplementation((name: string) => { + const execute = + name === 'task_list' + ? vi.fn().mockImplementation(async () => ({ + llmContent: boards(), + returnDisplay: 'ok', + })) + : vi.fn().mockResolvedValue({ + llmContent: 'ok', + returnDisplay: 'ok', + }); + return { + name, + kind: core.Kind.Read, + displayName: name, + description: name, + build: vi.fn().mockImplementation((args) => ({ + params: args, + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + getDescription: vi.fn().mockReturnValue(name), + toolLocations: vi.fn().mockReturnValue([]), + execute, + })), + canUpdateOutput: false, + isOutputMarkdown: false, + }; + }); + }; + const runTaskListPoll = ( loopState: ReturnType, round: number, @@ -10879,6 +10912,70 @@ describe('Session', () => { expect(thawed).toBe(true); expect(loopState.loopDetected).toBe(false); }); + + it('releases the daemon cap when a frozen task_list poller is abandoned for productive work', async () => { + // CLI defaults: skipLoopDetection=true, adaptive soft cap — the + // cap's stateful stuck signal is the ONLY live halt path. The + // streak map must not latch a stale peak from an abandoned key: + // interleaved frozen polls peak the signal, then the model stops + // polling and does diverse productive work. Pre-fix the add-only + // streak map kept the peak for the whole turn, so the productive + // turn was halted as TURN_TOOL_CALL_CAP just past the soft cap + // (issue #9450; core twin in loopDetectionService). + mockConfig.getApprovalMode = vi + .fn() + .mockReturnValue(ApprovalMode.YOLO); + mockConfig.getMaxToolCallsPerTurn = vi.fn().mockReturnValue(20); + mockConfig.isMaxToolCallsPerTurnExplicit = vi + .fn() + .mockReturnValue(false); + mockConfig.getSkipLoopDetection = vi.fn().mockReturnValue(true); + installTaskListAndGenericTools(() => 'frozen board'); + const loopState = freshLoopState(); + + // 8 interleaved frozen task_list polls: the stuck signal peaks at + // 8 without halting (still under the soft cap). + for (let round = 0; round < 8; round++) { + const result = await runTaskListPoll(loopState, round); + expect(result.loopDetected ?? false).toBe(false); + } + expect(loopState.statefulMaxResultRepeat).toBe(8); + + // Abandon polling; do diverse productive work past the soft cap + // of 20. The abandoned key's peak must decay at the batch + // boundaries, so no TURN_TOOL_CALL_CAP halt fires. + const runDiverseBatch = (round: number) => + ( + session as unknown as { + runToolCalls: ( + abortSignal: AbortSignal, + promptId: string, + calls: unknown[], + loopState: ReturnType, + ) => Promise<{ loopDetected?: boolean; parts: Part[] }>; + } + ).runToolCalls( + new AbortController().signal, + `prompt-diverse-${round}`, + [ + { + id: `diverse_${round}`, + name: 'generic_tool', + args: { step: round }, + }, + ], + loopState, + ); + + let fired = false; + for (let round = 0; round < 20 && !fired; round++) { + const result = await runDiverseBatch(round); + fired = result.loopDetected ?? false; + } + expect(fired).toBe(false); + expect(loopState.loopDetected).toBe(false); + expect(loopState.totalToolCalls).toBeGreaterThan(20); + }); }); }); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index a0c3428bbd1..a966567670a 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -675,6 +675,14 @@ export type DaemonToolLoopState = { * Disarmed when a result changes, so a thawed board releases the cap. */ statefulMaxResultRepeat: number; + /** + * Stateful keys that recorded a result since the previous batch (mirrors + * core's statefulResultKeysSinceLastFinished). At each batch boundary, + * keys absent from this set are abandoned and their streaks stop feeding + * statefulMaxResultRepeat (issue #9450). Optional: lazily initialized so + * pre-existing hand-built states stay compatible. + */ + statefulResultKeysSinceLastBatch?: Set; loopDetected: boolean; loopType?: LoopType; repeatedToolFailureMode: RepeatedToolFailureGuardMode; @@ -705,6 +713,7 @@ function createDaemonToolLoopState( maxToolCallKeyRepeat: 0, statefulResultStreaks: new Map(), statefulMaxResultRepeat: 0, + statefulResultKeysSinceLastBatch: new Set(), loopDetected: false, repeatedToolFailureMode, repeatedToolFailureState: createRepeatedToolFailureGuardState(), @@ -845,6 +854,41 @@ function isLoopDetectedTurnError(error: unknown): boolean { ); } +/** + * Batch-boundary decay for the cap's stateful stuck signal — the daemon + * twin of core's LoopDetectionService.decayAbandonedStatefulStreaks; the + * two runtimes must not drift (issue #9450 requirement #6). A stateful key + * that produced no result since the previous batch was abandoned: the model + * moved on to other work, so its frozen-phase streak must stop feeding + * statefulMaxResultRepeat. Without this the streak map is add-only and the + * peak latches for the whole turn — under the CLI default + * skipLoopDetection=true the cap's stuck signal is the ONLY live halt path + * for a frozen daemon poller, and the latched peak would halt a productive + * turn just past the soft cap. Keys polled in every batch appear in the set + * and keep their streaks, so a continuously frozen board still arms the cap. + */ +function decayAbandonedDaemonStreaks(loopState: DaemonToolLoopState): void { + const sinceLastBatch = (loopState.statefulResultKeysSinceLastBatch ??= + new Set()); + let decayed = false; + for (const [key, state] of loopState.statefulResultStreaks) { + if (sinceLastBatch.has(key)) continue; + if (state.consecutiveIdenticalResults > 0) { + state.consecutiveIdenticalResults = 0; + decayed = true; + } + } + sinceLastBatch.clear(); + if (!decayed) return; + let peak = 0; + for (const state of loopState.statefulResultStreaks.values()) { + if (state.consecutiveIdenticalResults > peak) { + peak = state.consecutiveIdenticalResults; + } + } + loopState.statefulMaxResultRepeat = peak; +} + function recordDaemonToolCalls( config: Config, promptId: string, @@ -853,6 +897,11 @@ function recordDaemonToolCalls( ): boolean { if (!loopState || loopState.loopDetected) return loopState?.loopDetected ?? false; + // Batch boundary: the previous batch's results have all been recorded by + // now (results are recorded during execution, before the next batch is + // streamed), so this is the safe point to decay stateful keys absent from + // them — the daemon twin of core's Finished-boundary decay (issue #9450). + decayAbandonedDaemonStreaks(loopState); loopState.totalToolCalls += calls.length; for (const call of calls) { // Stateful read tools are counted post-execution in @@ -960,6 +1009,11 @@ function recordDaemonToolResult( if (fingerprint === null) return false; const key = getToolCallRepeatKey(toolCall.name, toolCall.args); + // Batch bookkeeping: this key produced a result in the current batch, so + // the next batch boundary must not decay it (see + // decayAbandonedDaemonStreaks). + (loopState.statefulResultKeysSinceLastBatch ??= new Set()).add(key); + let state = loopState.statefulResultStreaks.get(key); if (!state) { state = { consecutiveIdenticalResults: 0, lastFingerprint: undefined }; From 00aa71b4a0a37df1c392eb9b90e1c53d008fdce5 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sun, 23 Aug 2026 14:33:45 +0800 Subject: [PATCH 22/51] fix(core): make the alternating-pattern carve-out in-flight aware (#9450) --- .../src/services/loopDetectionService.test.ts | 88 +++++++++++++++++++ .../core/src/services/loopDetectionService.ts | 48 ++++++++-- 2 files changed, 130 insertions(+), 6 deletions(-) diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index 345503d489b..d2ae49e06b5 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -2535,6 +2535,94 @@ describe('LoopDetectionService', () => { ); }); + it('does not halt a batched [task_list, tool_b] ABAB poller whose results keep changing', () => { + // Parallel batches feed BOTH requests of a round to the heuristic + // tier before that round's results land, with the stateful call + // LEADING the batch. Pre-fix the carve-out encoded a strictly + // sequential in-flight model (only the window-tail key got + // occurrences - 1): when the 6th request filled the window, the + // leading key's 3rd occurrence was still in flight, history held 2 + // fingerprints but expectedResults=3, the exonerating check was + // skipped, and the guard halted ALTERNATING_TOOL_CALL_PATTERN on + // args alone despite every result having changed (issue #9450). The + // run stays at 4 rounds so tool_b's constant-args request count (4) + // stays below the global-duplicate threshold. + const heuristicService = new LoopDetectionService( + makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), + ); + heuristicService.reset('batched-alternating-productive'); + + let fired = false; + for (let round = 0; round < 4 && !fired; round++) { + fired = heuristicService.addAndCheck(taskListEvent(`tl-${round}`)); + if (fired) break; + fired = heuristicService.addAndCheck( + createToolCallRequestEvent('tool_b', { step: 'work' }), + ); + if (fired) break; + fired = heuristicService.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult(`board state v${round}`), + ); + } + expect(fired).toBe(false); + expect(loggers.logLoopDetected).not.toHaveBeenCalled(); + }); + + it('keeps the batched [tool_b, task_list] ordering exonerated', () => { + // Ordering twin: with the stateful call TRAILING the batch the + // window fills on a task_list request, which was already exonerated + // pre-fix (the tail key lost one expected result). Pins that the + // in-flight counter does not regress this ordering. + const heuristicService = new LoopDetectionService( + makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), + ); + heuristicService.reset('batched-alternating-reversed'); + + let fired = false; + for (let round = 0; round < 4 && !fired; round++) { + fired = heuristicService.addAndCheck( + createToolCallRequestEvent('tool_b', { step: 'work' }), + ); + if (fired) break; + fired = heuristicService.addAndCheck(taskListEvent(`tl-${round}`)); + if (fired) break; + fired = heuristicService.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult(`board state v${round}`), + ); + } + expect(fired).toBe(false); + }); + + it('still halts a batched ABAB pattern when the stateful results are frozen (fail-safe)', () => { + // Fail-safe twin of the batched regression: with an unchanged board + // the recorded results corroborate the alternation, so the halt must + // still fire under the batched [task_list, tool_b] ordering. + const heuristicService = new LoopDetectionService( + makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), + ); + heuristicService.reset('batched-alternating-frozen'); + + let fired = false; + for (let round = 0; round < 4 && !fired; round++) { + fired = heuristicService.addAndCheck(taskListEvent(`tl-${round}`)); + if (fired) break; + fired = heuristicService.addAndCheck( + createToolCallRequestEvent('tool_b', { step: 'work' }), + ); + if (fired) break; + fired = heuristicService.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult('frozen board'), + ); + } + expect(fired).toBe(true); + expect(heuristicService.getLastLoopType()).toBe( + LoopType.ALTERNATING_TOOL_CALL_PATTERN, + ); + }); + it('treats changed results as progress for action stagnation', () => { const heuristicService = new LoopDetectionService( makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 02f86259219..e2d18e4ae55 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -452,6 +452,17 @@ export class LoopDetectionService { // its own requests produced. private statefulAlternationHistory = new Map(); + // Per-key count of stateful requests fed to the heuristic tier whose + // results have NOT landed yet (incremented in addAndCheckHeuristicLoops, + // decremented in recordToolResult). With parallel tool batches both + // requests of a round reach the guard before that round's results land, + // so a window judged on args alone would skip the exonerating result + // check for occurrences still in flight and false-halt a productive + // poller; the carve-out subtracts these from its expected results (issue + // #9450). Reduces to the sequential arithmetic when results land before + // the next request is fed. + private statefulInFlight = new Map(); + // Loop type of the most recent firing. Bubbled up through the // LoopDetected event so callers (non-interactive CLI, telemetry) can tell // the user which detector actually fired. @@ -513,6 +524,15 @@ export class LoopDetectionService { // (see statefulResultKeysSinceLastFinished). this.statefulResultKeysSinceLastFinished.add(key); + // One in-flight request for this key has landed: unreserve it for the + // alternating-pattern carve-out (see statefulInFlight). Floored at zero + // because results can be recorded without a heuristic feed when + // skipLoopDetection keeps the heuristic tier off. + const inFlight = this.statefulInFlight.get(key) ?? 0; + if (inFlight > 0) { + this.statefulInFlight.set(key, inFlight - 1); + } + // Rolling result history for the alternating-pattern carve-out (see // checkAlternatingPattern), capped at one window's occurrences per key. const history = this.statefulAlternationHistory.get(key) ?? []; @@ -666,6 +686,13 @@ export class LoopDetectionService { const stateful = this.isStatefulReadTool(event.value.name); if (stateful) { this.statefulRepeatKeys.add(toolCallKey); + // This request is now in flight: its result has not landed yet, + // so the alternating-pattern carve-out must not expect it (see + // statefulInFlight). recordToolResult decrements when it lands. + this.statefulInFlight.set( + toolCallKey, + (this.statefulInFlight.get(toolCallKey) ?? 0) + 1, + ); } const globalDup = stateful ? false @@ -690,6 +717,7 @@ export class LoopDetectionService { this.recentToolCallKeys = []; this.statefulAlternationHistory.clear(); this.statefulRepeatKeys.clear(); + this.statefulInFlight.clear(); break; } case GeminiEventType.Content: { @@ -762,6 +790,7 @@ export class LoopDetectionService { this.statefulResultKeysSinceLastFinished.clear(); this.statefulAlternationHistory.clear(); this.statefulRepeatKeys.clear(); + this.statefulInFlight.clear(); return false; } @@ -1497,20 +1526,26 @@ export class LoopDetectionService { // identical arguments do not imply an identical result, so an ABAB // poller is only stuck when its observed results corroborate it. For // every stateful participant require the results produced by the - // window's own prior requests (all of them for the key that opened the - // window, all but the in-flight last request for the other); if ANY + // window's own prior requests, minus the requests still in flight (fed + // to this tier but not yet answered — with parallel tool batches BOTH + // requests of a round reach the guard before that round's results land, + // so more than just the window-tail request can be in flight); if ANY // recorded result changed, the alternation is making observable // progress and the window restarts. Missing result evidence (results // never recorded) fails safe and keeps the argument-only halt, so a - // wiring gap never loosens the guard. - const windowTail = this.recentToolCallKeys[maxLen - 1]; + // wiring gap never loosens the guard. The per-key in-flight count + // reduces to the sequential arithmetic (tail request in flight) when + // each result lands before the next request is fed. for (const altKey of [a, b]) { if (!this.statefulRepeatKeys.has(altKey)) continue; const occurrences = this.recentToolCallKeys.filter( (windowKey) => windowKey === altKey, ).length; - const expectedResults = - altKey === windowTail ? occurrences - 1 : occurrences; + const inFlight = Math.min( + this.statefulInFlight.get(altKey) ?? 0, + occurrences, + ); + const expectedResults = occurrences - inFlight; if (expectedResults <= 0) continue; const history = this.statefulAlternationHistory.get(altKey); if (!history || history.length < expectedResults) { @@ -1561,6 +1596,7 @@ export class LoopDetectionService { this.statefulResultKeysSinceLastFinished.clear(); this.statefulRepeatKeys.clear(); this.statefulAlternationHistory.clear(); + this.statefulInFlight.clear(); this.requestByCallId.clear(); } From 71630d81c526997f87e0ef1a2cef2caed755c288 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sun, 23 Aug 2026 19:17:00 +0800 Subject: [PATCH 23/51] fix(core): keep degenerate batch-budget fits content-dependent (#9450) --- .../src/utils/tool-response-finalizer.test.ts | 114 +++++++++++++++++- .../core/src/utils/tool-response-finalizer.ts | 27 ++++- 2 files changed, 135 insertions(+), 6 deletions(-) diff --git a/packages/core/src/utils/tool-response-finalizer.test.ts b/packages/core/src/utils/tool-response-finalizer.test.ts index a2f81121a08..ea01a838285 100644 --- a/packages/core/src/utils/tool-response-finalizer.test.ts +++ b/packages/core/src/utils/tool-response-finalizer.test.ts @@ -17,7 +17,11 @@ import { toolResponseTextLength, type ToolResponseBudgetEntry, } from './tool-response-finalizer.js'; -import { buildStub, persistAndTruncateToolResult } from './truncation.js'; +import { + buildStub, + FULL_OUTPUT_DIGEST_LABEL, + persistAndTruncateToolResult, +} from './truncation.js'; const debugLogger = vi.hoisted(() => ({ debug: vi.fn(), @@ -909,4 +913,112 @@ describe('tool response finalization', () => { expect(fittedDigest(secondFit)).toBe(boardDigest(board)); }); }); + + describe('degenerate batch-budget fits stay content-dependent (issue #9450)', () => { + // The digest starts at offset BATCH_BUDGET_FIT_PREFIX.length + 1 + + // FULL_OUTPUT_DIGEST_LABEL.length (= 43), so pre-fix any per-slot + // allocation <= 43 sliced only constant header text: every oversized + // result fingerprinted identically regardless of content, and repeated + // polls of a CHANGING board false-halted on + // consecutive_identical_tool_calls under a small configured + // toolOutputBatchBudget. + const oversizedEntry = (callId: string, board: string) => + entry(callId, [ + { + functionResponse: { + id: callId, + name: 'task_list', + response: { output: `${board}\n${'board line\n'.repeat(300)}` }, + }, + }, + ]); + + const fittedOutput = (entries: ToolResponseBudgetEntry[]) => + entries[0].responseParts[0].functionResponse?.response?.['output']; + + it('fingerprints distinct boards distinctly at allocations below the digest offset', () => { + // Single oversized slot: the whole budget is the slot's allocation. + const boardA = '#1 [in_progress] @peer-a — ship it'; + const boardB = '#2 [completed] @peer-b — totally different board'; + + for (const budget of [21, 40, 43]) { + const fitA = fittedOutput( + enforceFunctionResponseBudget([oversizedEntry('a', boardA)], budget), + ) as string; + const fitB = fittedOutput( + enforceFunctionResponseBudget([oversizedEntry('b', boardB)], budget), + ) as string; + expect(fitA.length).toBeLessThanOrEqual(budget); + expect(fitB.length).toBeLessThanOrEqual(budget); + // Content-dependent from the first char past the digest label. + expect(fitA.startsWith(FULL_OUTPUT_DIGEST_LABEL)).toBe(true); + expect(fitA).not.toBe(fitB); + } + }); + + it('keeps the mid band (full digest, sliced header) content-dependent', () => { + // With an artifact note the header outruns the minimal prefix + digest + // line (107 chars), so allocations in [107, header.length) slice the + // header with the FULL digest present — that band must stay + // content-dependent too. + const midBandEntry = (callId: string, board: string) => + entry( + callId, + [ + { + functionResponse: { + id: callId, + name: 'task_list', + response: { output: `${board}\n${'board line\n'.repeat(300)}` }, + }, + }, + ], + [`/tmp/tool-results/${callId}.txt`], + ); + // Same callId for both so the per-call artifact path is identical and + // only the board content (via the digest) can distinguish the fits. + const fitA = fittedOutput( + enforceFunctionResponseBudget( + [midBandEntry('a', '#1 [in_progress] @peer-a')], + 130, + ), + ) as string; + const fitB = fittedOutput( + enforceFunctionResponseBudget( + [midBandEntry('a', '#2 [completed] @peer-b')], + 130, + ), + ) as string; + expect(fitA.startsWith(BATCH_BUDGET_FIT_PREFIX)).toBe(true); + expect(fitA).toContain(FULL_OUTPUT_DIGEST_LABEL); + expect(fitA).not.toBe(fitB); + }); + + it('keeps identical content fingerprint-stable under a degenerate fit', () => { + const board = '#3 [in_progress] @peer-c — frozen board'; + const fitOne = fittedOutput( + enforceFunctionResponseBudget([oversizedEntry('a', board)], 40), + ) as string; + const fitTwo = fittedOutput( + enforceFunctionResponseBudget([oversizedEntry('b', board)], 40), + ) as string; + expect(fitOne).toBe(fitTwo); + }); + + it('does not collapse many distinct boards into one constant text', () => { + // Reviewer witness shape: budget 500 over 12 oversized slots gives + // per-slot allocations of ~41 chars — below the digest offset — which + // pre-fix collapsed every board to the same constant slice. + const entries = Array.from({ length: 12 }, (_, index) => + oversizedEntry(`call-${index}`, `board variant ${index} — distinct`), + ); + const fitted = enforceFunctionResponseBudget(entries, 500).map( + (fittedEntry) => + fittedEntry.responseParts[0].functionResponse?.response?.[ + 'output' + ] as string, + ); + expect(new Set(fitted).size).toBe(12); + }); + }); }); diff --git a/packages/core/src/utils/tool-response-finalizer.ts b/packages/core/src/utils/tool-response-finalizer.ts index 539fe6d0c71..7ba06b95d4d 100644 --- a/packages/core/src/utils/tool-response-finalizer.ts +++ b/packages/core/src/utils/tool-response-finalizer.ts @@ -231,8 +231,9 @@ function fitText( // embeds a per-call artifact path, so hashing the fitted output would // fingerprint every call uniquely and silently disable the result-aware // loop guards for exactly these oversized batch-budget results (issue - // #9450). The digest sits right after the constant prefix so it survives - // even when a tiny allocation slices the header. + // #9450). The digest sits right after the constant prefix; when even the + // header does not fit the allocation, the degenerate slice below takes + // the digest line itself so the fitted text stays content-dependent. // // Idempotence across nesting: the scheduler persists oversized results // BEFORE the batch budget runs, so the text fitted here can itself be an @@ -258,11 +259,27 @@ function fitText( .map((file) => `- ${file}`) .join('\n')}` : undefined; + const minimalHeader = `${BATCH_BUDGET_FIT_PREFIX}\n${digestLine}`; const header = artifactNote - ? `${BATCH_BUDGET_FIT_PREFIX}\n${digestLine}\n${artifactNote}` - : `${BATCH_BUDGET_FIT_PREFIX}\n${digestLine}`; + ? `${minimalHeader}\n${artifactNote}` + : minimalHeader; if (header.length >= maxChars) { - return sliceStartWithoutBrokenSurrogate(header, maxChars); + // Degenerate allocation: the header does not fit whole. As long as the + // allocation holds prefix + digest line, slicing the header keeps the + // full digest (content-dependent). Below that, slicing the header would + // return only constant text — the prefix plus a fragment of the digest + // LABEL, whose digest starts at offset + // BATCH_BUDGET_FIT_PREFIX.length + 1 + FULL_OUTPUT_DIGEST_LABEL.length — + // so every oversized result would fingerprint identically regardless of + // content and a CHANGING board would false-halt on + // consecutive_identical_tool_calls under a small configured + // toolOutputBatchBudget (issue #9450). Slice the digest line itself + // instead so any allocation reaching past the label carries + // content-dependent digest characters. + return sliceStartWithoutBrokenSurrogate( + maxChars >= minimalHeader.length ? header : digestLine, + maxChars, + ); } const separator = '\n\n'; From 660cc461d94781c31cb6ac0de562082a8df96e23 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sun, 23 Aug 2026 19:17:10 +0800 Subject: [PATCH 24/51] fix(core): exclude never-executed synthetic results from the subagent loop guard (#9450) --- .../core/src/agents/runtime/agent-core.ts | 36 ++- .../src/agents/runtime/agent-headless.test.ts | 246 ++++++++++++++++++ .../core/src/services/loopDetectionService.ts | 35 +++ 3 files changed, 315 insertions(+), 2 deletions(-) diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 9fd7bfca0aa..170b2f71ddc 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -1170,6 +1170,7 @@ export class AgentCore { wasOutputTruncated, handledToolCallFingerprints, duplicateProviderToolCallResponseIds, + loopDetector, ); if (toolCallResult.repeatedDuplicateProviderToolCall) { terminateMode = AgentTerminateMode.LOOP_DETECTED; @@ -1599,18 +1600,27 @@ export class AgentCore { wasOutputTruncated = false, handledToolCallFingerprints = new Map(), duplicateProviderToolCallResponseIds = new Set(), + loopDetector?: LoopDetectionService, ): Promise<{ messages: Content[]; repeatedDuplicateProviderToolCall: boolean; /** Executed calls with their model-visible results, in call order. * Consumed by the loop detector for result-aware stateful-read guards - * (issue #9450). */ + * (issue #9450). Never-executed synthetic responses (duplicate-replay + * and authorization rejections) are excluded — they carry no result + * evidence and would reset the guards' streaks as "changed" results. */ results: Array<{ toolName: string; args: Record; responseParts: Part[]; }>; }> { + // callIds whose responses are synthetic and were NEVER executed: replay + // suppressions and authorization rejections (plus abort synthetics). + // Their results must not feed the result-aware loop guards (issue #9450) + // — the daemon twin excludes the same class (Session's result-recording + // filter on providerDuplicate / executionStatus 'not_started'). + const neverExecutedCallIds = new Set(); const responseByCallId = new Map< string, { @@ -1714,6 +1724,11 @@ export class AgentCore { responseParts: [functionResponsePart], durationMs: 0, }); + // Never executed: keep the synthetic error out of the loop guards' + // result evidence and unwind the request-time reservations it made + // when streamed (issue #9450). + neverExecutedCallIds.add(callId); + loopDetector?.noteSuppressedToolCallByCallId(callId); continue; } @@ -1762,6 +1777,14 @@ export class AgentCore { persistedOutputFiles: response.persistedOutputFiles, durationMs: 0, }); + // Never executed (cross-round replay of an already-handled call + // id): the fabricated duplicate response carries no result + // evidence. Exclude it from the loop guards and unwind the + // request-time reservations the replayed request made when + // streamed — the daemon twin excludes this class via its + // providerDuplicate / not_started filter (issue #9450). + neverExecutedCallIds.add(callId); + loopDetector?.noteSuppressedToolCallByCallId(callId); continue; } recordHandledToolCall( @@ -2065,6 +2088,10 @@ export class AgentCore { responseParts, durationMs: 0, }); + // Never executed (cancelled before emission): same exclusion as + // the other synthetic responses (issue #9450). + neverExecutedCallIds.add(req.callId); + loopDetector?.noteSuppressedToolCallByCallId(req.callId); } }; abortController.signal.addEventListener('abort', onAbort, { once: true }); @@ -2138,8 +2165,12 @@ export class AgentCore { timestamp: Date.now(), }); - // Pair each executed call with its model-visible (finalized) result so + // Pair each EXECUTED call with its model-visible (finalized) result so // the reasoning loop can feed the loop detector's result-aware guards. + // Never-executed synthetic responses are skipped: recording one would + // pair a fabricated error with the replayed/rejected call's request and + // reset the guards' streaks as a "changed" result, disarming every + // result-aware halt (issue #9450). const finalizedByCallId = new Map( finalizedResponses.map((response) => [response.callId, response]), ); @@ -2150,6 +2181,7 @@ export class AgentCore { }> = []; for (const fc of uniqueFunctionCalls) { const callId = callIdByFunctionCall.get(fc) ?? fc.id ?? ''; + if (neverExecutedCallIds.has(callId)) continue; const finalized = finalizedByCallId.get(callId); if (!finalized) continue; results.push({ diff --git a/packages/core/src/agents/runtime/agent-headless.test.ts b/packages/core/src/agents/runtime/agent-headless.test.ts index 780b52a166e..528ab966417 100644 --- a/packages/core/src/agents/runtime/agent-headless.test.ts +++ b/packages/core/src/agents/runtime/agent-headless.test.ts @@ -2555,6 +2555,252 @@ describe('subagent.ts', () => { expect(finishEvents[0].loopType).toBe('global_tool_call_duplicate'); }); + // Cross-round replays of an already-handled call id are suppressed and + // answered with a fabricated duplicate error that never executed. That + // synthetic response must not feed the result-aware guards: recording + // it pairs a "changed" result with the replayed request and resets the + // frozen-board streaks, disarming every result-aware halt (the daemon + // twin excludes the class via its providerDuplicate/not_started + // filter). These two tests interleave one replay into a frozen streak + // and assert the halt still fires (issue #9450). + const installFrozenTaskListTool = (taskListArgs: object) => { + const taskListInvocation = { + params: taskListArgs, + getDescription: vi.fn().mockReturnValue('List tasks'), + toolLocations: vi.fn().mockReturnValue([]), + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + // No teammate activity: every poll returns the identical board. + execute: vi.fn().mockResolvedValue({ + llmContent: '#7 [in_progress] @peer-a — task', + returnDisplay: 'Listed tasks', + }), + }; + const taskListTool = { + name: 'task_list', + displayName: 'Task List', + description: 'List tasks in the team task list', + kind: 'READ' as const, + schema: { + name: 'task_list', + description: 'Lists team tasks', + parameters: { type: Type.OBJECT, properties: {} }, + }, + build: vi.fn().mockImplementation(() => taskListInvocation), + canUpdateOutput: false, + isOutputMarkdown: false, + } as unknown as AnyDeclarativeTool; + return { taskListInvocation, taskListTool }; + }; + + it('still halts a frozen task_list streak when one poll is a cross-round replay of a handled id (issue #9450)', async () => { + 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 4 replays poll_1 (already handled in round 1): the provider + // re-emits a handled call id, exactly the misbehavior the + // suppression machinery exists for. + const [replayedPart] = normalizeModelToolCallIds( + [ + { + functionCall: { + id: 'poll_1', + name: 'task_list', + args: taskListArgs, + }, + }, + ], + new Set(['poll_1']), + new Set(), + ); + mockSendMessageStream.mockImplementation( + createMockStream([ + [{ id: 'poll_1', name: 'task_list', args: taskListArgs }], + [{ id: 'poll_2', name: 'task_list', args: taskListArgs }], + [{ id: 'poll_3', name: 'task_list', args: taskListArgs }], + [replayedPart!.functionCall!], + [{ id: 'poll_4', name: 'task_list', args: taskListArgs }], + [{ id: 'poll_5', name: 'task_list', args: taskListArgs }], + 'stop', + ]), + ); + + const { taskListInvocation, taskListTool } = + installFrozenTaskListTool(taskListArgs); + vi.mocked( + (config.getToolRegistry() as unknown as ToolRegistry).getTool, + ).mockImplementation((name: string) => + name === 'task_list' ? taskListTool : undefined, + ); + + const finishEvents: Array<{ loopType?: string }> = []; + const eventEmitter = new AgentEventEmitter(); + eventEmitter.on(AgentEventType.FINISH, (event: unknown) => { + finishEvents.push(event as { loopType?: string }); + }); + + const scope = await AgentHeadless.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + { ...defaultRunConfig, max_turns: 20 }, + toolConfig, + eventEmitter, + ); + + await scope.execute(new ContextState()); + + // The replay never executes; the halt lands on the fifth identical + // request (poll_4's stream), whose four preceding results are the + // three executed frozen boards (the synthetic replay response is + // excluded). Pre-fix the fabricated replay error counted as a + // changed result, the streak restarted, and the run sailed to GOAL. + expect(taskListInvocation.execute).toHaveBeenCalledTimes(3); + expect(mockSendMessageStream).toHaveBeenCalledTimes(5); + expect(scope.getTerminateMode()).toBe(AgentTerminateMode.LOOP_DETECTED); + expect(finishEvents).toHaveLength(1); + expect(finishEvents[0].loopType).toBe( + 'consecutive_identical_tool_calls', + ); + }); + + it('still halts interleaved frozen task_list polling when one poll is a cross-round replay (issue #9450)', async () => { + const taskListToolDef: FunctionDeclaration = { + name: 'task_list', + description: 'Lists team tasks', + parameters: { type: Type.OBJECT, properties: {} }, + }; + const fillerToolDef: FunctionDeclaration = { + name: 'tool_b', + description: 'Distinct filler work', + parameters: { type: Type.OBJECT, properties: {} }, + }; + const { config } = await createMockConfig({ + getFunctionDeclarationsFiltered: vi + .fn() + .mockReturnValue([taskListToolDef, fillerToolDef]), + getTool: vi.fn().mockReturnValue(undefined), + }); + const toolConfig: ToolConfig = { tools: ['task_list', 'tool_b'] }; + const taskListArgs = { + status: 'in_progress', + owner: 'peer-a', + blockedBy: '', + }; + + // Interleaved shape of the result-time-guard test above, with one + // cross-round replay of poll_1 between poll_3 and poll_4. + const [replayedPart] = normalizeModelToolCallIds( + [ + { + functionCall: { + id: 'poll_1', + name: 'task_list', + args: taskListArgs, + }, + }, + ], + new Set(['poll_1']), + new Set(), + ); + const turns: Array = [ + [{ id: 'poll_1', name: 'task_list', args: taskListArgs }], + [{ id: 'fill_1', name: 'tool_b', args: { step: 1 } }], + [{ id: 'poll_2', name: 'task_list', args: taskListArgs }], + [{ id: 'fill_2', name: 'tool_b', args: { step: 2 } }], + [{ id: 'poll_3', name: 'task_list', args: taskListArgs }], + [replayedPart!.functionCall!], + [{ id: 'poll_4', name: 'task_list', args: taskListArgs }], + [{ id: 'fill_4', name: 'tool_b', args: { step: 4 } }], + [{ id: 'poll_5', name: 'task_list', args: taskListArgs }], + [{ id: 'fill_5', name: 'tool_b', args: { step: 5 } }], + [{ id: 'poll_6', name: 'task_list', args: taskListArgs }], + // Extra rounds the pre-fix run consumes after its streak reset. + [{ id: 'fill_6', name: 'tool_b', args: { step: 6 } }], + [{ id: 'poll_7', name: 'task_list', args: taskListArgs }], + [{ id: 'fill_7', name: 'tool_b', args: { step: 7 } }], + [{ id: 'poll_8', name: 'task_list', args: taskListArgs }], + 'stop', + ]; + mockSendMessageStream.mockImplementation(createMockStream(turns)); + + const { taskListInvocation, taskListTool } = + installFrozenTaskListTool(taskListArgs); + const fillerInvocation = { + params: {}, + getDescription: vi.fn().mockReturnValue('Filler'), + toolLocations: vi.fn().mockReturnValue([]), + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + execute: vi.fn().mockResolvedValue({ + llmContent: 'filler done', + returnDisplay: 'Filler done', + }), + }; + const fillerTool = { + name: 'tool_b', + displayName: 'Tool B', + description: 'Distinct filler work', + kind: 'READ' as const, + schema: fillerToolDef, + build: vi.fn().mockImplementation(() => fillerInvocation), + canUpdateOutput: false, + isOutputMarkdown: false, + } as unknown as AnyDeclarativeTool; + vi.mocked( + (config.getToolRegistry() as unknown as ToolRegistry).getTool, + ).mockImplementation((name: string) => + name === 'task_list' + ? taskListTool + : name === 'tool_b' + ? fillerTool + : undefined, + ); + + const finishEvents: Array<{ loopType?: string }> = []; + const eventEmitter = new AgentEventEmitter(); + eventEmitter.on(AgentEventType.FINISH, (event: unknown) => { + finishEvents.push(event as { loopType?: string }); + }); + + const scope = await AgentHeadless.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + { ...defaultRunConfig, max_turns: 20 }, + toolConfig, + eventEmitter, + ); + + await scope.execute(new ContextState()); + + // Six executed frozen polls: the replay round never executes and + // its synthetic response is excluded, so the result-time + // global-duplicate count runs through it and halts when the sixth + // identical (call, result) pair is recorded. Pre-fix the fabricated + // replay error reset the streak and the halt slipped past poll_6. + expect(taskListInvocation.execute).toHaveBeenCalledTimes(6); + expect(mockSendMessageStream).toHaveBeenCalledTimes(11); + expect(scope.getTerminateMode()).toBe(AgentTerminateMode.LOOP_DETECTED); + expect(finishEvents).toHaveLength(1); + 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 diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index e2d18e4ae55..ae6ba7d0ce8 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -635,6 +635,41 @@ export class LoopDetectionService { ); } + /** + * Notes that a call which streamed through the guards was suppressed + * WITHOUT executing (a cross-round replay of an already-handled provider + * call id, or an authorization rejection), so its synthetic response carries + * no result evidence and must not be recorded via recordToolResult / + * recordToolResultByCallId. The request-time reservations the guards made + * when the call streamed in must unwind: the callId pairing is dropped (no + * real result will land for it), the alternating-pattern carve-out's + * in-flight reservation is released (otherwise the carve-out over-subtracts + * in-flight counts and judges the window on too little evidence), and the + * key is marked as having produced activity since the last Finished + * boundary — the model DID re-issue the poll; suppression is the runtime's + * machinery, not abandonment, so the decay must not wipe a live + * frozen-board streak on the next boundary (the daemon twin skips decay + * for batches that execute nothing instead — recordDaemonToolCalls in the + * ACP Session). Without this, a replay-suppressed round is + * indistinguishable from abandonment and disarms the result-aware halts. + * Unknown callIds (never streamed through the guards) are ignored. + */ + noteSuppressedToolCallByCallId(callId: string): void { + const request = this.requestByCallId.get(callId); + if (!request) return; + this.requestByCallId.delete(callId); + if (!this.isStatefulReadTool(request.name)) return; + const key = this.getToolCallKey(request); + // Floored at zero because the heuristic tier (the only writer besides + // this unwind) may be off under skipLoopDetection; a stale decrement is + // inert then — nothing reads the count until a Retry/reset clears it. + const inFlight = this.statefulInFlight.get(key) ?? 0; + if (inFlight > 0) { + this.statefulInFlight.set(key, inFlight - 1); + } + this.statefulResultKeysSinceLastFinished.add(key); + } + private isStatefulReadTool(toolName: string): boolean { return isStatefulReadTool(toolName); } From 0f8abb8494d6c531a7dfdfdd16fc3d5eb104696c Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sun, 23 Aug 2026 19:17:22 +0800 Subject: [PATCH 25/51] fix(core): exclude synthetic duplicate responses from the main-session loop guard (#9450) --- packages/core/src/core/client.test.ts | 119 ++++++++++++++++++++++++++ packages/core/src/core/client.ts | 14 +++ packages/core/src/core/turn.ts | 27 +++++- 3 files changed, 159 insertions(+), 1 deletion(-) diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index dea68cd6ba8..7200e921ae1 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -47,6 +47,7 @@ import { UnauthorizedError } from '../utils/errors.js'; import { retryWithBackoff } from '../utils/retry.js'; import { CompressionStatus, + createDuplicateProviderToolCallResponse, GeminiEventType, Turn, type ServerGeminiStreamEvent, @@ -8044,6 +8045,124 @@ hello ).toBe('consecutive_identical_tool_calls'); }); + // Variant of runTaskListPollTurns where one mid-streak poll arrives as + // a cross-round replay of an already-handled call id: useGeminiStream + // suppresses the execution and submits the fabricated duplicate error + // back as a ToolResult message (executionStatus 'not_started' — never + // executed). Streams also yield Finished events so the round-trip + // boundary decay runs between rounds, exactly as in production. The + // synthetic response must be excluded from the result-aware recording — + // the daemon twin filters the same class (issue #9450 requirement #6). + async function runReplayedHandledIdPollTurns( + board: (round: number) => string, + maxRounds = 9, + ) { + const promptId = 'prompt-task-list-replay-poll'; + const taskListArgs = { status: 'in_progress', owner: 'peer-a' }; + const replayRound = 3; // streams a replay of the handled id 'tl-1' + const allEvents: Array<{ type: string; value?: unknown }> = []; + const request = (callId: string, name: string, args: object) => ({ + type: GeminiEventType.ToolCallRequest, + value: { + callId, + name, + args, + isClientInitiated: false, + prompt_id: promptId, + }, + }); + for (let round = 0; round <= maxRounds; round++) { + const taskListCallId = round === replayRound ? 'tl-1' : `tl-${round}`; + mockTurnRunFn.mockReturnValueOnce( + (async function* () { + yield request(taskListCallId, 'task_list', taskListArgs); + yield request(`other-${round}`, 'tool_b', { step: round }); + yield { type: GeminiEventType.Finished }; + })(), + ); + let contents: object[]; + if (round === 0) { + contents = [{ text: 'poll the board' }]; + } else if (round - 1 === replayRound) { + // The replayed call never executed: its ToolResult is the + // fabricated duplicate error useGeminiStream submits back. + const synthetic = createDuplicateProviderToolCallResponse({ + callId: 'tl-1', + name: 'task_list', + args: taskListArgs, + } as never); + contents = [ + synthetic.responseParts[0], + { + functionResponse: { + id: `other-${round - 1}`, + name: 'tool_b', + response: { output: `step ${round - 1}` }, + }, + }, + ]; + } else { + contents = [ + { + functionResponse: { + id: `tl-${round - 1}`, + name: 'task_list', + response: { output: board(round - 1) }, + }, + }, + { + functionResponse: { + id: `other-${round - 1}`, + name: 'tool_b', + response: { output: `step ${round - 1}` }, + }, + }, + ]; + } + const events = await fromAsync( + client.sendMessageStream( + contents as never, + new AbortController().signal, + promptId, + { + type: + round === 0 + ? SendMessageType.UserQuery + : SendMessageType.ToolResult, + }, + ), + ); + allEvents.push(...(events as Array<{ type: string; value?: unknown }>)); + if ( + allEvents.some((e) => e.type === GeminiEventType.LoopDetected) || + !events.some((e) => e.type === GeminiEventType.ToolCallRequest) + ) { + return allEvents; + } + } + return allEvents; + } + + it('still halts a frozen board when one poll arrives as a replayed handled id (#9450)', async () => { + const events = await runReplayedHandledIdPollTurns(() => 'frozen board'); + const loopEvent = events.find( + (e) => e.type === GeminiEventType.LoopDetected, + ); + // The replay's fabricated error never executed: it must not pair with + // the replayed request as a "changed" result. With it excluded, the + // six frozen boards record consecutively across the replay round and + // trip the result-time global-duplicate count; with it recorded (the + // pre-fix behavior) the streak restarts and no halt lands in budget. + expect(loopEvent).toBeDefined(); + expect( + (loopEvent?.value as { loopType?: string } | undefined)?.loopType, + ).toBe('global_tool_call_duplicate'); + // The halt lands at the sixth recorded frozen board — the seventh + // ToolResult turn — before that turn's model stream runs: 7 streams + // (rounds 0-6) out of the 10 the harness would otherwise consume. + expect(mockTurnRunFn).toHaveBeenCalledTimes(7); + }); + it('feeds the loop guards one event per call id per attempt, re-feeding after retries (#9450)', async () => { const loopDetector = client['loopDetector']; const alwaysOnSpy = vi diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 678ba06976c..9374be96dcb 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -70,6 +70,7 @@ import { import { CompressionStatus, GeminiEventType, + isDuplicateProviderToolCallResponse, Turn, type ChatCompressionInfo, type ServerGeminiStreamEvent, @@ -3370,6 +3371,19 @@ export class GeminiClient { } const functionResponseId = (part as Part).functionResponse?.id; if (!functionResponseId) continue; + // Synthetic duplicate responses (cross-round replays of + // already-handled call ids, suppressed by useGeminiStream) never + // executed, so they carry no result evidence. Recording one would + // pair the fabricated error with the replayed request and reset + // the guards' streaks as a "changed" result, disarming every + // result-aware halt — the daemon twin excludes this class via its + // providerDuplicate / not_started filter (issue #9450). + if (isDuplicateProviderToolCallResponse(part as Part)) { + this.loopDetector.noteSuppressedToolCallByCallId( + functionResponseId, + ); + continue; + } if ( this.loopDetector.recordToolResultByCallId(functionResponseId, [ part as Part, diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 1c7c593854d..2aba7d0a95f 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -220,15 +220,40 @@ function buildApiErrorReportContext(chat: GeminiChat, req: PartListUnion) { }; } +// Stable prefix of the synthetic duplicate response's error text (see +// duplicateProviderToolCallMessage). Shared by the producer and the +// isDuplicateProviderToolCallResponse discriminator so the two cannot +// drift apart. +const DUPLICATE_PROVIDER_TOOL_CALL_MESSAGE_PREFIX = + 'Duplicate provider tool call id "'; + function duplicateProviderToolCallMessage(providerCallId: string): string { return ( - `Duplicate provider tool call id "${providerCallId}" was already handled. ` + + `${DUPLICATE_PROVIDER_TOOL_CALL_MESSAGE_PREFIX}${providerCallId}" was already handled. ` + `The duplicate tool call was ignored and not executed again. If you ` + `intended to run this tool again, re-issue the call with a new unique ` + `tool-call id (or explicitly different arguments).` ); } +/** + * Whether a response part is the synthetic error fabricated for a + * suppressed duplicate provider tool call (see + * createDuplicateProviderToolCallResponse). Such a part never executed, so + * it carries no result evidence for the result-aware loop guards and must + * be excluded from their recording feeds (issue #9450) — the daemon twin + * filters the same class via executionStatus/providerDuplicate metadata, + * which main-session parts do not carry, leaving the message text as the + * only discriminator here. + */ +export function isDuplicateProviderToolCallResponse(part: Part): boolean { + const error = part.functionResponse?.response?.['error']; + return ( + typeof error === 'string' && + error.startsWith(DUPLICATE_PROVIDER_TOOL_CALL_MESSAGE_PREFIX) + ); +} + export function createDuplicateProviderToolCallResponse( request: ToolCallRequestInfo, ): ToolCallResponseInfo { From a08fac336bbd2b9fb047006413c934e7a0622a0e Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sun, 23 Aug 2026 19:17:50 +0800 Subject: [PATCH 26/51] fix(cli): skip daemon abandonment decay for batches that execute nothing (#9450) --- .../acp-integration/session/Session.test.ts | 90 +++++++++++++++++++ .../src/acp-integration/session/Session.ts | 17 ++++ 2 files changed, 107 insertions(+) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 70261624e4c..86a04a205ab 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -10976,6 +10976,96 @@ describe('Session', () => { expect(loopState.loopDetected).toBe(false); expect(loopState.totalToolCalls).toBeGreaterThan(20); }); + + it('still halts a frozen daemon poller when replay-only rounds are interleaved in the streak (issue #9450)', async () => { + // CLI defaults: skipLoopDetection=true, adaptive soft cap — the + // cap's stateful stuck signal is the ONLY live halt path. A + // replay-suppressed round (every call pushed as a duplicate batch) + // executes nothing and records zero results BY DESIGN; the + // batch-boundary decay must not mistake it for abandonment and + // wipe the live frozen-board streak. Pre-fix, replays interleaved + // at <=5-poll intervals kept statefulMaxResultRepeat below the + // stuck threshold indefinitely while the replay batches added 0 + // to totalToolCalls — the detected stuck loop ran to the hard + // backstop instead of halting just past the soft cap. + mockConfig.getApprovalMode = vi + .fn() + .mockReturnValue(ApprovalMode.YOLO); + mockConfig.getMaxToolCallsPerTurn = vi.fn().mockReturnValue(20); + mockConfig.isMaxToolCallsPerTurnExplicit = vi + .fn() + .mockReturnValue(false); + mockConfig.getSkipLoopDetection = vi.fn().mockReturnValue(true); + const execute = installTaskListTool(() => 'frozen board'); + // Each replay round replays a DISTINCT already-handled id with the + // same (name, args) fingerprint, so its batch is suppressed whole + // without tripping the repeated-duplicate breaker (which fires on + // a second replay of the SAME id — a different, also-correct halt). + const fingerprint = core.getToolCallFingerprint( + 'task_list', + TASK_LIST_ARGS, + ); + vi.mocked(mockChat.getHistoryToolCallFingerprints).mockReturnValue( + new Map( + Array.from({ length: 15 }, (_, index) => [ + `replayed_task_list_${index}`, + fingerprint, + ]), + ), + ); + const loopState = freshLoopState(); + + let replayOrdinal = 0; + const runReplayRound = (round: number) => + ( + session as unknown as { + runToolCalls: ( + abortSignal: AbortSignal, + promptId: string, + calls: unknown[], + loopState: ReturnType, + ) => Promise<{ loopDetected?: boolean; parts: Part[] }>; + } + ).runToolCalls( + new AbortController().signal, + `prompt-replay-${round}`, + [ + { + id: `replayed_task_list_${replayOrdinal++}`, + name: 'task_list', + args: TASK_LIST_ARGS, + }, + ], + loopState, + ); + + let fired = false; + for (let round = 0; round < 60 && !fired; round++) { + if (round > 0 && round % 5 === 4) { + // Every fifth round is a replay-only round: zero executable + // calls, zero recorded results (four executed polls between + // replays, matching the finding's <=5-poll interleave). + const replayResult = await runReplayRound(round); + expect(replayResult.loopDetected ?? false).toBe(false); + expect( + (replayResult.parts[0]?.functionResponse?.response?.[ + 'error' + ] as string) ?? '', + ).toContain('Duplicate provider tool call id'); + continue; + } + const result = await runTaskListPoll(loopState, round); + fired = result.loopDetected ?? false; + } + + // The streak survives the replay rounds and arms the cap's stuck + // signal: the halt lands at totalToolCalls 21 (soft cap 20 + 1), + // before the 21st poll executes. + expect(fired).toBe(true); + expect(loopState.loopType).toBe(core.LoopType.TURN_TOOL_CALL_CAP); + expect(loopState.totalToolCalls).toBe(21); + expect(execute).toHaveBeenCalledTimes(20); + }); }); }); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index a966567670a..4c7af380b61 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -897,6 +897,23 @@ function recordDaemonToolCalls( ): boolean { if (!loopState || loopState.loopDetected) return loopState?.loopDetected ?? false; + // A batch that executes nothing — every call suppressed as a replay of an + // already-handled provider call id (pushDuplicateBatch) — records zero + // results BY DESIGN (the result-recording filter excludes + // providerDuplicate / not_started records). Running the abandonment decay + // for it would mistake that for the model moving on: the next batch's + // decay would find the live frozen-board key absent and wipe its streak, + // letting replays interleaved at ≤5-poll intervals keep + // statefulMaxResultRepeat below the stuck threshold indefinitely — + // disarming the cap's stuck signal while the replay batches also add 0 to + // totalToolCalls and push the hard backstop away (issue #9450). Skip it: + // the still-populated statefulResultKeysSinceLastBatch set carries the + // last EXECUTED round's keys through the empty batch, so abandonment + // decay still runs (and clears) on the next non-empty batch. The cap + // check cannot newly fire on an empty batch: totalToolCalls is unchanged + // and skipping the decay can only keep the repeat peak higher, which a + // prior non-halting check at the same total already tolerated. + if (calls.length === 0) return false; // Batch boundary: the previous batch's results have all been recorded by // now (results are recorded during execution, before the next batch is // streamed), so this is the safe point to decay stateful keys absent from From 0ca1aa38f14b13dbfd2daf197a155855f2699ef9 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sun, 23 Aug 2026 22:19:58 +0800 Subject: [PATCH 27/51] fix(core): canonicalize loop-guard fingerprints across the batch-budget fit (#9450) --- .../src/agents/runtime/agent-core.test.ts | 308 ++++++++++++++++++ .../src/services/loopDetectionService.test.ts | 46 +++ .../core/src/services/loopDetectionService.ts | 16 +- 3 files changed, 369 insertions(+), 1 deletion(-) diff --git a/packages/core/src/agents/runtime/agent-core.test.ts b/packages/core/src/agents/runtime/agent-core.test.ts index 0c810ff0ebc..2eb7383dee3 100644 --- a/packages/core/src/agents/runtime/agent-core.test.ts +++ b/packages/core/src/agents/runtime/agent-core.test.ts @@ -26,6 +26,20 @@ import { import { subagentNameContext } from '../../utils/subagentNameContext.js'; import { runInForkContext } from '../../tools/agent/fork-subagent.js'; import { ToolNames } from '../../tools/tool-names.js'; +import { + LoopDetectionService, + fingerprintToolResult, +} from '../../services/loopDetectionService.js'; +import { GeminiEventType } from '../../core/turn.js'; +import type { ServerGeminiStreamEvent } from '../../core/turn.js'; +import { MockTool } from '../../test-utils/mock-tool.js'; +import { BATCH_BUDGET_FIT_PREFIX } from '../../utils/tool-response-finalizer.js'; +import { + ApprovalMode, + DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, +} from '../../index.js'; +import type { ToolRegistry } from '../../tools/tool-registry.js'; import { getAgentName, getTeammateContext, @@ -994,3 +1008,297 @@ describe('extractParentToolNames', () => { expect(extractParentToolNames(configWithTools([{}]))).toEqual([]); }); }); + +describe('AgentCore.processFunctionCalls loop-detector result feed', () => { + // The loop detector's result-aware guards must see representation-stable + // parts (issue #9450): a frozen board whose batch oscillates around the + // toolOutputBatchBudget boundary would otherwise alternate between a raw + // JSON-verbatim fingerprint (under-budget batch) and a digest-reduced + // batch-budget-fit fingerprint (over-budget batch) — two representations + // of identical content that never collide, so every poll counts as + // "changed" and no result-aware halt ever fires. + + const BOARD = 'task row for a frozen board\n'.repeat(80); // ~2.2KB + const SIBLING_OUTPUT = 'sibling payload line\n'.repeat(100); // ~2.1KB + const TASK_LIST_ARGS = { board: 'shared' }; + // BOARD fits the budget solo; BOARD + SIBLING_OUTPUT exceeds it, so + // alternating solo/co-batched rounds oscillate across the fit boundary. + // Both stay far below the per-result truncation threshold, so executed + // parts carry the raw board text (no scheduler persistence). + const BATCH_BUDGET = 3000; + + const fnDecls: FunctionDeclaration[] = [ + { name: 'task_list', description: 'list tasks' } as FunctionDeclaration, + { name: 'big_sibling', description: 'big sibling' } as FunctionDeclaration, + ]; + + function buildAgentForExecutedTools(tmpDir: string): { + core: AgentCore; + config: Config; + } { + const boardTool = new MockTool({ + name: 'task_list', + execute: async () => ({ + llmContent: BOARD, + returnDisplay: 'board', + }), + }); + const siblingTool = new MockTool({ + name: 'big_sibling', + execute: async () => ({ + llmContent: SIBLING_OUTPUT, + returnDisplay: 'sibling', + }), + }); + const byName = new Map([ + [boardTool.name, boardTool], + [siblingTool.name, siblingTool], + ]); + const registry = { + getTool: (name: string) => byName.get(name), + ensureTool: async (name: string) => byName.get(name), + getAllToolNames: () => [...byName.keys()], + getFunctionDeclarations: () => [], + warmAll: async () => undefined, + } as unknown as ToolRegistry; + const config = { + getDebugLogger: vi.fn().mockReturnValue({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + getSessionId: () => 'session-loop-feed', + getUsageStatisticsEnabled: () => false, + getTelemetryEnabled: () => false, + getDebugMode: () => false, + getApprovalMode: () => ApprovalMode.DEFAULT, + getPermissionsAllow: () => [], + getPermissionsDeny: () => undefined, + getContentGeneratorConfig: () => ({ + model: 'test-model', + authType: 'gemini', + }), + getShellExecutionConfig: () => ({ + terminalWidth: 90, + terminalHeight: 30, + }), + storage: { getProjectTempDir: () => tmpDir }, + getTruncateToolOutputThreshold: () => + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, + getToolRegistry: () => registry, + getUseModelRouter: () => false, + getGeminiClient: () => null, + getChatRecordingService: () => undefined, + getMessageBus: vi.fn().mockReturnValue(undefined), + getDisableAllHooks: vi.fn().mockReturnValue(true), + getDisabledTools: () => new Set(), + getSkillManager: () => undefined, + getConditionalRulesRegistry: () => undefined, + getCwd: () => tmpDir, + getTargetDir: () => tmpDir, + getSdkMode: () => false, + getIdeMode: () => false, + getExperimentalZedIntegration: () => false, + getInputFormat: () => undefined, + getPlanFilePath: () => path.join(tmpDir, 'plan.md'), + getToolOutputBatchBudget: () => BATCH_BUDGET, + getToolResultBytesWritten: () => 500 * 1024 * 1024, + getMaxSubagentDepth: () => 5, + getSkipLoopDetection: () => false, + getMaxToolCallsPerTurn: () => 100, + isMaxToolCallsPerTurnExplicit: () => false, + } as unknown as Config; + const core = new AgentCore( + 'loop-feed-subagent', + config, + { systemPrompt: '' }, + { model: 'test-model' }, + { max_turns: 1 }, + { tools: ['*'] }, + ); + return { core, config }; + } + + const taskListCall = (id: string) => ({ + name: 'task_list', + args: TASK_LIST_ARGS, + id, + }); + + const toolCallRequestEvent = ( + name: string, + args: Record, + callId: string, + ): ServerGeminiStreamEvent => ({ + type: GeminiEventType.ToolCallRequest, + value: { + name, + args, + callId, + isClientInitiated: false, + prompt_id: 'prompt-loop-feed', + }, + }); + + it('feeds representation-stable parts to the guards across the budget boundary', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-loop-feed-')); + try { + const { core } = buildAgentForExecutedTools(tmpDir); + + const solo = await runWithAgentContext('loop-feed', () => + core.runInAgentFrames(() => + core.processFunctionCalls( + [taskListCall('tl-solo')], + new AbortController(), + 'prompt-loop-feed', + 1, + fnDecls, + ), + ), + ); + const coBatched = await runWithAgentContext('loop-feed', () => + core.runInAgentFrames(() => + core.processFunctionCalls( + [ + taskListCall('tl-co'), + { name: 'big_sibling', args: { step: 1 }, id: 'sib-co' }, + ], + new AbortController(), + 'prompt-loop-feed', + 2, + fnDecls, + ), + ), + ); + + const visibleOutput = ( + messages: unknown, + callIdSuffix: string, + ): string => { + const parts = ((messages as Array<{ parts?: unknown[] }>)[0]?.parts ?? + []) as Array<{ + functionResponse?: { + id?: string; + response?: { output?: string }; + }; + }>; + const part = parts.find((p) => + p.functionResponse?.id?.endsWith(callIdSuffix), + ); + return part?.functionResponse?.response?.output ?? ''; + }; + // Sanity: the budget actually ran — the over-budget co-batch fitted + // the model-visible board result while the solo batch kept it raw. + expect(visibleOutput(coBatched.messages, 'tl-co')).toContain( + BATCH_BUDGET_FIT_PREFIX, + ); + expect(visibleOutput(solo.messages, 'tl-solo')).not.toContain( + BATCH_BUDGET_FIT_PREFIX, + ); + + // The guard feed must not depend on batch composition: identical + // content fingerprints identically whether its batch fitted or not. + const soloBoard = solo.results.find((r) => r.toolName === 'task_list'); + const coBoard = coBatched.results.find((r) => r.toolName === 'task_list'); + expect(soloBoard).toBeDefined(); + expect(coBoard).toBeDefined(); + expect(fingerprintToolResult(soloBoard!.responseParts)).toBe( + fingerprintToolResult(coBoard!.responseParts), + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('halts a frozen board polled across the fit boundary (oscillating batches)', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-loop-feed-')); + try { + const { core, config } = buildAgentForExecutedTools(tmpDir); + const loopDetector = new LoopDetectionService(config); + loopDetector.reset('session-loop-feed#loop-feed-subagent'); + const handledToolCallFingerprints = new Map(); + const duplicateProviderToolCallResponseIds = new Set(); + + let halted = false; + let rounds = 0; + for (let round = 0; round < 10 && !halted; round++) { + rounds++; + const coBatched = round % 2 === 1; + // Request-time feed, exactly as the reasoning loop streams events. + const events = [ + toolCallRequestEvent('task_list', TASK_LIST_ARGS, `tl-${round}`), + ]; + if (coBatched) { + events.push( + toolCallRequestEvent( + 'big_sibling', + { step: round }, + `sib-${round}`, + ), + ); + } + for (const event of events) { + if ( + loopDetector.checkAlwaysOnSafeties(event) || + loopDetector.addAndCheckHeuristicLoops(event) + ) { + halted = true; + break; + } + } + if (halted) break; + + const calls: Array<{ + name: string; + args: Record; + id: string; + }> = [taskListCall(`tl-${round}`)]; + if (coBatched) { + calls.push({ + name: 'big_sibling', + args: { step: round }, + id: `sib-${round}`, + }); + } + const result = await runWithAgentContext('loop-feed', () => + core.runInAgentFrames(() => + core.processFunctionCalls( + calls, + new AbortController(), + 'prompt-loop-feed', + round + 1, + fnDecls, + undefined, + false, + handledToolCallFingerprints, + duplicateProviderToolCallResponseIds, + loopDetector, + ), + ), + ); + // Result-time feed, exactly as the reasoning loop records results. + for (const toolResult of result.results) { + if ( + loopDetector.recordToolResult( + { name: toolResult.toolName, args: toolResult.args }, + toolResult.responseParts, + ) + ) { + halted = true; + break; + } + } + } + + // The result-aware global-duplicate guard fires on the 6th identical + // frozen result. Pre-fix, the raw/fitted representation oscillation + // judged every poll "changed" and no guard fired within these rounds. + expect(halted).toBe(true); + expect(rounds).toBeLessThanOrEqual(6); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index d2ae49e06b5..a7e9ddad765 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -27,6 +27,7 @@ import { } from '../utils/truncation.js'; import { DEFAULT_MAX_TOOL_CALLS_PER_TURN, + fingerprintToolResult, LoopDetectionService, } from './loopDetectionService.js'; @@ -3420,6 +3421,51 @@ describe('LoopDetectionService', () => { expect(fired).toBe(false); expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); + + it('collides the raw and batch-budget-fitted fingerprints of identical content', () => { + // A batch oscillating around the budget boundary alternates between + // the raw output (under-budget) and the digest-reduced fit header + // (over-budget). The two representations of identical content must + // fingerprint identically or every poll counts as "changed" and + // the result-aware guards never fire (issue #9450). + expect(fingerprintToolResult(taskListResult(FROZEN_BOARD, 'raw'))).toBe( + fingerprintToolResult(batchBudgetResult('fitted', FROZEN_BOARD)), + ); + // A changed board stays distinct in both representations. + expect( + fingerprintToolResult(taskListResult(FROZEN_BOARD, 'raw')), + ).not.toBe( + fingerprintToolResult( + batchBudgetResult('fitted', `${FROZEN_BOARD}new row`), + ), + ); + }); + + it('halts a frozen board whose representation alternates raw/fitted across the budget boundary', () => { + // Witness along the finding's shape: identical board content, but + // the batch fits under budget on solo polls (raw) and over budget + // on co-batched polls (fitted). Pre-fix the alternating + // fingerprints judged every poll "changed" — unchangedStreak and + // consecutiveIdenticalResults reset every round and no guard + // fired. With the representations colliding, the always-on + // consecutive guard halts at the 5th identical request with all + // prior results observed unchanged. + let fired = false; + for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD + 1 && !fired; i++) { + fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); + if (fired) break; + service.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + i % 2 === 0 + ? taskListResult(FROZEN_BOARD, `poll_${i}`) + : batchBudgetResult(`poll_${i}`, FROZEN_BOARD), + ); + } + expect(fired).toBe(true); + expect(service.getLastLoopType()).toBe( + LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, + ); + }); }); }); }); diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index ae6ba7d0ce8..b9ef168a924 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -234,13 +234,27 @@ function extractAnchoredStubDigest(value: string): string | null { * STUB_PRODUCER_PREFIXES) and the digest must be line-anchored with a full * 64-hex payload, so arbitrary result text that merely contains the label * is fingerprinted verbatim instead of being collapsed to a quoted window. + * + * Non-stub text is canonicalized to its own sha256 digest marker instead of + * being carried verbatim: the batch-budget fit rewrites an over-budget + * batch's results into fit headers embedding the sha256 of the full pre-fit + * text (fitText), while an under-budget batch keeps the raw text. Those two + * representations of identical content must collide or a frozen board whose + * batch oscillates around the budget boundary would count every poll as + * "changed" and fail open past every result-aware guard (issue #9450). + * Hashing the full text preserves every distinction a changed board makes + * (including inside the band a fit would drop), and the `` + * sentinel keeps a canonicalized result from ever colliding with a literal + * small output that happens to match the raw text of another shape. */ function stripPersistenceEnvelope(value: string): string { const isProducerStub = STUB_PRODUCER_PREFIXES.some((prefix) => value.startsWith(prefix), ); if (!isProducerStub) { - return value; + return `sha256:${createHash('sha256') + .update(value) + .digest('hex')}`; } const digest = extractAnchoredStubDigest(value); From da1d1c3f3a4f3f71c2105f4f68578af96cce582b Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Mon, 24 Aug 2026 05:23:17 +0800 Subject: [PATCH 28/51] fix(core): unwind suppressed replays and skip decay for requested keys (#9450) --- .../src/services/loopDetectionService.test.ts | 176 ++++++++++++++++++ .../core/src/services/loopDetectionService.ts | 87 +++++++-- 2 files changed, 250 insertions(+), 13 deletions(-) diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index a7e9ddad765..5d7b132a3c1 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -3052,6 +3052,182 @@ describe('LoopDetectionService', () => { expect(capService.getLastLoopType()).toBeNull(); }); + it('does not halt a changing-board poller when a suppressed replay lands mid-streak', () => { + // The provider re-emitted an already-handled call id mid-streak: the + // replay streamed through the guards (incrementing the request-side + // counts) and was suppressed without executing. Pre-fix the + // suppressed occurrence kept its increment, so at the 5th identical + // request expectedResults = 4 while resultsObserved = 3 forever — + // the exoneration branch unreachable, and the turn halted + // CONSECUTIVE_IDENTICAL_TOOL_CALLS despite every executed result + // having changed (the #9450 false positive re-entering via provider + // re-emission). + let fired = false; + for (let i = 0; i < 8 && !fired; i++) { + const callId = `call-${i}`; + fired = service.checkAlwaysOnSafeties(taskListEvent(callId)); + if (fired) break; + if (i === 2) { + // Mid-streak replay of an already-handled call id: it streams in + // (the guards count it), then the runtime suppresses it — no + // result will ever land for it. + fired = service.checkAlwaysOnSafeties(taskListEvent('call-1')); + if (fired) break; + service.noteSuppressedToolCallByCallId('call-1'); + } + fired = service.recordToolResultByCallId( + callId, + taskListResult(`board state v${i}`, callId), + ); + } + expect(fired).toBe(false); + expect(service.getLastLoopType()).toBeNull(); + }); + + it('does not halt an ABAB poller when a suppressed replay pads the window', () => { + // A replay of the stateful participant mid-window pads the window to + // a clean ABABAB shape while producing no result. Pre-fix the + // carve-out saw 3 window occurrences against only 2 recorded + // results, skipped the exoneration, and halted on arguments alone. + const heuristicService = new LoopDetectionService( + makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), + ); + heuristicService.reset('alternating-replay'); + + const toolB = () => + createToolCallRequestEvent('tool_b', { step: 'work' }); + const recordBoard = (callId: string, board: string) => + heuristicService.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult(board, callId), + ); + + expect(heuristicService.addAndCheck(taskListEvent('a-0'))).toBe(false); + expect(recordBoard('a-0', 'board state v0')).toBe(false); + expect(heuristicService.addAndCheck(toolB())).toBe(false); + expect(heuristicService.addAndCheck(taskListEvent('a-1'))).toBe(false); + expect(recordBoard('a-1', 'board state v1')).toBe(false); + expect(heuristicService.addAndCheck(toolB())).toBe(false); + // The replay streams in and is suppressed without executing. + expect(heuristicService.addAndCheck(taskListEvent('a-0'))).toBe(false); + heuristicService.noteSuppressedToolCallByCallId('a-0'); + // Pre-fix this 6th window entry halted + // ALTERNATING_TOOL_CALL_PATTERN on args alone. + expect(heuristicService.addAndCheck(toolB())).toBe(false); + expect(heuristicService.getLastLoopType()).toBeNull(); + }); + + it('halts an interleaved frozen poller whose gap rounds previously decayed the streak', () => { + // Production ordering: requests → Finished → results. A frozen board + // polled every OTHER round between varied work: pre-fix the poll + // round's Finished boundary found the key absent from the result set + // (the gap round's boundary had consumed the previous result's mark), + // decayed the streak back to zero, and the cap's stuck signal never + // armed — the turn ran to the hard backstop instead of halting just + // past the soft cap. The requested-keys skip keeps the streak alive + // across gap rounds. + const capService = new LoopDetectionService(makeConfig(20)); + capService.reset('cap-interleaved-frozen'); + const finishedEvent = { + type: GeminiEventType.Finished, + value: { reason: 'STOP' }, + } as unknown as ServerGeminiStreamEvent; + + let fired = false; + let totalCalls = 0; + for (let round = 0; round < 40 && !fired; round++) { + fired = capService.checkAlwaysOnSafeties(taskListEvent(`tl-${round}`)); + totalCalls++; + if (fired) break; + capService.checkAlwaysOnSafeties(finishedEvent); + fired = capService.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult('frozen board'), + ); + if (fired) break; + // Gap round: other work, no task_list request or result. + fired = capService.checkAlwaysOnSafeties( + createToolCallRequestEvent('tool_b', { step: round }), + ); + totalCalls++; + if (fired) break; + capService.checkAlwaysOnSafeties(finishedEvent); + } + expect(fired).toBe(true); + expect(capService.getLastLoopType()).toBe(LoopType.TURN_TOOL_CALL_CAP); + // Halts just past the soft cap (20) once the stuck signal arms, far + // below the hard backstop (20 * 10). + expect(totalCalls).toBeLessThanOrEqual(24); + }); + + it('does not halt a changing-board poller when a gap round lands mid-streak', () => { + // A text-only gap round mid-streak: pre-fix the next poll round's + // Finished boundary decayed resultsObserved/unchangedStreak to zero + // while toolCallRepetitionCount stood, making the carve-out gate + // resultsObserved >= count - 1 permanently unsatisfiable — the 5th + // identical request halted on arguments alone despite every executed + // result having changed (fail-closed arm of the decay gap). + const finishedEvent = { + type: GeminiEventType.Finished, + value: { reason: 'STOP' }, + } as unknown as ServerGeminiStreamEvent; + + expect(service.checkAlwaysOnSafeties(taskListEvent('call-0'))).toBe( + false, + ); + service.checkAlwaysOnSafeties(finishedEvent); + expect( + service.recordToolResultByCallId( + 'call-0', + taskListResult('board v0', 'call-0'), + ), + ).toBe(false); + + expect(service.checkAlwaysOnSafeties(taskListEvent('call-1'))).toBe( + false, + ); + service.checkAlwaysOnSafeties(finishedEvent); + expect( + service.recordToolResultByCallId( + 'call-1', + taskListResult('board v1', 'call-1'), + ), + ).toBe(false); + + // Text-only gap round: no requests, no results. + service.checkAlwaysOnSafeties(finishedEvent); + + expect(service.checkAlwaysOnSafeties(taskListEvent('call-2'))).toBe( + false, + ); + // Pre-fix this boundary wiped resultsObserved/unchangedStreak. + service.checkAlwaysOnSafeties(finishedEvent); + expect( + service.recordToolResultByCallId( + 'call-2', + taskListResult('board v2', 'call-2'), + ), + ).toBe(false); + + expect(service.checkAlwaysOnSafeties(taskListEvent('call-3'))).toBe( + false, + ); + service.checkAlwaysOnSafeties(finishedEvent); + expect( + service.recordToolResultByCallId( + 'call-3', + taskListResult('board v3', 'call-3'), + ), + ).toBe(false); + + // 5th identical request: all four prior results changed, so the + // exoneration branch must restart the streak instead of halting. + expect(service.checkAlwaysOnSafeties(taskListEvent('call-4'))).toBe( + false, + ); + expect(service.getLastLoopType()).toBeNull(); + }); + it('does not collapse the fingerprint when board content merely quotes the digest label', () => { // task_list embeds peer-authored text verbatim, and agents quote stub // text (including the `Full output sha256: ` line this PR adds to diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index b9ef168a924..73ad64f0537 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -441,15 +441,31 @@ export class LoopDetectionService { >(); // Stateful keys that recorded a result since the last Finished round-trip - // boundary. At each Finished, keys NOT in this set produced no result for - // a whole round-trip: the model moved on to other work, so their streak - // evidence is abandoned and must stop feeding the cap's stuck signal — - // otherwise a key abandoned after a frozen phase keeps its peak for the - // whole prompt and the adaptive cap halts a productive turn just past the - // soft cap (issue #9450). Keys that keep polling appear in every round's - // results and are never decayed. + // boundary. At each Finished, keys NOT in this set AND not in the + // requested set below produced no result AND no request for a whole + // round-trip: the model moved on to other work, so their streak evidence + // is abandoned and must stop feeding the cap's stuck signal — otherwise a + // key abandoned after a frozen phase keeps its peak for the whole prompt + // and the adaptive cap halts a productive turn just past the soft cap + // (issue #9450). Keys that keep polling appear in every round's results + // (or requests) and are never decayed. private statefulResultKeysSinceLastFinished = new Set(); + // Stateful keys that streamed a request since the last Finished boundary. + // Decay must skip these too: production records results AFTER the Finished + // of the stream that emitted their calls, so at the boundary of a poll + // round the poll's own result has not landed yet, and a gap round (a + // text-only turn, an interleaved other tool) consumes the previous + // result's mark at ITS boundary — keying decay on result marks alone + // wipes a still-polled key's streak at the next poll's boundary. That + // disarmed the cap's stuck signal for a frozen board polled every other + // round (fail open) and reset resultsObserved mid-streak while + // toolCallRepetitionCount stood, making the consecutive guard's + // exoneration gate permanently unsatisfiable (fail closed) (issue #9450). + // Maintained in the always-on path (checkAlwaysOnSafeties) so it works + // under skipLoopDetection too. + private statefulRequestedKeysSinceLastFinished = new Set(); + // callId → request pairing so results can be matched to their calls when // the runtime only has the response (populated on ToolCallRequest events, // consumed by recordToolResultByCallId). @@ -666,7 +682,17 @@ export class LoopDetectionService { * for batches that execute nothing instead — recordDaemonToolCalls in the * ACP Session). Without this, a replay-suppressed round is * indistinguishable from abandonment and disarms the result-aware halts. - * Unknown callIds (never streamed through the guards) are ignored. + * The request-side repetition evidence the guards accumulated when the + * call streamed in must unwind too: no result will ever land for a + * suppressed call, so keeping its increment would leave the result-aware + * carve-outs permanently one result short of the request count (the + * exoneration gate `resultsObserved >= count - 1` becomes unreachable) + * and halt a changing-board poller on arguments alone — the #9450 false + * positive re-entering via provider re-emission mid-streak. The daemon + * twin never counts suppressed calls (its batch recorder receives only + * executable calls), so this keeps the runtimes aligned (issue #9450 + * requirement #6). Unknown callIds (never streamed through the guards) + * are ignored. */ noteSuppressedToolCallByCallId(callId: string): void { const request = this.requestByCallId.get(callId); @@ -681,6 +707,24 @@ export class LoopDetectionService { if (inFlight > 0) { this.statefulInFlight.set(key, inFlight - 1); } + // Unwind the consecutive-identical increment, but only while the streak + // still belongs to the suppressed key: a later different call restarts + // the count for its own key, and decrementing then would corrupt an + // unrelated streak. Floored at zero (a Retry may have reset it since). + if (this.lastToolCallKey === key && this.toolCallRepetitionCount > 0) { + this.toolCallRepetitionCount--; + } + // Drop the window occurrence the alternating-pattern tier pushed when + // the call streamed in; it carries no result, and leaving it in would + // overstate the key's occurrences so the carve-out expects one more + // recorded result than can ever exist (judging the window on too + // little evidence). Remove the most recent occurrence: the suppressed + // call is the latest push of this key unless an identical call streamed + // after it, in which case removing either occurrence is equivalent. + const windowIndex = this.recentToolCallKeys.lastIndexOf(key); + if (windowIndex >= 0) { + this.recentToolCallKeys.splice(windowIndex, 1); + } this.statefulResultKeysSinceLastFinished.add(key); } @@ -837,6 +881,7 @@ export class LoopDetectionService { state.consecutiveIdenticalResults = 0; } this.statefulResultKeysSinceLastFinished.clear(); + this.statefulRequestedKeysSinceLastFinished.clear(); this.statefulAlternationHistory.clear(); this.statefulRepeatKeys.clear(); this.statefulInFlight.clear(); @@ -861,6 +906,10 @@ export class LoopDetectionService { const stateful = this.isStatefulReadTool(event.value.name); if (stateful) { this.statefulRepeatKeys.add(key); + // The Finished-boundary decay must not treat this key as abandoned + // at this stream's own boundary: its result is recorded AFTER the + // Finished event (see statefulRequestedKeysSinceLastFinished). + this.statefulRequestedKeysSinceLastFinished.add(key); } // Pair requests with their later results (recordToolResultByCallId). @@ -1441,16 +1490,26 @@ export class LoopDetectionService { * stuck signal. Without this the key map is add-only and the peak latches * for the whole prompt — the adaptive cap would then halt a productive * turn just past the soft cap on the abandoned key's stale peak (issue - * #9450). Keys polled in every round-trip appear in the set and keep their - * streaks, so a continuously frozen board still arms the cap. lastFingerprint - * survives the decay: when polling resumes, the first fresh result is - * still judged against the last observed one (changed → productive, - * unchanged → the count re-accumulates toward the halt). + * #9450). Keys polled in every round-trip appear in the result set and + * keep their streaks, so a continuously frozen board still arms the cap. + * Keys that merely streamed a request since the last boundary are skipped + * too (statefulRequestedKeysSinceLastFinished): production records results + * AFTER the Finished of the stream that emitted their calls, and any gap + * round (a text-only turn, an interleaved other tool) consumes the + * previous result's mark at its own boundary — decaying a key that is + * still being polled would wipe its streak at the next poll's boundary, + * disarming the stuck signal for an every-other-round frozen poller and + * resetting resultsObserved mid-streak while toolCallRepetitionCount + * stands (issue #9450). lastFingerprint survives the decay: when polling + * resumes, the first fresh result is still judged against the last + * observed one (changed → productive, unchanged → the count + * re-accumulates toward the halt). */ private decayAbandonedStatefulStreaks(): void { let decayed = false; for (const [key, state] of this.statefulRepeatState) { if (this.statefulResultKeysSinceLastFinished.has(key)) continue; + if (this.statefulRequestedKeysSinceLastFinished.has(key)) continue; if ( state.consecutiveIdenticalResults > 0 || state.resultsObserved > 0 || @@ -1463,6 +1522,7 @@ export class LoopDetectionService { } } this.statefulResultKeysSinceLastFinished.clear(); + this.statefulRequestedKeysSinceLastFinished.clear(); if (decayed) { this.recomputeStatefulCapPeak(); } @@ -1643,6 +1703,7 @@ export class LoopDetectionService { this.statefulCapKeyRepeat = 0; this.statefulRepeatState.clear(); this.statefulResultKeysSinceLastFinished.clear(); + this.statefulRequestedKeysSinceLastFinished.clear(); this.statefulRepeatKeys.clear(); this.statefulAlternationHistory.clear(); this.statefulInFlight.clear(); From 395420a7d8a5b253a3cdcf6f631fae6499a2b8f6 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Mon, 24 Aug 2026 05:23:29 +0800 Subject: [PATCH 29/51] fix(cli): skip daemon batch decay for re-requested stateful keys (#9450) --- .../acp-integration/session/Session.test.ts | 62 +++++++++++++++++++ .../src/acp-integration/session/Session.ts | 27 +++++++- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 86a04a205ab..9ce191261f0 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -11066,6 +11066,68 @@ describe('Session', () => { expect(loopState.totalToolCalls).toBe(21); expect(execute).toHaveBeenCalledTimes(20); }); + + it('still halts a frozen daemon poller interleaved every other batch with other work (issue #9450)', async () => { + // CLI defaults: skipLoopDetection=true, adaptive soft cap — the + // cap's stateful stuck signal is the ONLY live halt path. A + // frozen board polled every OTHER batch between varied work: + // pre-fix the poll batch's boundary found the key absent from + // the result set (the gap batch recorded no task_list result and + // consumed the previous mark at its own boundary), decayed the + // streak back to zero, and the stuck signal never armed — the + // turn ran to the hard backstop. The re-requested skip (mirror + // of core's requested-keys skip in + // decayAbandonedStatefulStreaks) keeps the streak alive across + // gap batches; the runtimes must not drift (requirement #6). + mockConfig.getApprovalMode = vi + .fn() + .mockReturnValue(ApprovalMode.YOLO); + mockConfig.getMaxToolCallsPerTurn = vi.fn().mockReturnValue(20); + mockConfig.isMaxToolCallsPerTurnExplicit = vi + .fn() + .mockReturnValue(false); + mockConfig.getSkipLoopDetection = vi.fn().mockReturnValue(true); + installTaskListAndGenericTools(() => 'frozen board'); + const loopState = freshLoopState(); + + const runDiverseBatch = (round: number) => + ( + session as unknown as { + runToolCalls: ( + abortSignal: AbortSignal, + promptId: string, + calls: unknown[], + loopState: ReturnType, + ) => Promise<{ loopDetected?: boolean; parts: Part[] }>; + } + ).runToolCalls( + new AbortController().signal, + `prompt-diverse-${round}`, + [ + { + id: `diverse_${round}`, + name: 'generic_tool', + args: { step: round }, + }, + ], + loopState, + ); + + let fired = false; + for (let round = 0; round < 40 && !fired; round++) { + const poll = await runTaskListPoll(loopState, round); + fired = poll.loopDetected ?? false; + if (fired) break; + const gap = await runDiverseBatch(round); + fired = gap.loopDetected ?? false; + } + // The streak survives the gap batches and arms the stuck signal: + // the halt lands just past the soft cap (20), far below the hard + // backstop (200). + expect(fired).toBe(true); + expect(loopState.loopType).toBe(core.LoopType.TURN_TOOL_CALL_CAP); + expect(loopState.totalToolCalls).toBeLessThanOrEqual(24); + }); }); }); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 4c7af380b61..bd8d06c3986 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -866,13 +866,25 @@ function isLoopDetectedTurnError(error: unknown): boolean { * for a frozen daemon poller, and the latched peak would halt a productive * turn just past the soft cap. Keys polled in every batch appear in the set * and keep their streaks, so a continuously frozen board still arms the cap. + * Keys requested in the CURRENT batch (`requestedKeys`) are skipped too — + * the mirror of core's skip for keys requested since the last Finished + * boundary (loopDetectionService.decayAbandonedStatefulStreaks): a poll + * batch's own results are recorded after this boundary runs, and any gap + * batch (other tools between polls) consumes the previous result's mark at + * its own boundary, so decaying a key that is still being polled would + * wipe its streak at the next poll's boundary and disarm the stuck signal + * for an every-other-batch frozen poller (issue #9450 requirement #6). */ -function decayAbandonedDaemonStreaks(loopState: DaemonToolLoopState): void { +function decayAbandonedDaemonStreaks( + loopState: DaemonToolLoopState, + requestedKeys?: ReadonlySet, +): void { const sinceLastBatch = (loopState.statefulResultKeysSinceLastBatch ??= new Set()); let decayed = false; for (const [key, state] of loopState.statefulResultStreaks) { if (sinceLastBatch.has(key)) continue; + if (requestedKeys?.has(key)) continue; if (state.consecutiveIdenticalResults > 0) { state.consecutiveIdenticalResults = 0; decayed = true; @@ -914,11 +926,22 @@ function recordDaemonToolCalls( // and skipping the decay can only keep the repeat peak higher, which a // prior non-halting check at the same total already tolerated. if (calls.length === 0) return false; + // Stateful keys requested in THIS batch: the boundary decay must skip + // them (see decayAbandonedDaemonStreaks) — their results have not landed + // yet, exactly as core's Finished-boundary decay skips keys requested + // since the last boundary. + const requestedStatefulKeys = new Set(); + for (const call of calls) { + const name = call.name ?? ''; + if (isStatefulReadTool(name)) { + requestedStatefulKeys.add(getToolCallRepeatKey(name, call.args ?? {})); + } + } // Batch boundary: the previous batch's results have all been recorded by // now (results are recorded during execution, before the next batch is // streamed), so this is the safe point to decay stateful keys absent from // them — the daemon twin of core's Finished-boundary decay (issue #9450). - decayAbandonedDaemonStreaks(loopState); + decayAbandonedDaemonStreaks(loopState, requestedStatefulKeys); loopState.totalToolCalls += calls.length; for (const call of calls) { // Stateful read tools are counted post-execution in From b7d8386c7e6e6f221234ed6790edbecea9e38395 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Mon, 24 Aug 2026 05:23:46 +0800 Subject: [PATCH 30/51] fix(core): keep sub-label batch-budget fits content-dependent (#9450) --- .../src/utils/tool-response-finalizer.test.ts | 51 +++++++++++++++++++ .../core/src/utils/tool-response-finalizer.ts | 18 +++++-- 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/packages/core/src/utils/tool-response-finalizer.test.ts b/packages/core/src/utils/tool-response-finalizer.test.ts index ea01a838285..4835fdeab5b 100644 --- a/packages/core/src/utils/tool-response-finalizer.test.ts +++ b/packages/core/src/utils/tool-response-finalizer.test.ts @@ -1020,5 +1020,56 @@ describe('tool response finalization', () => { ); expect(new Set(fitted).size).toBe(12); }); + + it('fingerprints distinct boards distinctly at sub-label allocations', () => { + // Allocations <= FULL_OUTPUT_DIGEST_LABEL.length (20 chars) sliced + // only constant label text pre-fix (the digest starts AFTER the + // label), so every oversized result fingerprinted identically no + // matter its content — the degenerate band one notch below the band + // the digest-line slice covers (21..107). + const boardA = '#1 [in_progress] @peer-a — ship it'; + const boardB = '#2 [completed] @peer-b — totally different board'; + + for (const budget of [1, 5, 12, 20]) { + const fitA = fittedOutput( + enforceFunctionResponseBudget([oversizedEntry('a', boardA)], budget), + ) as string; + const fitB = fittedOutput( + enforceFunctionResponseBudget([oversizedEntry('b', boardB)], budget), + ) as string; + expect(fitA.length).toBeLessThanOrEqual(budget); + expect(fitA).not.toBe(''); + expect(fitA).not.toBe(fitB); + } + }); + + it('does not collapse many distinct boards at the label-length allocation', () => { + // Reviewer witness shape: budget 240 over 12 oversized slots gives + // EXACTLY FULL_OUTPUT_DIGEST_LABEL.length (20) chars per slot — + // pre-fix every fit was the identical constant label text, so + // repeated oversized polls of a CHANGING board fingerprinted + // identically and false-halted on consecutive_identical_tool_calls. + const entries = Array.from({ length: 12 }, (_, index) => + oversizedEntry(`call-${index}`, `board variant ${index} — distinct`), + ); + const fitted = enforceFunctionResponseBudget(entries, 240).map( + (fittedEntry) => + fittedEntry.responseParts[0].functionResponse?.response?.[ + 'output' + ] as string, + ); + expect(new Set(fitted).size).toBe(12); + }); + + it('keeps identical content fingerprint-stable at sub-label allocations', () => { + const board = '#3 [in_progress] @peer-c — frozen board'; + const fitOne = fittedOutput( + enforceFunctionResponseBudget([oversizedEntry('a', board)], 12), + ) as string; + const fitTwo = fittedOutput( + enforceFunctionResponseBudget([oversizedEntry('b', board)], 12), + ) as string; + expect(fitOne).toBe(fitTwo); + }); }); }); diff --git a/packages/core/src/utils/tool-response-finalizer.ts b/packages/core/src/utils/tool-response-finalizer.ts index 7ba06b95d4d..0b4438c81bf 100644 --- a/packages/core/src/utils/tool-response-finalizer.ts +++ b/packages/core/src/utils/tool-response-finalizer.ts @@ -275,11 +275,19 @@ function fitText( // consecutive_identical_tool_calls under a small configured // toolOutputBatchBudget (issue #9450). Slice the digest line itself // instead so any allocation reaching past the label carries - // content-dependent digest characters. - return sliceStartWithoutBrokenSurrogate( - maxChars >= minimalHeader.length ? header : digestLine, - maxChars, - ); + // content-dependent digest characters. The band at or below the label + // length is degenerate one notch further: slicing the digest line there + // yields only (a prefix of) the constant label itself — budget 240 over + // 12 oversized slots gives exactly FULL_OUTPUT_DIGEST_LABEL.length chars + // per slot — so carry the digest's own characters instead and every + // non-zero allocation stays content-dependent (issue #9450). + if (maxChars >= minimalHeader.length) { + return sliceStartWithoutBrokenSurrogate(header, maxChars); + } + if (maxChars > FULL_OUTPUT_DIGEST_LABEL.length) { + return sliceStartWithoutBrokenSurrogate(digestLine, maxChars); + } + return sliceStartWithoutBrokenSurrogate(digest, maxChars); } const separator = '\n\n'; From e754027f72a82b87d6488293625170e3cf3e95c9 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Mon, 24 Aug 2026 07:04:30 +0800 Subject: [PATCH 31/51] test(core): align the replay-in-streak halt with the suppression unwind (#9450) --- .../src/agents/runtime/agent-headless.test.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/core/src/agents/runtime/agent-headless.test.ts b/packages/core/src/agents/runtime/agent-headless.test.ts index 528ab966417..517b071063c 100644 --- a/packages/core/src/agents/runtime/agent-headless.test.ts +++ b/packages/core/src/agents/runtime/agent-headless.test.ts @@ -2665,13 +2665,18 @@ describe('subagent.ts', () => { await scope.execute(new ContextState()); - // The replay never executes; the halt lands on the fifth identical - // request (poll_4's stream), whose four preceding results are the - // three executed frozen boards (the synthetic replay response is - // excluded). Pre-fix the fabricated replay error counted as a - // changed result, the streak restarted, and the run sailed to GOAL. - expect(taskListInvocation.execute).toHaveBeenCalledTimes(3); - expect(mockSendMessageStream).toHaveBeenCalledTimes(5); + // The replay never executes, and the suppression unwinds its + // streamed-in request count (keeping the exoneration gate + // `resultsObserved >= count - 1` reachable for changing boards and + // matching the daemon twin, which never counts suppressed calls), + // so the consecutive streak counts the four executable requests: + // the halt lands on the fifth countable identical request + // (poll_5's stream) after poll_4 executes as the fourth frozen + // board, corroborated by the four unchanged results. Pre-fix the + // fabricated replay error counted as a changed result, the streak + // restarted, and the run sailed to GOAL. + expect(taskListInvocation.execute).toHaveBeenCalledTimes(4); + expect(mockSendMessageStream).toHaveBeenCalledTimes(6); expect(scope.getTerminateMode()).toBe(AgentTerminateMode.LOOP_DETECTED); expect(finishEvents).toHaveLength(1); expect(finishEvents[0].loopType).toBe( From b1a8e66c0b30284561eeecde9b68bd6ed7e241fd Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Mon, 24 Aug 2026 12:54:12 +0800 Subject: [PATCH 32/51] fix(core): make the always-on consecutive guard in-flight-aware for parallel stateful batches (#9450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exoneration gate assumed the prior N-1 results of the Nth identical request had all been recorded, but with parallel tool batches ALL of a round's identical task_list requests stream through the guard before ANY of that round's results lands (dedupeToolCallsById collapses only same-callId duplicates). The gate became unsatisfiable and a productive changing-board poller halted CONSECUTIVE_IDENTICAL_TOOL_CALLS — the #9450 false positive re-entering via a parallel batch. Give the always-on guard the same per-key in-flight accounting the alternating-pattern carve-out already uses: maintain statefulInFlight in the always-on path (checkAlwaysOnSafeties) so it also works under the skipLoopDetection default, and judge the gate on toolCallRepetitionCount - inFlight results (floored at the recorded evidence). A genuine wiring gap (shortfall with no recorded evidence) still fails safe and halts, preserving the #5019 protection. --- .../src/services/loopDetectionService.test.ts | 82 +++++++++++++++++++ .../core/src/services/loopDetectionService.ts | 79 +++++++++++------- 2 files changed, 132 insertions(+), 29 deletions(-) diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index a7662001eba..81bd35e1ca1 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -2796,6 +2796,88 @@ describe('LoopDetectionService', () => { expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); + it('does not halt a parallel-batch task_list poller whose results keep changing (issue #9450)', () => { + // Parallel same-round identical requests: ALL of a round's requests + // stream through the always-on guard before ANY of that round's + // results is recorded (production ordering: requests → Finished → + // results). Pre-fix the exoneration gate assumed the prior N-1 + // results of the Nth identical request had all landed — with rounds + // [poll], [poll, poll], [poll, poll] the 5th request saw only 3 + // recorded results against expectedResults 4, the gate was skipped, + // and a productive changing-board poller halted + // CONSECUTIVE_IDENTICAL_TOOL_CALLS (the #9450 false positive + // re-entering via a parallel batch). + const finishedEvent = { + type: GeminiEventType.Finished, + value: { reason: 'STOP' }, + } as unknown as ServerGeminiStreamEvent; + const parallelService = new LoopDetectionService(makeConfig()); + parallelService.reset('parallel-productive'); + + const roundSizes = [1, 2, 2]; + let poll = 0; + let fired = false; + for (const roundSize of roundSizes) { + for (let i = 0; i < roundSize && !fired; i++) { + fired = parallelService.checkAlwaysOnSafeties( + taskListEvent(`poll-${poll}`), + ); + poll++; + } + if (fired) break; + parallelService.checkAlwaysOnSafeties(finishedEvent); + for (let i = 0; i < roundSize; i++) { + fired = parallelService.recordToolResultByCallId( + `poll-${poll - roundSize + i}`, + taskListResult( + `board state v${poll - roundSize + i}`, + `poll-${poll - roundSize + i}`, + ), + ); + if (fired) break; + } + } + expect(fired).toBe(false); + expect(parallelService.getLastLoopType()).toBeNull(); + }); + + it('still halts a parallel-batch task_list poller on a frozen board (fail-safe)', () => { + // Fail-safe twin of the parallel-batch regression: with an unchanged + // board the recorded results corroborate the repetition, so the + // in-flight-aware gate still halts at the 5th identical request. + const finishedEvent = { + type: GeminiEventType.Finished, + value: { reason: 'STOP' }, + } as unknown as ServerGeminiStreamEvent; + const parallelService = new LoopDetectionService(makeConfig()); + parallelService.reset('parallel-frozen'); + + const roundSizes = [1, 2, 2]; + let poll = 0; + let fired = false; + for (const roundSize of roundSizes) { + for (let i = 0; i < roundSize && !fired; i++) { + fired = parallelService.checkAlwaysOnSafeties( + taskListEvent(`poll-${poll}`), + ); + poll++; + } + if (fired) break; + parallelService.checkAlwaysOnSafeties(finishedEvent); + for (let i = 0; i < roundSize; i++) { + fired = parallelService.recordToolResultByCallId( + `poll-${poll - roundSize + i}`, + taskListResult('frozen board', `poll-${poll - roundSize + i}`), + ); + if (fired) break; + } + } + expect(fired).toBe(true); + expect(parallelService.getLastLoopType()).toBe( + LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, + ); + }); + it('keeps productive polling alive past the adaptive per-turn cap', () => { // With the default (adaptive) cap, a turn beyond the soft cap halts // only on a stuck-repetition signal. Changed results must not build diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 01bcb4f4888..20a850f8df5 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -529,15 +529,18 @@ export class LoopDetectionService { // its own requests produced. private statefulAlternationHistory = new Map(); - // Per-key count of stateful requests fed to the heuristic tier whose - // results have NOT landed yet (incremented in addAndCheckHeuristicLoops, - // decremented in recordToolResult). With parallel tool batches both - // requests of a round reach the guard before that round's results land, - // so a window judged on args alone would skip the exonerating result - // check for occurrences still in flight and false-halt a productive - // poller; the carve-out subtracts these from its expected results (issue - // #9450). Reduces to the sequential arithmetic when results land before - // the next request is fed. + // Per-key count of stateful requests streamed through the guards whose + // results have NOT landed yet (incremented in checkAlwaysOnSafeties, + // decremented in recordToolResult and noteSuppressedToolCallByCallId). + // With parallel tool batches ALL requests of a round reach the guards + // before that round's results land, so a guard judged on args alone would + // skip the exonerating result check for occurrences still in flight and + // false-halt a productive poller; both the always-on consecutive-identical + // gate and the alternating-pattern carve-out subtract these from their + // expected results (issue #9450). Maintained in the always-on path so the + // accounting works under the skipLoopDetection default too. Reduces to + // the sequential arithmetic when results land before the next request is + // fed. private statefulInFlight = new Map(); // Loop type of the most recent firing. Bubbled up through the @@ -610,9 +613,9 @@ export class LoopDetectionService { this.statefulResultKeysSinceLastFinished.add(key); // One in-flight request for this key has landed: unreserve it for the - // alternating-pattern carve-out (see statefulInFlight). Floored at zero - // because results can be recorded without a heuristic feed when - // skipLoopDetection keeps the heuristic tier off. + // in-flight accounting (see statefulInFlight). Floored at zero because + // results can be recorded for calls that never streamed through the + // guards (direct recordToolResult callers). const inFlight = this.statefulInFlight.get(key) ?? 0; if (inFlight > 0) { this.statefulInFlight.set(key, inFlight - 1); @@ -727,9 +730,10 @@ export class LoopDetectionService { * no result evidence and must not be recorded via recordToolResult / * recordToolResultByCallId. The request-time reservations the guards made * when the call streamed in must unwind: the callId pairing is dropped (no - * real result will land for it), the alternating-pattern carve-out's - * in-flight reservation is released (otherwise the carve-out over-subtracts - * in-flight counts and judges the window on too little evidence), and the + * real result will land for it), the per-key in-flight reservation is + * released (otherwise the consecutive-identical gate and the + * alternating-pattern carve-out over-subtract in-flight counts and judge + * on too little evidence), and the * key is marked as having produced activity since the last Finished * boundary — the model DID re-issue the poll; suppression is the runtime's * machinery, not abandonment, so the decay must not wipe a live @@ -834,13 +838,10 @@ export class LoopDetectionService { const stateful = this.isStatefulReadTool(event.value.name); if (stateful) { this.statefulRepeatKeys.add(toolCallKey); - // This request is now in flight: its result has not landed yet, - // so the alternating-pattern carve-out must not expect it (see - // statefulInFlight). recordToolResult decrements when it lands. - this.statefulInFlight.set( - toolCallKey, - (this.statefulInFlight.get(toolCallKey) ?? 0) + 1, - ); + // The per-key in-flight reservation for this request is made in + // the always-on path (checkAlwaysOnSafeties), which production + // and addAndCheck always run first — incrementing here too would + // double-count (see statefulInFlight). } const globalDup = stateful ? false @@ -1010,6 +1011,14 @@ export class LoopDetectionService { // at this stream's own boundary: its result is recorded AFTER the // Finished event (see statefulRequestedKeysSinceLastFinished). this.statefulRequestedKeysSinceLastFinished.add(key); + // This request is now in flight: its result has not landed yet, so + // the consecutive-identical gate's exoneration check and the + // alternating-pattern carve-out must not expect it (see + // statefulInFlight). recordToolResult / noteSuppressedToolCallByCallId + // decrement when it lands or is suppressed. Kept here (always-on) so + // the accounting also works under the skipLoopDetection default, + // where the heuristic tier never runs (issue #9450). + this.statefulInFlight.set(key, (this.statefulInFlight.get(key) ?? 0) + 1); } // Pair requests with their later results (recordToolResultByCallId). @@ -1087,16 +1096,28 @@ export class LoopDetectionService { if (this.isStatefulReadTool(toolCall.name)) { // Result-aware guard (issue #9450): identical arguments to a stateful // read do not imply an identical result, so only halt when the - // executed results corroborate the loop. By the Nth identical request - // the prior N-1 results have been recorded; if they were ALL observed - // and unchanged, the repetition is genuinely unproductive. If some - // result changed, the model's re-poll was productive — restart the - // streak instead of halting. Missing result evidence (results never - // recorded for this streak) fails safe and keeps the pre-#9450 + // executed results corroborate the loop. With sequential rounds the + // prior N-1 results of the Nth identical request have been recorded; + // with parallel batches ALL of a round's identical requests stream + // through this guard before ANY of that round's results lands + // (dedupeToolCallsById collapses only same-callId duplicates, so + // distinct-callId twins both execute), and the still-in-flight + // requests cannot have recorded results yet. Subtract them from the + // expected count (floored at the recorded evidence) so the gate is + // judged on the results that CAN have landed; a changed recorded + // result still restarts the streak. Missing result evidence (results + // never recorded for this streak) fails safe and keeps the pre-#9450 // behavior, so the DashScope protection (#5019) is never loosened by // a wiring gap. const state = this.statefulRepeatState.get(key); - const expectedResults = this.toolCallRepetitionCount - 1; + const inFlight = Math.min( + this.statefulInFlight.get(key) ?? 0, + this.toolCallRepetitionCount, + ); + const expectedResults = Math.max( + this.toolCallRepetitionCount - inFlight, + state?.resultsObserved ?? 0, + ); if (state && state.resultsObserved >= expectedResults) { if (state.unchangedStreak < expectedResults - 1) { this.toolCallRepetitionCount = 1; From 5746008c1030103ab4050b9c47403aa3b7b1d186 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Mon, 24 Aug 2026 12:57:53 +0800 Subject: [PATCH 33/51] fix(core): reset the consecutive streak when its result evidence decays (#9450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Finished-boundary decay zeroed a key's result evidence (resultsObserved / unchangedStreak) after two consecutive mark-less round-trips, but the always-on consecutive streak survived untouched. When polling resumed mid-streak, the exoneration gate could never be satisfied again (resultsObserved can only ever reach count - 2), so a changing-board poller halted CONSECUTIVE_IDENTICAL_TOOL_CALLS — the #9450 false positive re-entering via the decay layer. Drop the consecutive streak together with its evidence in decayAbandonedStatefulStreaks so resumed polling starts a fresh streak judged on its own results. Decay never runs for a key with requests still in flight (the requested-set skip), so this cannot defeat the in-flight deferral. --- .../src/services/loopDetectionService.test.ts | 98 +++++++++++++++++++ .../core/src/services/loopDetectionService.ts | 16 ++- 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index 81bd35e1ca1..6a7ecbc1dc8 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -3791,6 +3791,104 @@ describe('LoopDetectionService', () => { expect(capService.getLastLoopType()).toBeNull(); }); + it('does not halt a resumed task_list poller whose evidence decayed mid-streak (issue #9450)', () => { + // Two consecutive tool-call-free round-trips mid-streak (reachable + // via checkNextSpeaker "Please continue." hook turns or agent-core + // external-input wait rounds) decay the key's result evidence at the + // second Finished boundary. Pre-fix the always-on consecutive streak + // (lastToolCallKey / toolCallRepetitionCount) survived the decay: + // resultsObserved could then only ever reach count - 2, the + // exoneration gate stayed permanently unsatisfiable, and a + // changing-board poller halted at the 5th identical request after + // resuming — the #9450 false positive re-entering via the decay + // layer. The decay's "abandoned" semantics must drop the streak too + // so resumed polling starts fresh and is judged on its own results. + const finishedEvent = { + type: GeminiEventType.Finished, + value: { reason: 'STOP' }, + } as unknown as ServerGeminiStreamEvent; + const gapService = new LoopDetectionService(makeConfig()); + gapService.reset('decay-resume-productive'); + + // Bring the streak to 3 with changing results, one poll per round + // (production ordering: request → Finished → result). + for (let i = 0; i < 3; i++) { + expect( + gapService.checkAlwaysOnSafeties(taskListEvent(`poll-${i}`)), + ).toBe(false); + gapService.checkAlwaysOnSafeties(finishedEvent); + expect( + gapService.recordToolResultByCallId( + `poll-${i}`, + taskListResult(`board state v${i}`, `poll-${i}`), + ), + ).toBe(false); + } + // Two consecutive tool-call-free round-trips: the first boundary + // consumes the last result's mark, the second decays the evidence. + gapService.checkAlwaysOnSafeties(finishedEvent); + gapService.checkAlwaysOnSafeties(finishedEvent); + + // Polling resumes with the board still changing: no halt. Pre-fix + // this fired CONSECUTIVE_IDENTICAL_TOOL_CALLS at the 5th identical + // request of the streak. + let fired = false; + for (let i = 3; i < 11 && !fired; i++) { + fired = gapService.checkAlwaysOnSafeties(taskListEvent(`poll-${i}`)); + if (fired) break; + gapService.checkAlwaysOnSafeties(finishedEvent); + fired = gapService.recordToolResultByCallId( + `poll-${i}`, + taskListResult(`board state v${i}`, `poll-${i}`), + ); + } + expect(fired).toBe(false); + expect(gapService.getLastLoopType()).toBeNull(); + }); + + it('still halts a resumed frozen poller whose evidence decayed mid-streak (fail-safe)', () => { + // Fail-safe twin of the decay-resume regression: after the abandoned + // evidence decays and polling resumes, a frozen board corroborates + // the loop again through the fresh streak's own results, so the + // guard still halts once the fresh streak is complete. + const finishedEvent = { + type: GeminiEventType.Finished, + value: { reason: 'STOP' }, + } as unknown as ServerGeminiStreamEvent; + const gapService = new LoopDetectionService(makeConfig()); + gapService.reset('decay-resume-frozen'); + + for (let i = 0; i < 3; i++) { + expect( + gapService.checkAlwaysOnSafeties(taskListEvent(`poll-${i}`)), + ).toBe(false); + gapService.checkAlwaysOnSafeties(finishedEvent); + expect( + gapService.recordToolResultByCallId( + `poll-${i}`, + taskListResult('frozen board', `poll-${i}`), + ), + ).toBe(false); + } + gapService.checkAlwaysOnSafeties(finishedEvent); + gapService.checkAlwaysOnSafeties(finishedEvent); + + let fired = false; + for (let i = 3; i < 11 && !fired; i++) { + fired = gapService.checkAlwaysOnSafeties(taskListEvent(`poll-${i}`)); + if (fired) break; + gapService.checkAlwaysOnSafeties(finishedEvent); + fired = gapService.recordToolResultByCallId( + `poll-${i}`, + taskListResult('frozen board', `poll-${i}`), + ); + } + expect(fired).toBe(true); + expect(gapService.getLastLoopType()).toBe( + LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, + ); + }); + it('does not halt a changing-board poller when a suppressed replay lands mid-streak', () => { // The provider re-emitted an already-handled call id mid-streak: the // replay streamed through the guards (incrementing the request-side diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 20a850f8df5..c792bd1e6ed 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -1848,7 +1848,17 @@ export class LoopDetectionService { * still being polled would wipe its streak at the next poll's boundary, * disarming the stuck signal for an every-other-round frozen poller and * resetting resultsObserved mid-streak while toolCallRepetitionCount - * stands (issue #9450). lastFingerprint survives the decay: when polling + * stands (issue #9450). When a key's evidence is zeroed, the always-on + * consecutive streak is dropped too if it still belongs to that key: + * the exoneration gate counts resultsObserved against + * toolCallRepetitionCount, and zeroing the evidence while the count + * stands leaves the gate permanently unsatisfiable — resumed polling + * would halt CONSECUTIVE_IDENTICAL_TOOL_CALLS regardless of its results + * (the #9450 false positive re-entering via the decay layer). A resumed + * streak starts fresh and is judged on its own results; decay never runs + * for a key with requests still in flight (the requested-set skip above), + * so this cannot drop a streak the in-flight accounting is still + * deferring. lastFingerprint survives the decay: when polling * resumes, the first fresh result is still judged against the last * observed one (changed → productive, unchanged → the count * re-accumulates toward the halt). @@ -1866,6 +1876,10 @@ export class LoopDetectionService { state.consecutiveIdenticalResults = 0; state.resultsObserved = 0; state.unchangedStreak = 0; + if (this.lastToolCallKey === key) { + this.lastToolCallKey = null; + this.toolCallRepetitionCount = 0; + } decayed = true; } } From c4db73a9c15649c480a6914d3eba91335024b373 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Mon, 24 Aug 2026 13:04:35 +0800 Subject: [PATCH 34/51] fix(cli): mark suppressed stateful replays for the daemon batch decay (#9450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A MIXED daemon batch — a suppressed task_list replay alongside at least one executable call — wiped the frozen-board streak: requestedStatefulKeys is built from executable calls only, the batch is non-empty so the empty-batch early return does not apply, and once the prior result's mark was consumed the replayed key sat in neither skip set, so decayAbandonedDaemonStreaks zeroed the streak and recomputed statefulMaxResultRepeat to 0. With wipes landing every few executed polls the stuck signal never reached GLOBAL_DUPLICATE_THRESHOLD and the adaptive-cap halt never fired under CLI-default skipLoopDetection=true — the two runtimes drifted (core marks suppression via noteSuppressedToolCallByCallId; issue #9450 requirement #6). Mirror core's suppression mark in pushDuplicateBatch: add the replayed stateful key to statefulResultKeysSinceLastBatch before recordDaemonToolCalls runs, and add a mixed-batch variant of the replay-interleave regression test. --- .../acp-integration/session/Session.test.ts | 90 +++++++++++++++++++ .../src/acp-integration/session/Session.ts | 18 ++++ 2 files changed, 108 insertions(+) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index e47c351add1..ec14d90feab 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -11499,6 +11499,96 @@ describe('Session', () => { expect(execute).toHaveBeenCalledTimes(20); }); + it('still halts a frozen daemon poller when MIXED batches interleave a suppressed replay with an executable call (issue #9450)', async () => { + // Mixed-batch variant of the replay-interleave regression: one + // executed poll per cycle, then two MIXED rounds that each + // suppress a task_list replay alongside an EXECUTABLE generic + // call. Pre-fix the batch was non-empty (no + // calls.length === 0 early return), requestedStatefulKeys held + // only the executable call, and once the previous result's mark + // was consumed the replayed key sat in neither skip set — + // decayAbandonedDaemonStreaks wiped the frozen-board streak and + // recomputed statefulMaxResultRepeat to 0, so the stuck signal + // never reached GLOBAL_DUPLICATE_THRESHOLD and the detected + // stuck loop ran to the hard backstop (core survives this shape + // via noteSuppressedToolCallByCallId's mark; requirement #6). + mockConfig.getApprovalMode = vi + .fn() + .mockReturnValue(ApprovalMode.YOLO); + mockConfig.getMaxToolCallsPerTurn = vi.fn().mockReturnValue(20); + mockConfig.isMaxToolCallsPerTurnExplicit = vi + .fn() + .mockReturnValue(false); + mockConfig.getSkipLoopDetection = vi.fn().mockReturnValue(true); + installTaskListAndGenericTools(() => 'frozen board'); + const fingerprint = core.getToolCallFingerprint( + 'task_list', + TASK_LIST_ARGS, + ); + vi.mocked(mockChat.getHistoryToolCallFingerprints).mockReturnValue( + new Map( + Array.from({ length: 15 }, (_, index) => [ + `replayed_task_list_${index}`, + fingerprint, + ]), + ), + ); + const loopState = freshLoopState(); + + let replayOrdinal = 0; + const runMixedRound = (round: number) => + ( + session as unknown as { + runToolCalls: ( + abortSignal: AbortSignal, + promptId: string, + calls: unknown[], + loopState: ReturnType, + ) => Promise<{ loopDetected?: boolean; parts: Part[] }>; + } + ).runToolCalls( + new AbortController().signal, + `prompt-mixed-${round}`, + [ + { + id: `replayed_task_list_${replayOrdinal++}`, + name: 'task_list', + args: TASK_LIST_ARGS, + }, + { + id: `generic_${round}`, + name: 'generic_tool', + args: { step: round }, + }, + ], + loopState, + ); + + let fired = false; + for (let round = 0; round < 60 && !fired; round++) { + if (round % 3 !== 0) { + // Two MIXED rounds per cycle (each one suppressed replay + + // one executable generic call): the first mixed boundary + // consumes the previous poll's result mark, so pre-fix the + // SECOND mixed boundary wiped the streak every cycle + // (peakSeries 1,1,0,…) and the stuck signal never armed. + const mixedResult = await runMixedRound(round); + fired = mixedResult.loopDetected ?? false; + continue; + } + const result = await runTaskListPoll(loopState, round); + fired = result.loopDetected ?? false; + } + + // The suppression mark carries the replayed key through the + // mixed-batch boundaries, so the streak arms the stuck signal + // exactly as in the replay-only control: the halt lands at + // totalToolCalls 21 (soft cap 20 + 1). + expect(fired).toBe(true); + expect(loopState.loopType).toBe(core.LoopType.TURN_TOOL_CALL_CAP); + expect(loopState.totalToolCalls).toBe(21); + }); + it('still halts a frozen daemon poller interleaved every other batch with other work (issue #9450)', async () => { // CLI defaults: skipLoopDetection=true, adaptive soft cap — the // cap's stateful stuck signal is the ONLY live halt path. A diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 5670d3efe66..1423bce5c4b 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -9653,6 +9653,24 @@ export class Session implements SessionContext { this.duplicateProviderToolCallResponseIds, ); + // A suppressed stateful replay keeps its key alive across the batch + // boundary: mirror core's suppression mark (core marks + // statefulResultKeysSinceLastFinished in noteSuppressedToolCallByCallId) + // so the abandonment decay skips it. Without this, a MIXED batch — + // the suppressed replay alongside at least one executable call — + // skipped the empty-batch early return, found the replayed key in + // neither skip set (requestedStatefulKeys is built from executable + // calls only), and decayAbandonedDaemonStreaks wiped the live + // frozen-board streak — keeping statefulMaxResultRepeat below the + // stuck threshold indefinitely and drifting from core (issue #9450 + // requirement #6). + if (toolLoopState && isStatefulReadTool(request.name)) { + (toolLoopState.statefulResultKeysSinceLastBatch ??= + new Set()).add( + getToolCallRepeatKey(request.name, request.args), + ); + } + const response = createDuplicateProviderToolCallResponse(request); debugLogger.debug( `[Session.runToolCalls] Suppressing duplicate provider tool-call id: ` + From a91b6b20280b94a1fa3a8507c0775c7bc0f9481d Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Mon, 24 Aug 2026 13:07:42 +0800 Subject: [PATCH 35/51] fix(cli): exclude never-executed skipped-output synthetics from the loop guards (#9450) nonInteractiveCli's sibling suppression fabricates skipped-output responses for unexecuted calls (a structured_output call sharing a batch with task_list polls in a --json-schema headless run). The synthetic parts carry the original callId, fail isDuplicateProviderToolCallResponse and carry no executionStatus, so client.ts's recording feed paired them with the streamed requests: the constant fabricated fingerprint counted as a "changed" result every round, exonerating a stuck frozen-board poller round after round. The daemon twin excludes exactly this class (providerDuplicate / not_started filter) and agent-core excludes it via neverExecutedCallIds. Mark each unexecuted sibling call via LoopDetectionService.noteSuppressedToolCallByCallId at synthesis time: the request-side reservations unwind and the later fabricated response finds no pairing, mirroring the daemon's not_started filter (issue #9450 requirement #6). --- packages/cli/src/nonInteractiveCli.test.ts | 78 ++++++++++++++++++++++ packages/cli/src/nonInteractiveCli.ts | 12 ++++ 2 files changed, 90 insertions(+) diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index dabf82249e2..033c9c97b28 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -256,7 +256,9 @@ describe('runNonInteractive', () => { consumePendingMemoryTaskPromises: Mock; recordCompletedToolCall: Mock; addHistory: Mock; + getLoopDetectionService: Mock; }; + let mockNoteSuppressedToolCallByCallId: Mock; let mockGetDebugResponses: Mock; let goalRuntime: GoalRuntime; @@ -317,6 +319,7 @@ describe('runNonInteractive', () => { abortAll: vi.fn(), }; + mockNoteSuppressedToolCallByCallId = vi.fn(); mockGeminiClient = { sendMessageStream: vi.fn(), consumePendingMemoryTaskPromises: vi.fn().mockReturnValue([]), @@ -331,6 +334,9 @@ describe('runNonInteractive', () => { })), getChat: vi.fn(() => ({})), getHistoryToolCallFingerprints: vi.fn(() => new Map()), + getLoopDetectionService: vi.fn(() => ({ + noteSuppressedToolCallByCallId: mockNoteSuppressedToolCallByCallId, + })), }; let currentModel = 'test-model'; @@ -6943,6 +6949,78 @@ describe('runNonInteractive', () => { expect(leadingContent).not.toMatch(/Re-issue this call/); }); + it('marks suppressed sibling calls as never-executed for the loop guards (issue #9450)', async () => { + // The fabricated skipped-output responses carry the original callId + // but never executed. client.ts's recording feed excludes only the + // duplicate-message synthetic class, so the constant fabricated + // fingerprint would be recorded as a "changed" result every round, + // exonerating a stuck frozen-board poller. The CLI must unwind the + // request-side reservations via noteSuppressedToolCallByCallId — + // mirroring the daemon's not_started filter and agent-core's + // neverExecutedCallIds exclusion (requirement #6). + (mockConfig.getJsonSchema as Mock).mockReturnValue({ + type: 'object', + properties: { summary: { type: 'string' } }, + }); + (mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON); + setupMetricsMock(); + + (mockConfig.getBackgroundTaskRegistry as Mock).mockReturnValue({ + setNotificationCallback: vi.fn(), + setRegisterCallback: vi.fn(), + getAll: vi.fn().mockReturnValue([]), + hasUnfinalizedTasks: vi.fn().mockReturnValue(false), + abortAll: vi.fn(), + }); + + // A task_list poll suppressed by the same-turn structured_output: + // exactly the call class the result-aware loop guards track. + const suppressedCall: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-suppressed-poll', + name: 'task_list', + args: { status: 'in_progress' }, + isClientInitiated: false, + prompt_id: 'prompt-id-suppressed', + }, + }; + const structuredCall: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-structured-main', + name: 'structured_output', + args: { summary: 'done' }, + isClientInitiated: false, + prompt_id: 'prompt-id-suppressed', + }, + }; + + mockCoreExecuteToolCall.mockResolvedValue({ + responseParts: [{ text: 'ok' }], + }); + + mockGeminiClient.sendMessageStream.mockReturnValueOnce( + createStreamFromEvents([suppressedCall, structuredCall]), + ); + + await runNonInteractive( + mockConfig, + mockSettings, + 'Emit structured output', + 'prompt-id-suppressed', + ); + + // Only structured_output executed; the suppressed poll must be + // marked never-executed so its fabricated skipped-output response + // is excluded from the loop guards' result evidence. + expect(mockCoreExecuteToolCall).toHaveBeenCalledTimes(1); + expect(mockNoteSuppressedToolCallByCallId).toHaveBeenCalledTimes(1); + expect(mockNoteSuppressedToolCallByCallId).toHaveBeenCalledWith( + 'tool-suppressed-poll', + ); + }); + it('tries multiple structured_output calls in the same turn until one succeeds', async () => { // Same-turn batch: [structured_output(bad), structured_output(good)]. // The first fails validation; the second has valid args and should diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 9e91837b2a2..20a7ddd7330 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -2180,6 +2180,18 @@ export async function runNonInteractive( structuredSubmission !== undefined, ); for (const call of unexecutedCalls) { + // Never executed: the fabricated skipped-output response + // carries no result evidence, so unwind the request-side + // reservations the loop guards made when the call streamed in — + // mirroring the daemon's not_started filter and agent-core's + // neverExecutedCallIds exclusion. Without this the constant + // fabricated fingerprint passes client.ts's recording feed + // (isDuplicateProviderToolCallResponse is false for it) and + // counts as a "changed" result every round, exonerating a + // stuck frozen-board poller (issue #9450 requirement #6). + geminiClient + .getLoopDetectionService() + .noteSuppressedToolCallByCallId(call.callId); const responseParts: Part[] = [ { functionResponse: { From 4201cd7910860a1442841df4835f01b63f9f10ba Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Mon, 24 Aug 2026 19:16:54 +0800 Subject: [PATCH 36/51] fix(core): reduce digest-carrying stub shapes the prefix list does not enumerate (#9450) --- .../src/services/loopDetectionService.test.ts | 166 ++++++++++++++++++ .../core/src/services/loopDetectionService.ts | 33 +++- .../src/utils/tool-response-finalizer.test.ts | 74 ++++++++ .../core/src/utils/tool-response-finalizer.ts | 13 ++ packages/core/src/utils/truncation.ts | 39 ++-- 5 files changed, 314 insertions(+), 11 deletions(-) diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index 6a7ecbc1dc8..887cf0846dd 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -5,6 +5,7 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createHash } from 'node:crypto'; import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -25,7 +26,9 @@ import type { DebugLogger } from '../utils/debugLogger.js'; import { enforceFunctionResponseBudget } from '../utils/tool-response-finalizer.js'; import { buildStub, + FULL_OUTPUT_DIGEST_LABEL, PREVIEW_SIZE_CHARS, + TRUNCATION_SAVE_FAILURE_NOTE, truncateAndSaveToFile, } from '../utils/truncation.js'; import { @@ -4479,6 +4482,169 @@ describe('LoopDetectionService', () => { LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, ); }); + + // fitText's degenerate band: when the per-slot allocation holds the + // 84-char digest line but not the 107-char minimal header (budgets + // 84..106 for a single slot), the fit is EXACTLY the line + // `Full output sha256: <64-hex>` — no producer prefix recognizes + // that shape, so the guard must reduce it structurally or it never + // collides with the raw under-budget representation of the same + // board and a frozen board oscillating across the budget boundary + // counts every poll as "changed" (issue #9450). + const degenerateFitResult = (callId: string, board: string): Part[] => { + const fitted = enforceFunctionResponseBudget( + [ + { + callId, + toolName: 'task_list', + responseParts: [ + { + functionResponse: { + id: callId, + name: 'task_list', + response: { output: board }, + }, + }, + ], + persistedOutputFiles: [`/tmp/qwen/tool-results/${callId}.txt`], + }, + ], + 100, + ); + return fitted[0].responseParts; + }; + + it('collides the raw and degenerate digest-line-only fingerprints of identical content', () => { + const fittedOutput = degenerateFitResult('fitted', FROZEN_BOARD)[0] + .functionResponse?.response?.['output']; + // Shape witness: the budget 100 fit is exactly the digest line. + expect(fittedOutput).toBe( + `${FULL_OUTPUT_DIGEST_LABEL}${createHash('sha256') + .update(FROZEN_BOARD) + .digest('hex')}`, + ); + expect(fingerprintToolResult(taskListResult(FROZEN_BOARD, 'raw'))).toBe( + fingerprintToolResult(degenerateFitResult('fitted', FROZEN_BOARD)), + ); + // A changed board stays distinct in both representations. + expect( + fingerprintToolResult(taskListResult(FROZEN_BOARD, 'raw')), + ).not.toBe( + fingerprintToolResult( + degenerateFitResult('fitted', `${FROZEN_BOARD}new row`), + ), + ); + }); + + it('halts a frozen board whose representation alternates raw/degenerate-fit across the budget boundary', () => { + let fired = false; + for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD + 1 && !fired; i++) { + fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); + if (fired) break; + service.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + i % 2 === 0 + ? taskListResult(FROZEN_BOARD, `poll_${i}`) + : degenerateFitResult(`poll_${i}`, FROZEN_BOARD), + ); + } + expect(fired).toBe(true); + expect(service.getLastLoopType()).toBe( + LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, + ); + }); + + it('collides the save-failure and successfully-spilled fingerprints of identical content', async () => + // truncateAndSaveToFile's save-failure fallback starts with the + // digest label itself (no producer prefix) and carries the + // head/tail payload plus the save-failure note. It must reduce to + // its embedded full-output digest exactly like the successfully + // spilled shape, or a board whose spill oscillates between success + // and failure counts every poll as "changed". + { + const spillDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'loop-detection-stub-'), + ); + try { + const { content: spilled } = await truncateAndSaveToFile( + FROZEN_BOARD, + 'task_list_ok', + spillDir, + 1024, + 20, + ); + // Force the save-failure path: mkdir(recursive) throws ENOTDIR + // when an ancestor path component is a regular file. + const blocker = path.join(spillDir, 'blocker'); + await fs.writeFile(blocker, 'x'); + const { content: unsaved } = await truncateAndSaveToFile( + FROZEN_BOARD, + 'task_list_fail', + path.join(blocker, 'sub'), + 1024, + 20, + ); + expect(unsaved.endsWith(TRUNCATION_SAVE_FAILURE_NOTE)).toBe(true); + expect(fingerprintToolResult(taskListResult(spilled, 'ok'))).toBe( + fingerprintToolResult(taskListResult(unsaved, 'fail')), + ); + } finally { + await fs.rm(spillDir, { recursive: true, force: true }); + } + }); + + it('halts a frozen board whose spill success alternates across polls', async () => { + const spillDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'loop-detection-stub-'), + ); + try { + const blocker = path.join(spillDir, 'blocker'); + await fs.writeFile(blocker, 'x'); + const failDir = path.join(blocker, 'sub'); + let fired = false; + for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD && !fired; i++) { + fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); + if (fired) break; + const { content } = await truncateAndSaveToFile( + FROZEN_BOARD, + `task_list_poll_${i}`, + i % 2 === 0 ? spillDir : failDir, + 1024, + 20, + ); + service.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult(content, `poll_${i}`), + ); + } + expect(fired).toBe(true); + expect(service.getLastLoopType()).toBe( + LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, + ); + } finally { + await fs.rm(spillDir, { recursive: true, force: true }); + } + }); + + it('does not collapse content that merely starts with the digest label', () => { + // Shape-exact recognition only: a board whose first line quotes + // the label without a full producer digest line (no 64-hex payload + // of the right length, no save-failure note) carries no producer + // digest and must keep fingerprinting as ordinary content, so its + // mutations stay visible to the result-aware guards. + let fired = false; + for (let i = 0; i < 4 * TOOL_CALL_LOOP_THRESHOLD; i++) { + fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); + if (fired) break; + const board = `${FULL_OUTPUT_DIGEST_LABEL}pending\nrow v${i}`; + service.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult(board, `poll_${i}`), + ); + } + expect(fired).toBe(false); + expect(loggers.logLoopDetected).not.toHaveBeenCalled(); + }); }); }); }); diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index c792bd1e6ed..5c337496966 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -28,6 +28,7 @@ import { PERSISTED_PREVIEW_MARKER, TOOL_OUTPUT_TRUNCATED_PREFIX, TRUNCATED_PART_MARKER, + TRUNCATION_SAVE_FAILURE_NOTE, } from '../utils/truncation.js'; // Re-exported for existing importers (daemon turn-loop guard); the @@ -298,7 +299,28 @@ function stripPersistenceEnvelope(value: string): string { const isProducerStub = STUB_PRODUCER_PREFIXES.some((prefix) => value.startsWith(prefix), ); - if (!isProducerStub) { + // Digest-carrying shapes that start with the digest label itself, so no + // producer prefix recognizes them: the batch-budget finalizer's + // degenerate band that returns exactly the `Full output sha256: <64-hex>` + // line when the per-slot allocation holds the digest line but not the + // full fit header, and truncateAndSaveToFile's save-failure fallback + // (label + head/tail payload + save-failure note). Both carry the full + // pre-truncation digest and must reduce to it exactly like the prefixed + // stubs, or an over-budget representation of a board never collides with + // its under-budget (or successfully-spilled) representation: a frozen + // board oscillating across the budget boundary would fingerprint + // differently per poll despite byte-identical content, the + // consecutive-identical streak would never accumulate, and the cap's + // stuck signal would never arm (issue #9450). Recognition stays + // shape-exact — the label alone is not enough: board content merely + // STARTING with the label carries no producer digest and must keep + // fingerprinting as ordinary content (the injection rationale above). + const isDigestCarryingShape = + !isProducerStub && + value.startsWith(FULL_OUTPUT_DIGEST_LABEL) && + (value.length === FULL_OUTPUT_DIGEST_LABEL.length + 64 || + value.endsWith(TRUNCATION_SAVE_FAILURE_NOTE)); + if (!isProducerStub && !isDigestCarryingShape) { return `sha256:${createHash('sha256') .update(value) .digest('hex')}`; @@ -308,6 +330,15 @@ function stripPersistenceEnvelope(value: string): string { if (digest !== null) { return `sha256:${digest}`; } + if (!isProducerStub) { + // A digest-carrying shape whose label is not followed by a full + // line-anchored 64-hex digest (should not happen for the producers, + // which always embed one): fall back to hashing the whole value like + // ordinary content instead of returning it verbatim. + return `sha256:${createHash('sha256') + .update(value) + .digest('hex')}`; + } const isPreviewStub = value.startsWith(PERSISTED_OUTPUT_OPEN_TAG) || diff --git a/packages/core/src/utils/tool-response-finalizer.test.ts b/packages/core/src/utils/tool-response-finalizer.test.ts index 4835fdeab5b..ffa62b4efa9 100644 --- a/packages/core/src/utils/tool-response-finalizer.test.ts +++ b/packages/core/src/utils/tool-response-finalizer.test.ts @@ -21,6 +21,7 @@ import { buildStub, FULL_OUTPUT_DIGEST_LABEL, persistAndTruncateToolResult, + TRUNCATION_SAVE_FAILURE_NOTE, } from './truncation.js'; const debugLogger = vi.hoisted(() => ({ @@ -912,6 +913,44 @@ describe('tool response finalization', () => { expect(secondFit).toContain(BATCH_BUDGET_FIT_PREFIX); expect(fittedDigest(secondFit)).toBe(boardDigest(board)); }); + + it('carries the inner digest when fitting the save-failure fallback shape', async () => { + // truncateAndSaveToFile's save-failure shape starts with the digest + // label itself (no producer prefix) and embeds the full-output + // digest; a fit wrapping it must carry that digest instead of + // hashing the whole shape, or the fit's digest-reduction never + // collides with the unfitted shape's own digest reduction and a + // board oscillating across the budget boundary counts every poll as + // "changed" (issue #9450). + const board = `#4 [in_progress] @peer-d — save failed\n${'board line\n'.repeat(300)}`; + const digest = boardDigest(board); + const saveFailureShape = + `${FULL_OUTPUT_DIGEST_LABEL}${digest}\n` + + 'head line\n... [CONTENT TRUNCATED] ...\ntail line\n' + + TRUNCATION_SAVE_FAILURE_NOTE; + expect(saveFailureShape.length).toBeGreaterThan(150); + + const finalized = await finalizeToolResponses( + config(150), + [ + entry('call-a', [ + { + functionResponse: { + id: 'call-a', + name: 'task_list', + response: { output: saveFailureShape }, + }, + }, + ]), + ], + new Map(), + ); + const output = finalized[0].responseParts[0].functionResponse?.response?.[ + 'output' + ] as string; + expect(output).toContain(BATCH_BUDGET_FIT_PREFIX); + expect(fittedDigest(output)).toBe(digest); + }); }); describe('degenerate batch-budget fits stay content-dependent (issue #9450)', () => { @@ -1071,5 +1110,40 @@ describe('tool response finalization', () => { ) as string; expect(fitOne).toBe(fitTwo); }); + + it('keeps every slot content-dependent when the budget is smaller than the slot count', () => { + // Budget 5 over 12 oversized slots: pre-fix the allocator gave 1 + // char to five slots and 0 to the rest, and fitText returns '' for + // a zero allocation — a content-independent constant, so every + // zero-allocation result fingerprinted identically no matter its + // content and a CHANGING board false-halted on the result-aware + // guards. Every active slot must keep >= 1 char even when that + // overshoots a sub-slot-count budget. + const boards = Array.from( + { length: 12 }, + (_, index) => `board variant ${index} — distinct`, + ); + const entries = boards.map((board, index) => + oversizedEntry(`call-${index}`, board), + ); + const fitted = enforceFunctionResponseBudget(entries, 5).map( + (fittedEntry) => + fittedEntry.responseParts[0].functionResponse?.response?.[ + 'output' + ] as string, + ); + expect(fitted.every((text) => text.length >= 1)).toBe(true); + // Content-dependent from the first char: each fit is the first char + // of its own board's full-output digest (1-char allocations cannot + // carry more), never the shared '' constant. + fitted.forEach((text, index) => { + expect(text).toBe( + createHash('sha256') + .update(`${boards[index]}\n${'board line\n'.repeat(300)}`) + .digest('hex') + .slice(0, 1), + ); + }); + }); }); }); diff --git a/packages/core/src/utils/tool-response-finalizer.ts b/packages/core/src/utils/tool-response-finalizer.ts index 0b4438c81bf..611df322164 100644 --- a/packages/core/src/utils/tool-response-finalizer.ts +++ b/packages/core/src/utils/tool-response-finalizer.ts @@ -167,6 +167,19 @@ function allocateTextBudget(lengths: number[], budget: number): number[] { const share = Math.floor(remaining / active.length); const fixed = active.filter((index) => lengths[index] <= share); if (fixed.length === 0) { + if (share === 0) { + // Budget smaller than the active-slot count: a zero-char slot fits + // to '' regardless of content, and hashing that constant would + // fingerprint every over-budget result identically — a CHANGING + // board would false-halt on the result-aware guards (issue #9450). + // Keep every active slot at >= 1 char (digest chars, content- + // dependent from the first char) even when that overshoots a + // sub-slot-count budget by less than one char per slot. + for (const index of active) { + allocations[index] = 1; + } + break; + } for (const index of active) { allocations[index] = share; } diff --git a/packages/core/src/utils/truncation.ts b/packages/core/src/utils/truncation.ts index e874f3ffe17..7c90ae102c1 100644 --- a/packages/core/src/utils/truncation.ts +++ b/packages/core/src/utils/truncation.ts @@ -51,6 +51,16 @@ export const TRUNCATED_PART_MARKER = 'Truncated part of the output:\n'; */ export const FULL_OUTPUT_DIGEST_LABEL = 'Full output sha256: '; +/** + * Trailing note `truncateAndSaveToFile` appends on its save-failure + * fallback shape (digest label + head/tail payload, no spilled file). + * Exported so consumers that recognize stub shapes (the loop guards, the + * batch-budget finalizer's nesting gate) parse the producer's constant + * instead of a hand-mirrored literal that can drift. + */ +export const TRUNCATION_SAVE_FAILURE_NOTE = + '[Note: Could not save full output to file]'; + /** * Extracts the sha256 digest a stub producer embedded for the FULL * pre-truncation output, anchored to a producer line: the label must start @@ -87,20 +97,29 @@ export function extractAnchoredStubDigest(value: string): string | null { /** * Returns the embedded full-output digest when `text` itself is an * oversized-result stub produced by this module: a `` - * envelope, an unwrapped `Output too large (...)` stub, or a - * `truncateAndSaveToFile` wrapper. Returns null for any other text. Used by - * the batch-budget finalizer's fitText to make stub reduction idempotent - * across nesting: the scheduler persists oversized results BEFORE the batch - * budget runs, so a fit wrapping an already-persisted stub must carry the - * stub's inner digest into its header instead of hashing the stub envelope, - * which embeds a per-call unique `/.txt` path and - * would fingerprint every poll of an unchanged board uniquely (issue #9450). + * envelope, an unwrapped `Output too large (...)` stub, a + * `truncateAndSaveToFile` wrapper, or a digest-carrying shape that starts + * with the digest label itself — the save-failure fallback of + * `truncateAndSaveToFile` (label + head/tail payload + save-failure note) + * and the batch-budget finalizer's degenerate digest-line-only fits. + * Returns null for any other text (the anchored extraction below still + * requires a line-anchored full 64-hex digest, so text that merely starts + * with the label without one is not treated as a stub). Used by the + * batch-budget finalizer's fitText to make stub reduction idempotent across + * nesting: the scheduler persists oversized results BEFORE the batch budget + * runs, so a fit wrapping an already-persisted stub must carry the stub's + * inner digest into its header instead of hashing the stub envelope, which + * embeds a per-call unique `/.txt` path (or, for + * the label-starting shapes, drops the digest that is the only part of the + * shape that survives a further fit) and would fingerprint every poll of an + * unchanged board uniquely (issue #9450). */ export function extractPersistedStubDigest(text: string): string | null { const isProducerStub = text.startsWith(PERSISTED_OUTPUT_OPEN_TAG) || text.startsWith(OUTPUT_TOO_LARGE_PREFIX) || - text.startsWith(TOOL_OUTPUT_TRUNCATED_PREFIX); + text.startsWith(TOOL_OUTPUT_TRUNCATED_PREFIX) || + text.startsWith(FULL_OUTPUT_DIGEST_LABEL); if (!isProducerStub) return null; return extractAnchoredStubDigest(text); } @@ -298,7 +317,7 @@ ${TRUNCATED_PART_MARKER}${truncatedContent}`; // Keep the digest even on the unsaved path: the fingerprinting // consumers must not regress to the head+tail-only payload here. return { - content: `${FULL_OUTPUT_DIGEST_LABEL}${fullDigest}\n${truncatedContent}\n[Note: Could not save full output to file]`, + content: `${FULL_OUTPUT_DIGEST_LABEL}${fullDigest}\n${truncatedContent}\n${TRUNCATION_SAVE_FAILURE_NOTE}`, }; } } From 88edb228bbebb7c71372470b029c01e2980f19f7 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Mon, 24 Aug 2026 19:17:07 +0800 Subject: [PATCH 37/51] fix(cli): unwind the loop-guard reservations for plan-mode sibling skips (#9450) --- packages/cli/src/nonInteractiveCli.test.ts | 16 ++++++++++++++++ packages/cli/src/nonInteractiveCli.ts | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 033c9c97b28..c4a8e381582 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -2816,6 +2816,22 @@ describe('runNonInteractive', () => { .filter((metadata) => metadata.callId !== 'enter-plan') .map((metadata) => metadata.executionStatus), ).toEqual(['not_started', 'not_started', 'not_started']); + // The skipped siblings are marked executed (they get a fabricated + // response in the next turn), but they never ran: the loop guards' + // request-side reservations must unwind so the constant fabricated + // error fingerprint is never recorded as result evidence (issue + // #9450 — the daemon excludes this class via its not_started + // filter; the CLI has to unwind it explicitly). + expect(mockNoteSuppressedToolCallByCallId).toHaveBeenCalledTimes(3); + expect(mockNoteSuppressedToolCallByCallId).toHaveBeenCalledWith( + 'write-before-entry', + ); + expect(mockNoteSuppressedToolCallByCallId).toHaveBeenCalledWith( + 'read-after-entry-1', + ); + expect(mockNoteSuppressedToolCallByCallId).toHaveBeenCalledWith( + 'read-after-entry-2', + ); }); it('runs a batch of concurrency-safe tool calls concurrently', async () => { diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 20a7ddd7330..b094a63f302 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -2019,6 +2019,22 @@ export async function runNonInteractive( const finalizePlanModeEntrySiblingSkip = ( requestInfo: ToolCallRequestInfo, ): void => { + // Never executed: the fabricated skip response below carries no + // result evidence, so unwind the request-side reservations the + // loop guards made when the call streamed in — the same unwind + // the never-executed skipped-output synthesis runs below. The + // fabricated error is not of the duplicate-provider synthetic + // class (client.ts's recording feed excludes ONLY that class), + // and this path marks the request executed, so without the + // unwind the feed pairs the constant fabricated fingerprint + // with the streamed request and records it as a "changed" + // result every occurrence — resetting the frozen-board streaks + // and disarming the result-aware halts (issue #9450). Mirrors + // the daemon twin, whose not_started filter excludes exactly + // this class from its result recording. + geminiClient + .getLoopDetectionService() + .noteSuppressedToolCallByCallId(requestInfo.callId); const error = new Error(PLAN_MODE_ENTRY_SIBLING_SKIP_MESSAGE); const responseParts: Part[] = [ { From 3ca2fdd9ea264c09e3618c817aba9f65c65d0b8c Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Mon, 24 Aug 2026 19:17:27 +0800 Subject: [PATCH 38/51] fix(cli): protect the suppressed replay key across the next daemon batch boundary (#9450) --- .../acp-integration/session/Session.test.ts | 101 ++++++++++++++++++ .../src/acp-integration/session/Session.ts | 24 ++++- 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index ec14d90feab..4ea24e9b245 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -11589,6 +11589,107 @@ describe('Session', () => { expect(loopState.totalToolCalls).toBe(21); }); + it('still halts a frozen daemon poller when a MIXED replay batch is followed by a gap batch (issue #9450)', async () => { + // Mixed replay batch, then a GAP batch (other work, no + // task_list), then the next poll — the shape the consecutive + // mixed-batch control above cannot see. Pre-fix the suppression + // mark was added during batch construction and consumed by the + // replay batch's OWN boundary decay (which the previous poll's + // result mark already protected), leaving the NEXT boundary — + // the gap batch's — exposed: decayAbandonedDaemonStreaks wiped + // the live frozen-board streak there, one boundary earlier than + // core's twin, whose noteSuppressedToolCallByCallId mark lands + // with the fabricated response AFTER the replay round's + // Finished boundary. The streak restarted every cycle and never + // reached the stuck threshold, so the turn ran past the soft + // cap toward the hard backstop instead of halting (issue #9450 + // requirement #6). + mockConfig.getApprovalMode = vi + .fn() + .mockReturnValue(ApprovalMode.YOLO); + mockConfig.getMaxToolCallsPerTurn = vi.fn().mockReturnValue(20); + mockConfig.isMaxToolCallsPerTurnExplicit = vi + .fn() + .mockReturnValue(false); + mockConfig.getSkipLoopDetection = vi.fn().mockReturnValue(true); + installTaskListAndGenericTools(() => 'frozen board'); + const fingerprint = core.getToolCallFingerprint( + 'task_list', + TASK_LIST_ARGS, + ); + vi.mocked(mockChat.getHistoryToolCallFingerprints).mockReturnValue( + new Map( + Array.from({ length: 25 }, (_, index) => [ + `replayed_task_list_${index}`, + fingerprint, + ]), + ), + ); + const loopState = freshLoopState(); + + let replayOrdinal = 0; + const runRound = (round: number) => { + const calls = + round % 3 === 0 + ? [ + { + id: `task_list_${round}`, + name: 'task_list', + args: TASK_LIST_ARGS, + }, + ] + : round % 3 === 1 + ? [ + { + id: `replayed_task_list_${replayOrdinal++}`, + name: 'task_list', + args: TASK_LIST_ARGS, + }, + { + id: `generic_${round}`, + name: 'generic_tool', + args: { step: round }, + }, + ] + : [ + { + id: `generic_${round}`, + name: 'generic_tool', + args: { step: round }, + }, + ]; + return ( + session as unknown as { + runToolCalls: ( + abortSignal: AbortSignal, + promptId: string, + calls: unknown[], + loopState: ReturnType, + ) => Promise<{ loopDetected?: boolean; parts: Part[] }>; + } + ).runToolCalls( + new AbortController().signal, + `prompt-mixed-gap-${round}`, + calls, + loopState, + ); + }; + + let fired = false; + for (let round = 0; round < 60 && !fired; round++) { + const result = await runRound(round); + fired = result.loopDetected ?? false; + } + + // The emit-phase mark gives the replayed key next-boundary + // protection, so the streak survives the mixed→gap cycles and + // arms the stuck signal: the halt lands at totalToolCalls 21 + // (soft cap 20 + 1), far below the hard backstop (200). + expect(fired).toBe(true); + expect(loopState.loopType).toBe(core.LoopType.TURN_TOOL_CALL_CAP); + expect(loopState.totalToolCalls).toBe(21); + }); + it('still halts a frozen daemon poller interleaved every other batch with other work (issue #9450)', async () => { // CLI defaults: skipLoopDetection=true, adaptive soft cap — the // cap's stateful stuck signal is the ONLY live halt path. A diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 1423bce5c4b..37483a3bb4e 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -9663,7 +9663,13 @@ export class Session implements SessionContext { // calls only), and decayAbandonedDaemonStreaks wiped the live // frozen-board streak — keeping statefulMaxResultRepeat below the // stuck threshold indefinitely and drifting from core (issue #9450 - // requirement #6). + // requirement #6). This mark protects the replay batch's OWN + // boundary (recordDaemonToolCalls runs after batch construction and + // consumes it there); emitDuplicateBatch re-adds the key during the + // execution phase for the NEXT boundary, mirroring core's timing — + // core's mark lands when the fabricated response is submitted with + // the next round's ToolResult, after the replay stream's Finished + // boundary (issue #9450 requirement #6). if (toolLoopState && isStatefulReadTool(request.name)) { (toolLoopState.statefulResultKeysSinceLastBatch ??= new Set()).add( @@ -9681,6 +9687,22 @@ export class Session implements SessionContext { const emitDuplicateBatch = async (batch: DuplicateBatch): Promise => { const { request, response } = batch; + // Next-boundary protection for the suppressed stateful replay: the + // mark pushDuplicateBatch added was consumed by THIS batch's own + // boundary decay (recordDaemonToolCalls ran after construction), so + // without a fresh mark a gap batch following a mixed replay batch + // would find the key in neither skip set and decay the live + // frozen-board streak — one boundary earlier than core's twin, whose + // suppression mark lands with the fabricated response AFTER the + // replay round's Finished boundary. Runs in the execution phase (the + // boundary has already run), so the mark survives to the next + // batch's decay (issue #9450 requirement #6). + if (toolLoopState && isStatefulReadTool(request.name)) { + (toolLoopState.statefulResultKeysSinceLastBatch ??= + new Set()).add( + getToolCallRepeatKey(request.name, request.args), + ); + } try { if (request.name === ToolNames.TODO_WRITE) { const provenance = ToolCallEmitter.resolveToolProvenance( From 429881f6e14b4d4b5ae9ac259f9f36f41d9f8388 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Mon, 24 Aug 2026 19:17:39 +0800 Subject: [PATCH 39/51] fix(core): keep the loop attribution on the final agent task card (#9450) --- .../core/src/agents/runtime/agent-headless.ts | 11 ++++++ packages/core/src/tools/agent/agent.test.ts | 37 +++++++++++++++++++ packages/core/src/tools/agent/agent.ts | 10 ++++- 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/core/src/agents/runtime/agent-headless.ts b/packages/core/src/agents/runtime/agent-headless.ts index 1f675520495..0f97cf87305 100644 --- a/packages/core/src/agents/runtime/agent-headless.ts +++ b/packages/core/src/agents/runtime/agent-headless.ts @@ -483,6 +483,17 @@ export class AgentHeadless { return this.terminateMode; } + /** + * Which loop detector fired when terminateMode is LOOP_DETECTED (issue + * #9450), or null otherwise. Lets consumers that read the terminal state + * AFTER execute() returns (the agent tool's post-await display update) + * attribute the stop the same way the FINISH event handler does, since + * the event alone is overwritten by that update. + */ + getLoopType(): string | null { + return this.loopType; + } + /** * Sets a callback that the reasoning loop calls between tool rounds * to drain external messages (e.g. from SendMessage tool). diff --git a/packages/core/src/tools/agent/agent.test.ts b/packages/core/src/tools/agent/agent.test.ts index 08f4fa38823..3b13be4f10a 100644 --- a/packages/core/src/tools/agent/agent.test.ts +++ b/packages/core/src/tools/agent/agent.test.ts @@ -2183,6 +2183,7 @@ describe('AgentTool', () => { failedToolCalls: 0, }), getTerminateMode: vi.fn().mockReturnValue(AgentTerminateMode.GOAL), + getLoopType: vi.fn().mockReturnValue(null), } as unknown as AgentHeadless; mockContextState = { @@ -2238,6 +2239,37 @@ describe('AgentTool', () => { expect(display.subagentName).toBe('file-search'); }); + it('keeps the loop attribution on the final task card when the subagent stops on a loop (issue #9450)', async () => { + // The FINISH handler augments terminateReason with the loop type, + // but the post-await display update merge-overwrites currentDisplay + // and execute() returns that display as the committed card — so the + // update must re-apply the augmentation or the card collapses back + // to bare LOOP_DETECTED and the failed task is unattributable. + vi.mocked(mockAgent.getTerminateMode).mockReturnValue( + AgentTerminateMode.LOOP_DETECTED, + ); + vi.mocked(mockAgent.getLoopType).mockReturnValue('turn_tool_call_cap'); + + const params: AgentParams = { + description: 'Search files', + prompt: 'Find all TypeScript files', + subagent_type: 'file-search', + run_in_background: false, + }; + + const invocation = ( + agentTool as AgentToolWithProtectedMethods + ).createInvocation(params); + const result = await invocation.execute(); + + const display = result.returnDisplay as AgentResultDisplay; + expect(display.type).toBe('task_execution'); + expect(display.status).toBe('failed'); + expect(display.terminateReason).toBe( + `${AgentTerminateMode.LOOP_DETECTED} (turn_tool_call_cap)`, + ); + }); + it('rejects working_dir when the resolved subagent config runs in the background', async () => { // The explicit run_in_background param is caught in validateToolParams; // this covers the other route into the background — a subagent config @@ -3720,6 +3752,7 @@ describe('AgentTool', () => { failedToolCalls: 0, }), getTerminateMode: vi.fn().mockReturnValue(AgentTerminateMode.GOAL), + getLoopType: vi.fn().mockReturnValue(null), } as unknown as AgentHeadless; mockContextState = { @@ -4984,6 +5017,7 @@ describe('AgentTool', () => { failedToolCalls: 0, }), getTerminateMode: vi.fn().mockReturnValue(AgentTerminateMode.GOAL), + getLoopType: vi.fn().mockReturnValue(null), } as unknown as AgentHeadless; mockContextState = { @@ -5180,6 +5214,7 @@ describe('AgentTool', () => { failedToolCalls: 0, }), getTerminateMode: vi.fn().mockReturnValue(AgentTerminateMode.GOAL), + getLoopType: vi.fn().mockReturnValue(null), } as unknown as AgentHeadless; mockContextState = { @@ -5531,6 +5566,7 @@ describe('AgentTool', () => { failedToolCalls: 0, }), getTerminateMode: vi.fn().mockReturnValue(AgentTerminateMode.GOAL), + getLoopType: vi.fn().mockReturnValue(null), } as unknown as AgentHeadless; vi.mocked(mockAgent.execute).mockImplementation(async () => { @@ -5914,6 +5950,7 @@ describe('AgentTool', () => { executeExternalInputs: vi.fn().mockResolvedValue(undefined), getFinalText: vi.fn().mockReturnValue('Monitor done'), getTerminateMode: vi.fn().mockReturnValue(AgentTerminateMode.GOAL), + getLoopType: vi.fn().mockReturnValue(null), getExecutionSummary: vi.fn().mockReturnValue({}), // Background spawn subscribes to the core's event emitter to // populate the entry's recentActivities buffer. Return a stub diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index b440e775a77..6fe74c8bc58 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -2197,6 +2197,12 @@ class AgentToolInvocation extends BaseToolInvocation { // Get the results const subagentRawText = subagent.getFinalText(); const terminateMode = subagent.getTerminateMode(); + // Which loop detector fired when the subagent stopped on a loop + // (issue #9450). The FINISH event handler augmented terminateReason + // with it, but the display update below merge-overwrites that — + // re-apply the same augmentation here or the committed task card + // (execute() returns this.currentDisplay) shows bare LOOP_DETECTED. + const loopType = subagent.getLoopType(); const finalText = appendStopHookBlockingCapWarning( toModelVisibleSubagentResult(subagentRawText, terminateMode), stopHookWarning, @@ -2236,7 +2242,9 @@ class AgentToolInvocation extends BaseToolInvocation { this.updateDisplay( { status: success ? 'completed' : 'failed', - terminateReason: terminateMode, + terminateReason: loopType + ? `${terminateMode} (${loopType})` + : terminateMode, result: finalText, executionSummary, }, From 342ea34abd54e0cda3451a99d89f91c16a306695 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Tue, 25 Aug 2026 05:58:47 +0800 Subject: [PATCH 40/51] fix(core): return the full digest line from degenerate batch-budget fits (#9450) --- .../src/tools/tool-response-finalizer.test.ts | 72 +++++++++++++++---- .../core/src/tools/tool-response-finalizer.ts | 37 +++++----- 2 files changed, 77 insertions(+), 32 deletions(-) diff --git a/packages/core/src/tools/tool-response-finalizer.test.ts b/packages/core/src/tools/tool-response-finalizer.test.ts index c49024170d2..defae9f2e89 100644 --- a/packages/core/src/tools/tool-response-finalizer.test.ts +++ b/packages/core/src/tools/tool-response-finalizer.test.ts @@ -23,6 +23,7 @@ import { persistAndTruncateToolResult, TRUNCATION_SAVE_FAILURE_NOTE, } from './truncation.js'; +import { fingerprintToolResult } from '../services/loopDetectionService.js'; const debugLogger = vi.hoisted(() => ({ debug: vi.fn(), @@ -987,9 +988,10 @@ describe('tool response finalization', () => { const fitB = fittedOutput( enforceFunctionResponseBudget([oversizedEntry('b', boardB)], budget), ) as string; - expect(fitA.length).toBeLessThanOrEqual(budget); - expect(fitB.length).toBeLessThanOrEqual(budget); - // Content-dependent from the first char past the digest label. + // Degenerate fits return the FULL digest line even when it + // overshoots the allocation: only the exact full line reduces to + // the digest in the loop guards (bounded overshoot). + expect(fitA.length).toBe(FULL_OUTPUT_DIGEST_LABEL.length + 64); expect(fitA.startsWith(FULL_OUTPUT_DIGEST_LABEL)).toBe(true); expect(fitA).not.toBe(fitB); } @@ -1065,7 +1067,9 @@ describe('tool response finalization', () => { // only constant label text pre-fix (the digest starts AFTER the // label), so every oversized result fingerprinted identically no // matter its content — the degenerate band one notch below the band - // the digest-line slice covers (21..107). + // the digest-line slice covers (21..107). The band now returns the + // full digest line too, so every non-zero allocation stays + // content-dependent AND reduces to the digest in the loop guards. const boardA = '#1 [in_progress] @peer-a — ship it'; const boardB = '#2 [completed] @peer-b — totally different board'; @@ -1076,8 +1080,8 @@ describe('tool response finalization', () => { const fitB = fittedOutput( enforceFunctionResponseBudget([oversizedEntry('b', boardB)], budget), ) as string; - expect(fitA.length).toBeLessThanOrEqual(budget); - expect(fitA).not.toBe(''); + expect(fitA.length).toBe(FULL_OUTPUT_DIGEST_LABEL.length + 64); + expect(fitA.startsWith(FULL_OUTPUT_DIGEST_LABEL)).toBe(true); expect(fitA).not.toBe(fitB); } }); @@ -1133,17 +1137,59 @@ describe('tool response finalization', () => { ] as string, ); expect(fitted.every((text) => text.length >= 1)).toBe(true); - // Content-dependent from the first char: each fit is the first char - // of its own board's full-output digest (1-char allocations cannot - // carry more), never the shared '' constant. + // Content-dependent and guard-reducible from the smallest allocation: + // every degenerate fit is the FULL digest line of its own board's + // full-output digest (bounded overshoot), never the shared '' + // constant and never a digest fragment the loop guards cannot reduce. fitted.forEach((text, index) => { expect(text).toBe( - createHash('sha256') - .update(`${boards[index]}\n${'board line\n'.repeat(300)}`) - .digest('hex') - .slice(0, 1), + FULL_OUTPUT_DIGEST_LABEL + + createHash('sha256') + .update(`${boards[index]}\n${'board line\n'.repeat(300)}`) + .digest('hex'), ); }); }); + + it('collides with every other representation of identical content across allocations', () => { + // The digest carry-through exists so the same board fingerprints + // identically no matter which representation carries it. Pre-fix the + // sub-84-char allocations emitted a digest FRAGMENT the guards' + // stripPersistenceEnvelope cannot reduce, so a frozen board whose + // per-slot allocation varied with its siblings' lengths (75 vs 76 + // chars, or crossing the full-line boundary as batch composition + // changes) fingerprinted differently every poll despite byte-identical + // content. Every degenerate allocation must now reduce to the same + // guard fingerprint as the raw (under-budget) board text. + const board = '#4 [in_progress] @peer-d — frozen oversized board'; + const text = `${board}\n${'board line\n'.repeat(300)}`; + const guardFingerprint = (output: string) => + fingerprintToolResult([ + { + functionResponse: { + id: 'a', + name: 'task_list', + response: { output }, + }, + }, + ]); + const rawFingerprint = guardFingerprint(text); + expect(rawFingerprint).not.toBeNull(); + for (const budget of [1, 5, 12, 20, 21, 40, 75, 76, 83, 84, 90, 106]) { + const fit = fittedOutput( + enforceFunctionResponseBudget([oversizedEntry('a', board)], budget), + ) as string; + expect(guardFingerprint(fit)).toBe(rawFingerprint); + } + // Same content, two different allocations in the degenerate band: + // the fingerprints must collide (the pre-fix 75-vs-76 divergence). + const fit75 = fittedOutput( + enforceFunctionResponseBudget([oversizedEntry('a', board)], 75), + ) as string; + const fit76 = fittedOutput( + enforceFunctionResponseBudget([oversizedEntry('b', board)], 76), + ) as string; + expect(guardFingerprint(fit75)).toBe(guardFingerprint(fit76)); + }); }); }); diff --git a/packages/core/src/tools/tool-response-finalizer.ts b/packages/core/src/tools/tool-response-finalizer.ts index c77818dfc2b..327ec13bccd 100644 --- a/packages/core/src/tools/tool-response-finalizer.ts +++ b/packages/core/src/tools/tool-response-finalizer.ts @@ -279,28 +279,27 @@ function fitText( if (header.length >= maxChars) { // Degenerate allocation: the header does not fit whole. As long as the // allocation holds prefix + digest line, slicing the header keeps the - // full digest (content-dependent). Below that, slicing the header would - // return only constant text — the prefix plus a fragment of the digest - // LABEL, whose digest starts at offset - // BATCH_BUDGET_FIT_PREFIX.length + 1 + FULL_OUTPUT_DIGEST_LABEL.length — - // so every oversized result would fingerprint identically regardless of - // content and a CHANGING board would false-halt on - // consecutive_identical_tool_calls under a small configured - // toolOutputBatchBudget (issue #9450). Slice the digest line itself - // instead so any allocation reaching past the label carries - // content-dependent digest characters. The band at or below the label - // length is degenerate one notch further: slicing the digest line there - // yields only (a prefix of) the constant label itself — budget 240 over - // 12 oversized slots gives exactly FULL_OUTPUT_DIGEST_LABEL.length chars - // per slot — so carry the digest's own characters instead and every - // non-zero allocation stays content-dependent (issue #9450). + // full digest (content-dependent). Below that, return the FULL digest + // line even though it overshoots the allocation (bounded by the line's + // own FULL_OUTPUT_DIGEST_LABEL.length + 64 chars — the same deliberate + // overshoot spirit allocateTextBudget applies with its >= 1-char + // allocations). A slice of the digest line instead would be a digest + // FRAGMENT, and the loop guards' stripPersistenceEnvelope reduces only + // the exact full digest line (or the save-failure note) to its digest: + // a fragment fingerprints as ordinary content, so byte-identical + // content would fingerprint differently for every sub-line allocation + // (75 vs 76 chars, or crossing the line length as batch composition + // changes) and never collide with the raw / full-fit / spilled + // representations of the same board — consecutiveIdentical evidence + // could never accumulate for a frozen oversized board under a small + // configured toolOutputBatchBudget, disarming the cap's result-aware + // stuck signal exactly in the small-budget regime (issue #9450). The + // full line makes every degenerate fit reduce to the same digest as + // every other representation of the same content. if (maxChars >= minimalHeader.length) { return sliceStartWithoutBrokenSurrogate(header, maxChars); } - if (maxChars > FULL_OUTPUT_DIGEST_LABEL.length) { - return sliceStartWithoutBrokenSurrogate(digestLine, maxChars); - } - return sliceStartWithoutBrokenSurrogate(digest, maxChars); + return digestLine; } const separator = '\n\n'; From 7f49db3784e8346832b383345c833cf0e5db3ca7 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Tue, 25 Aug 2026 06:07:04 +0800 Subject: [PATCH 41/51] fix(core): carry stateful streak marks across replay-suppressed rounds (#9450) --- .../acp-integration/session/Session.test.ts | 106 ++++++++++++++++++ .../core/src/agents/runtime/agent-core.ts | 10 +- packages/core/src/core/client.ts | 7 +- .../src/services/loopDetectionService.test.ts | 66 +++++++++++ .../core/src/services/loopDetectionService.ts | 59 +++++++++- 5 files changed, 244 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 4ea24e9b245..3754d608c08 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -11690,6 +11690,112 @@ describe('Session', () => { expect(loopState.totalToolCalls).toBe(21); }); + it('still halts a frozen daemon poller when NON-STATEFUL replay-only rounds are interleaved (issue #9450 requirement #6)', async () => { + // Non-stateful twin of the replay-only regression: the replayed + // id belongs to a NON-stateful tool (generic_tool), so no + // suppression mark exists for it anywhere — the empty-batch + // early return in recordDaemonToolCalls is the ONLY mechanism + // carrying the last executed round's result marks across the + // replay batch to the gap batch's decay. task_list is the only + // stateful tool, so every other replayed tool takes this path; + // without the skip the daemon would wipe the frozen streak and + // run to the hard backstop while the exact same event sequence + // halts core just past the soft cap (requirement #6 parity — + // core's twin carries via noteSuppressedToolCallByCallId's + // replaySuppression mark). + mockConfig.getApprovalMode = vi + .fn() + .mockReturnValue(ApprovalMode.YOLO); + mockConfig.getMaxToolCallsPerTurn = vi.fn().mockReturnValue(20); + mockConfig.isMaxToolCallsPerTurnExplicit = vi + .fn() + .mockReturnValue(false); + mockConfig.getSkipLoopDetection = vi.fn().mockReturnValue(true); + installTaskListAndGenericTools(() => 'frozen board'); + const fingerprint = core.getToolCallFingerprint('generic_tool', { + step: 0, + }); + vi.mocked(mockChat.getHistoryToolCallFingerprints).mockReturnValue( + new Map( + Array.from({ length: 30 }, (_, index) => [ + `replayed_generic_${index}`, + fingerprint, + ]), + ), + ); + const loopState = freshLoopState(); + + let replayOrdinal = 0; + const runRound = (round: number) => { + const calls = + round % 3 === 0 + ? [ + { + id: `task_list_${round}`, + name: 'task_list', + args: TASK_LIST_ARGS, + }, + ] + : round % 3 === 1 + ? [ + { + id: `replayed_generic_${replayOrdinal++}`, + name: 'generic_tool', + args: { step: 0 }, + }, + ] + : [ + { + id: `generic_${round}`, + name: 'generic_tool', + args: { step: round }, + }, + ]; + return ( + session as unknown as { + runToolCalls: ( + abortSignal: AbortSignal, + promptId: string, + calls: unknown[], + loopState: ReturnType, + ) => Promise<{ loopDetected?: boolean; parts: Part[] }>; + } + ).runToolCalls( + new AbortController().signal, + `prompt-nonstateful-replay-${round}`, + calls, + loopState, + ); + }; + + let fired = false; + for (let round = 0; round < 90 && !fired; round++) { + const result = await runRound(round); + if (round % 3 === 1) { + // The replay-only batch is suppressed whole and executes + // nothing (its repeat keys never count toward the stuck + // signal — only executable calls do). + expect(result.loopDetected ?? false).toBe(false); + expect( + (result.parts[0]?.functionResponse?.response?.[ + 'error' + ] as string) ?? '', + ).toContain('Duplicate provider tool call id'); + continue; + } + fired = result.loopDetected ?? false; + } + + // The empty-batch decay skip carries the poll's result mark + // through the replay batch, so the streak survives the gap + // batch's boundary and arms the stuck signal: the halt lands at + // totalToolCalls 21 (soft cap 20 + 1; replay batches add 0), + // far below the hard backstop (200). + expect(fired).toBe(true); + expect(loopState.loopType).toBe(core.LoopType.TURN_TOOL_CALL_CAP); + expect(loopState.totalToolCalls).toBe(21); + }); + it('still halts a frozen daemon poller interleaved every other batch with other work (issue #9450)', async () => { // CLI defaults: skipLoopDetection=true, adaptive soft cap — the // cap's stateful stuck signal is the ONLY live halt path. A diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 747c41d36fa..878f1e29170 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -1827,9 +1827,15 @@ export class AgentCore { // evidence. Exclude it from the loop guards and unwind the // request-time reservations the replayed request made when // streamed — the daemon twin excludes this class via its - // providerDuplicate / not_started filter (issue #9450). + // providerDuplicate / not_started filter (issue #9450). The + // replaySuppression mark carries the live streak evidence across + // the next Finished boundary, mirroring the daemon's all-replay + // (empty) batch decay skip — a replay of a NON-stateful tool + // marks nothing on its own (issue #9450 requirement #6). neverExecutedCallIds.add(callId); - loopDetector?.noteSuppressedToolCallByCallId(callId); + loopDetector?.noteSuppressedToolCallByCallId(callId, { + replaySuppression: true, + }); continue; } recordHandledToolCall( diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index c4098b00387..7baa4604a88 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -3408,10 +3408,15 @@ export class GeminiClient { // pair the fabricated error with the replayed request and reset // the guards' streaks as a "changed" result, disarming every // result-aware halt — the daemon twin excludes this class via its - // providerDuplicate / not_started filter (issue #9450). + // providerDuplicate / not_started filter (issue #9450). The + // replaySuppression mark carries the live streak evidence across + // the next Finished boundary — the daemon twin skips decay for + // all-replay batches, and a replay of a NON-stateful tool marks + // nothing on its own (issue #9450 requirement #6). if (isDuplicateProviderToolCallResponse(part as Part)) { this.loopDetector.noteSuppressedToolCallByCallId( functionResponseId, + { replaySuppression: true }, ); continue; } diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index b32d740a795..40f0dae2c4b 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -3794,6 +3794,72 @@ describe('LoopDetectionService', () => { expect(capService.getLastLoopType()).toBeNull(); }); + it('still halts a frozen poller interleaved with non-stateful replay-only rounds (requirement #6 parity)', () => { + // Requirement-#6 parity with the daemon (issue #9450): poll a frozen + // task_list board → suppressed replay of an already-handled + // NON-stateful call id (the round executes nothing) → gap round, + // repeated. The daemon's batch recorder receives the all-replay batch + // as zero executable calls and skips its boundary decay entirely, so + // the last executed round's result marks survive and the daemon halts + // just past the soft cap. Pre-fix core consumed the poll's mark at the + // replay round's Finished boundary (noteSuppressedToolCallByCallId + // marks nothing for a non-stateful replay) and wiped the frozen streak + // at the next one, so the stuck signal never armed and the turn ran to + // the 10x hard backstop. The replaySuppression carry must keep the + // streak alive across the replay round's boundary. + const capService = new LoopDetectionService(makeConfig(20)); + capService.reset('replay-parity-non-stateful'); + const finishedEvent = { + type: GeminiEventType.Finished, + value: { reason: 'STOP' }, + } as unknown as ServerGeminiStreamEvent; + + let fired = false; + let totalCalls = 0; + for (let round = 0; round < 30 && !fired; round++) { + // Poll round: production ordering — the request streams, its + // Finished boundary runs, then the result is recorded with the next + // round's submission. + fired = capService.checkAlwaysOnSafeties(taskListEvent(`tl-${round}`)); + totalCalls++; + if (fired) break; + capService.checkAlwaysOnSafeties(finishedEvent); + fired = capService.recordToolResultByCallId( + `tl-${round}`, + taskListResult('frozen board', `tl-${round}`), + ); + if (fired) break; + // Replay-only round: a NON-stateful already-handled call id streams + // in and is suppressed without executing; the suppression is noted + // with the following round's submission (after the Finished + // boundary), exactly when client.ts's feed unwinds it. + // Varying args: the replay's own repeat key must not build the + // cap's stuck signal — the mechanism under test is the stateful + // streak carry, not the replay's request-time counting. + fired = capService.checkAlwaysOnSafeties( + createToolCallRequestEvent('read_file', { file_path: `/a${round}` }), + ); + totalCalls++; + if (fired) break; + capService.checkAlwaysOnSafeties(finishedEvent); + capService.noteSuppressedToolCallByCallId('test-id', { + replaySuppression: true, + }); + // Gap round (other productive work). + fired = capService.checkAlwaysOnSafeties( + createToolCallRequestEvent('tool_b', { step: round }), + ); + totalCalls++; + if (fired) break; + capService.checkAlwaysOnSafeties(finishedEvent); + } + expect(fired).toBe(true); + expect(capService.getLastLoopType()).toBe(LoopType.TURN_TOOL_CALL_CAP); + // Halts just past the soft cap of 20 — pre-fix the streak restarted + // every cycle and nothing fired within 90 calls (hard backstop 200). + expect(totalCalls).toBeLessThanOrEqual(30); + }); + it('does not halt a resumed task_list poller whose evidence decayed mid-streak (issue #9450)', () => { // Two consecutive tool-call-free round-trips mid-streak (reachable // via checkNextSpeaker "Please continue." hook turns or agent-core diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 4b23d2c3e51..3187f4cbc91 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -783,8 +783,43 @@ export class LoopDetectionService { * executable calls), so this keeps the runtimes aligned (issue #9450 * requirement #6). Unknown callIds (never streamed through the guards) * are ignored. + * + * `replaySuppression` marks the CROSS-ROUND REPLAY class (a suppressed + * re-emission of an already-handled provider call id — the duplicate + * synthetics), as opposed to the never-executed class (authorization + * rejections, scheduler not_started synthetics). Replay-only rounds must + * carry the live stateful streak marks across the next Finished boundary: + * the daemon twin's batch recorder receives an all-replay batch as zero + * executable calls and skips its boundary decay entirely + * (recordDaemonToolCalls), so the last executed round's result marks + * survive to the NEXT non-empty batch's decay. Without the carry, core + * consumes those marks at the replay round's own Finished boundary and + * wipes a live frozen-board streak at the next one when the replayed + * tool is NOT stateful (a non-stateful replay marks nothing here — and + * its callId never resolves in the pairing, which tracks only stateful + * requests), so a frozen task_list board polled around non-stateful + * replay rounds never accumulates its stuck signal in core while the + * daemon halts it just past the soft cap (requirement #6 parity). The + * carry re-adds the keys with live streak evidence for exactly one more + * boundary; the never-executed class must NOT carry: the daemon treats + * those calls as ordinary (non-empty) batches whose boundary decay runs. + * Suppression awareness, not activity awareness: post-abandonment rounds + * have no suppressions at all, so decay still releases abandoned peaks + * (the literal "skip decay on stateful-inactive rounds" formulation + * would latch the peak forever and break the abandonment release). */ - noteSuppressedToolCallByCallId(callId: string): void { + noteSuppressedToolCallByCallId( + callId: string, + options?: { replaySuppression?: boolean }, + ): void { + // Runs BEFORE the callId pairing lookup: the pairing only tracks + // STATEFUL requests (see checkAlwaysOnSafeties), but the carry is + // needed most when the suppressed replay is a NON-stateful tool — its + // callId never resolves here, yet its replay-only round must still + // protect the live frozen-board streaks (see the doc above). + if (options?.replaySuppression) { + this.carryStatefulStreakMarksAcrossSuppression(); + } const request = this.requestByCallId.get(callId); if (!request) return; this.requestByCallId.delete(callId); @@ -818,6 +853,28 @@ export class LoopDetectionService { this.statefulResultKeysSinceLastFinished.add(key); } + /** + * One extra Finished boundary of decay coverage for the live stateful + * streak marks, applied when a replay suppression lands (see + * noteSuppressedToolCallByCallId): re-adds every key that still carries + * streak evidence to statefulResultKeysSinceLastFinished so the next + * boundary's decay skips it, mirroring the daemon's empty-batch decay + * skip. Keys already marked are re-added idempotently; keys whose + * evidence already decayed stay gone (the carry never resurrects an + * abandoned streak — only postpones an imminent decay by one boundary). + */ + private carryStatefulStreakMarksAcrossSuppression(): void { + for (const [key, state] of this.statefulRepeatState) { + if ( + state.consecutiveIdenticalResults > 0 || + state.resultsObserved > 0 || + state.unchangedStreak > 0 + ) { + this.statefulResultKeysSinceLastFinished.add(key); + } + } + } + private isStatefulReadTool(toolName: string): boolean { return isStatefulReadTool(toolName); } From 372e4b7259d0b73fcad372e440a0e634b99f2a7f Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Tue, 25 Aug 2026 06:15:59 +0800 Subject: [PATCH 42/51] fix(core): keep rejected stateful calls counting toward the consecutive-identical guard (#9450) --- .../core/src/agents/runtime/agent-core.ts | 8 +- .../src/services/loopDetectionService.test.ts | 55 +++++++++ .../core/src/services/loopDetectionService.ts | 111 ++++++++++++++---- 3 files changed, 147 insertions(+), 27 deletions(-) diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 878f1e29170..50a49bf5f30 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -1771,7 +1771,13 @@ export class AgentCore { }); // Never executed: keep the synthetic error out of the loop guards' // result evidence and unwind the request-time reservations it made - // when streamed (issue #9450). + // when streamed (issue #9450). The request-side repetition + // increment is KEPT (see noteSuppressedToolCallByCallId): a + // subagent persistently re-emitting an unavailable task_list is a + // pure stream of rejected calls — no result ever lands to exonerate + // it, so the always-on consecutive-identical guard must still halt + // it on the 5th identical request instead of oscillating the count + // back down forever. neverExecutedCallIds.add(callId); loopDetector?.noteSuppressedToolCallByCallId(callId); continue; diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index 40f0dae2c4b..b89f1deb1a2 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -4023,6 +4023,61 @@ describe('LoopDetectionService', () => { expect(heuristicService.getLastLoopType()).toBeNull(); }); + it('halts a pure stream of rejected identical task_list calls on the 5th request (issue #9450)', () => { + // A subagent whose model persistently re-emits an unavailable + // task_list (undeclared for the subagent, or allowlisted out): every + // identical request streams through the guard and is rejected without + // executing. Pre-fix noteSuppressedToolCallByCallId unwound the + // consecutive-identical increment right back (the count oscillated + // 0↔1 — the threshold 5 unreachable), a rejected call never records + // a result (the missing-evidence fail-safe unreachable), and the + // cap's stuck signal never arms (trackCapKeyRepeat skips stateful + // tools while statefulCapKeyRepeat feeds only on recorded results) — + // the stream looped to the hard backstop instead of halting on the + // 5th identical request like the pre-PR wiring. + let fired = false; + let firedAt = -1; + for (let i = 0; i < 12 && !fired; i++) { + fired = service.checkAlwaysOnSafeties(taskListEvent(`rej-${i}`)); + if (fired) { + firedAt = i + 1; + break; + } + // The rejection lands before the next request streams (agent-core + // rejects during batch filtering, ahead of execution). + service.noteSuppressedToolCallByCallId(`rej-${i}`); + } + expect(fired).toBe(true); + expect(firedAt).toBe(TOOL_CALL_LOOP_THRESHOLD); + expect(service.getLastLoopType()).toBe( + LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, + ); + }); + + it('does not halt a changing-board stream mixing rejected and executed identical calls (issue #9450)', () => { + // Rejected calls keep their request-side increments (the pure stream + // above must still halt), so the exoneration gate must subtract the + // streak's suppressedRequests from the expected result count — or a + // MIXED stream of rejected + executed calls whose executed results + // keep changing would be permanently one result short and false-halt + // on arguments alone (the #9450 false positive re-entering via the + // rejection branch). + let fired = false; + for (let i = 0; i < 8 && !fired; i++) { + fired = service.checkAlwaysOnSafeties(taskListEvent(`rej-${i}`)); + if (fired) break; + service.noteSuppressedToolCallByCallId(`rej-${i}`); + fired = service.checkAlwaysOnSafeties(taskListEvent(`exec-${i}`)); + if (fired) break; + fired = service.recordToolResultByCallId( + `exec-${i}`, + taskListResult(`board state v${i}`, `exec-${i}`), + ); + } + expect(fired).toBe(false); + expect(service.getLastLoopType()).toBeNull(); + }); + it('halts an interleaved frozen poller whose gap rounds previously decayed the streak', () => { // Production ordering: requests → Finished → results. A frozen board // polled every OTHER round between varied work: pre-fix the poll diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 3187f4cbc91..807076310fd 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -498,6 +498,18 @@ export class LoopDetectionService { // streak (restarted when the streak breaks); `lastFingerprint` survives // streak breaks so a state change is still visible across interleaved // calls (used by the action-stagnation reset). + // `suppressedRequests` counts the suppressed calls (replays, rejections — + // see noteSuppressedToolCallByCallId) within the CURRENT streak. A + // suppressed call can never produce an exonerating result, so the + // consecutive-identical gate's expected-result computation subtracts it: + // without that, the request-side count of a mixed stream (suppressed + + // executed calls) would permanently outrun the recorded results and the + // exoneration gate would be unsatisfiable. The suppressed call's + // consecutive-count increment is KEPT (not unwound), so a pure stream of + // identical suppressed calls still reaches the threshold — its entry + // carries zero result evidence, the expected-result count reduces to + // zero, and the gate halts (the fail-safe shape). That is what catches a + // subagent persistently re-emitting an unavailable task_list. // `consecutiveIdenticalResults` is the stuck-repetition evidence for the // global-duplicate detector and the adaptive cap (replacing the // request-time global-duplicate counting and the cap's stuck-repetition @@ -514,6 +526,7 @@ export class LoopDetectionService { resultsObserved: number; unchangedStreak: number; consecutiveIdenticalResults: number; + suppressedRequests: number; lastFingerprint: string | undefined; } >(); @@ -670,6 +683,7 @@ export class LoopDetectionService { resultsObserved: 0, unchangedStreak: 0, consecutiveIdenticalResults: 0, + suppressedRequests: 0, lastFingerprint: undefined, }; this.statefulRepeatState.set(key, state); @@ -772,17 +786,22 @@ export class LoopDetectionService { * for batches that execute nothing instead — recordDaemonToolCalls in the * ACP Session). Without this, a replay-suppressed round is * indistinguishable from abandonment and disarms the result-aware halts. - * The request-side repetition evidence the guards accumulated when the - * call streamed in must unwind too: no result will ever land for a - * suppressed call, so keeping its increment would leave the result-aware - * carve-outs permanently one result short of the request count (the - * exoneration gate `resultsObserved >= count - 1` becomes unreachable) - * and halt a changing-board poller on arguments alone — the #9450 false - * positive re-entering via provider re-emission mid-streak. The daemon - * twin never counts suppressed calls (its batch recorder receives only - * executable calls), so this keeps the runtimes aligned (issue #9450 - * requirement #6). Unknown callIds (never streamed through the guards) - * are ignored. + * The request-side repetition increment the suppressed call made when it + * streamed in is KEPT (not unwound): a persistent stream of identical + * suppressed calls — a subagent re-emitting an unavailable task_list, a + * provider re-emitting the same handled id — is exactly the stuck + * pattern the always-on consecutive-identical guard exists to stop, and + * no result will ever land for it. Unwinding the increment let such a + * stream oscillate the count 0↔1 forever: the threshold unreachable, the + * missing-evidence fail-safe unreachable (no result ever records, so no + * state entry exists), and the cap's stuck signal unreachable + * (trackCapKeyRepeat skips stateful tools) — the loop ran to the hard + * backstop instead of halting on the 5th identical request (issue #9450). + * The exoneration gate stays satisfiable for MIXED streams (suppressed + + * executed calls with changing results) because checkToolCallLoop + * subtracts the streak's suppressedRequests from the expected result + * count. Unknown callIds (never streamed through the guards) are + * ignored. * * `replaySuppression` marks the CROSS-ROUND REPLAY class (a suppressed * re-emission of an already-handled provider call id — the duplicate @@ -832,12 +851,30 @@ export class LoopDetectionService { if (inFlight > 0) { this.statefulInFlight.set(key, inFlight - 1); } - // Unwind the consecutive-identical increment, but only while the streak - // still belongs to the suppressed key: a later different call restarts - // the count for its own key, and decrementing then would corrupt an - // unrelated streak. Floored at zero (a Retry may have reset it since). - if (this.lastToolCallKey === key && this.toolCallRepetitionCount > 0) { - this.toolCallRepetitionCount--; + // The consecutive-identical increment the suppressed call made when it + // streamed in is KEPT (see the doc above): count it into the streak's + // suppressedRequests instead so the exoneration gate can subtract it + // while the threshold still sees the request-side evidence. Only while + // the streak still belongs to the suppressed key: a later different + // call restarts the count for its own key. The entry is created + // lazily here (no recorded result yet) so suppressions landing BEFORE + // the streak's first result still balance the gate — for a PURE + // suppressed stream the entry then carries zero result evidence, the + // expected-result count reduces to zero, and the gate halts exactly + // like the missing-evidence fail-safe it replaces. + if (this.lastToolCallKey === key) { + let state = this.statefulRepeatState.get(key); + if (!state) { + state = { + resultsObserved: 0, + unchangedStreak: 0, + consecutiveIdenticalResults: 0, + suppressedRequests: 0, + lastFingerprint: undefined, + }; + this.statefulRepeatState.set(key, state); + } + state.suppressedRequests++; } // Drop the window occurrence the alternating-pattern tier pushed when // the call streamed in; it carries no result, and leaving it in would @@ -1068,6 +1105,7 @@ export class LoopDetectionService { state.resultsObserved = 0; state.unchangedStreak = 0; state.consecutiveIdenticalResults = 0; + state.suppressedRequests = 0; } this.statefulResultKeysSinceLastFinished.clear(); this.statefulRequestedKeysSinceLastFinished.clear(); @@ -1175,6 +1213,7 @@ export class LoopDetectionService { if (state) { state.resultsObserved = 0; state.unchangedStreak = 0; + state.suppressedRequests = 0; } } this.lastToolCallKey = key; @@ -1193,17 +1232,28 @@ export class LoopDetectionService { // requests cannot have recorded results yet. Subtract them from the // expected count (floored at the recorded evidence) so the gate is // judged on the results that CAN have landed; a changed recorded - // result still restarts the streak. Missing result evidence (results - // never recorded for this streak) fails safe and keeps the pre-#9450 - // behavior, so the DashScope protection (#5019) is never loosened by - // a wiring gap. + // result still restarts the streak. Suppressed calls in the streak + // (replays, rejections) are subtracted too: they can never produce + // an exonerating result, and leaving their request-side increments + // in the expected count would keep the gate permanently one result + // short for mixed suppressed + executed streams. Missing result + // evidence (results never recorded for this streak) fails safe and + // keeps the pre-#9450 behavior, so the DashScope protection (#5019) + // is never loosened by a wiring gap — that fail-safe is also what + // halts a pure stream of rejected/suppressed identical calls (no + // state entry ever exists for it), e.g. a subagent persistently + // re-emitting an unavailable task_list. const state = this.statefulRepeatState.get(key); const inFlight = Math.min( this.statefulInFlight.get(key) ?? 0, this.toolCallRepetitionCount, ); + const suppressedInStreak = Math.min( + state?.suppressedRequests ?? 0, + this.toolCallRepetitionCount, + ); const expectedResults = Math.max( - this.toolCallRepetitionCount - inFlight, + this.toolCallRepetitionCount - inFlight - suppressedInStreak, state?.resultsObserved ?? 0, ); if (state && state.resultsObserved >= expectedResults) { @@ -1211,6 +1261,7 @@ export class LoopDetectionService { this.toolCallRepetitionCount = 1; state.resultsObserved = 0; state.unchangedStreak = 0; + state.suppressedRequests = 0; return false; } } @@ -1956,15 +2007,23 @@ export class LoopDetectionService { for (const [key, state] of this.statefulRepeatState) { if (this.statefulResultKeysSinceLastFinished.has(key)) continue; if (this.statefulRequestedKeysSinceLastFinished.has(key)) continue; - if ( + const hadResultEvidence = state.consecutiveIdenticalResults > 0 || state.resultsObserved > 0 || - state.unchangedStreak > 0 - ) { + state.unchangedStreak > 0; + if (hadResultEvidence || state.suppressedRequests > 0) { state.consecutiveIdenticalResults = 0; state.resultsObserved = 0; state.unchangedStreak = 0; - if (this.lastToolCallKey === key) { + state.suppressedRequests = 0; + if (hadResultEvidence && this.lastToolCallKey === key) { + // Dropping the exoneration gate's result evidence while the + // consecutive count stands would leave the gate permanently + // unsatisfiable, so drop the streak with it. A + // suppressedRequests-only entry keeps its count: the gate stays + // satisfiable for it (no result evidence to expect), so the + // threshold still halts a stream of identical suppressed calls + // crossing round-trip boundaries. this.lastToolCallKey = null; this.toolCallRepetitionCount = 0; } From 27f12a2515e137076896b96f3c3a283a07c850a7 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Tue, 25 Aug 2026 06:20:10 +0800 Subject: [PATCH 43/51] fix(cli): unwind never-executed not_started synthetics before the interactive feed (#9450) --- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 112 ++++++++++++++++++ packages/cli/src/ui/hooks/useGeminiStream.ts | 20 ++++ 2 files changed, 132 insertions(+) diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index a62e0b0ad80..1adc04ee147 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -1659,6 +1659,118 @@ describe('useGeminiStream', () => { ); }); + it('unwinds never-executed not_started scheduler responses before submitting them (issue #9450)', async () => { + // A scheduler response with executionStatus 'not_started' — the + // plan-mode-entry sibling skip, pre-validation cancellations, + // permission / tool-not-found / validation rejections — never executed, + // so its fabricated constant error carries no result evidence. The hook + // must unwind the loop guards' request-side reservations before + // submission: client.ts's recording feed excludes only the + // duplicate-provider synthetic class, so without the unwind the + // fabricated error pairs with the streamed request in requestByCallId + // and records as a "changed" result — resetting frozen-board streaks + // and disarming every result-aware halt (the daemon twin excludes this + // class via its not_started filter). + const noteSuppressedToolCallByCallId = vi.fn(); + const client = new MockedGeminiClientClass(mockConfig); + client.getLoopDetectionService = vi.fn().mockReturnValue({ + noteSuppressedToolCallByCallId, + }); + + const completedToolCalls: TrackedToolCall[] = [ + { + request: { + callId: 'skipped-sibling', + name: 'task_list', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-not-started', + }, + status: 'error', + responseSubmittedToGemini: false, + response: { + callId: 'skipped-sibling', + responseParts: [ + { + functionResponse: { + id: 'skipped-sibling', + name: 'task_list', + response: { error: 'plan mode entry sibling skip' }, + }, + }, + ], + errorType: ToolErrorType.EXECUTION_DENIED, + executionStatus: 'not_started', + }, + } as unknown as TrackedCompletedToolCall, + { + request: { + callId: 'executed-tool', + name: 'shell', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-not-started', + }, + status: 'success', + responseSubmittedToGemini: false, + response: { + callId: 'executed-tool', + responseParts: [{ text: 'executed output' }], + errorType: undefined, + executionStatus: 'success', + }, + } as unknown as TrackedCompletedToolCall, + ]; + + let capturedOnComplete: + | ((completedTools: TrackedToolCall[]) => Promise) + | null = null; + + mockUseReactToolScheduler.mockImplementation((onComplete) => { + capturedOnComplete = onComplete; + return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted]; + }); + + renderHook(() => + useGeminiStream( + client, + [], + mockAddItem, + mockConfig, + true, + mockLoadedSettings, + mockOnDebugMessage, + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + () => {}, + () => Promise.resolve(), + false, + () => {}, + () => {}, + () => {}, + () => {}, + 80, + 24, + ), + ); + + await act(async () => { + if (capturedOnComplete) { + await capturedOnComplete(completedToolCalls); + } + }); + + // Only the never-executed synthetic is unwound; the executed response + // keeps its result evidence. + await waitFor(() => { + expect(noteSuppressedToolCallByCallId).toHaveBeenCalledTimes(1); + }); + expect(noteSuppressedToolCallByCallId).toHaveBeenCalledWith( + 'skipped-sibling', + ); + }); + it('stamps the committed tool_group with the batch id minted at schedule time (#9420)', async () => { const makeCompletedTool = (callId: string): TrackedCompletedToolCall => ({ diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 0b0cc0454ad..070b940729d 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -4750,6 +4750,26 @@ export const useGeminiStream = ( toolCall.request.name, toolCall.request.args as Record, ); + // Never-executed scheduler synthetics — the plan-mode-entry sibling + // skip, pre-validation cancellations, and permission / + // tool-not-found / validation rejections (executionStatus + // 'not_started') — carry no result evidence. Unwind the + // request-side reservations the loop guards made when the call + // streamed in BEFORE submission: client.ts's recording feed + // excludes only the duplicate-provider synthetic class + // (isDuplicateProviderToolCallResponse), so the fabricated constant + // error would otherwise pair with the streamed request in + // requestByCallId and record as a "changed" result — resetting the + // frozen-board streaks and disarming every result-aware halt for a + // genuinely stuck task_list poller (issue #9450). Mirrors the + // daemon twin, whose result-recording filter excludes exactly the + // not_started class (Session.queueToolResultRecord), and the + // non-interactive runner's never-executed unwinds. + if (toolCall.response.executionStatus === 'not_started') { + geminiClient + ?.getLoopDetectionService() + ?.noteSuppressedToolCallByCallId(toolCall.request.callId); + } } if (geminiTools.length === 0 && pendingDuplicateResponses.length === 0) { From 9ee830038ca6e07527a32f4fb15658846212afc9 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Tue, 25 Aug 2026 11:38:32 +0800 Subject: [PATCH 44/51] fix(core): clear stateful loop-guard trackers on model fallback (#9450) --- .../src/services/loopDetectionService.test.ts | 99 +++++++++++++++++++ .../core/src/services/loopDetectionService.ts | 50 ++++++++++ 2 files changed, 149 insertions(+) diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index b89f1deb1a2..7cd1db8cd22 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -4238,6 +4238,105 @@ describe('LoopDetectionService', () => { ); }); + it('does not halt a fallback ABAB poller whose predecessor died mid-batch (issue #9450)', () => { + // A parallel poll batch streams two identical task_list requests from + // the primary model, then the attempt fails before any result lands: + // Turn.run clears pendingToolCalls on ModelFallback without a + // suppression note, so the in-flight reservations made when those + // requests streamed in can never unwind on their own. Pre-fix the + // ModelFallback branches never cleared the stateful trackers, so the + // stale reservations survived into the fallback attempt and the + // alternating-pattern carve-out computed expectedResults = + // occurrences - staleInFlight <= 0, skipping the exoneration check — + // the fallback model's changing-board poller halted + // ALTERNATING_TOOL_CALL_PATTERN on arguments alone. + const heuristicService = new LoopDetectionService( + makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), + ); + heuristicService.reset('fallback-alternating-productive'); + + // The primary model's failed partial round: two identical task_list + // requests, no results. + expect(heuristicService.addAndCheck(taskListEvent('primary-0'))).toBe( + false, + ); + expect(heuristicService.addAndCheck(taskListEvent('primary-1'))).toBe( + false, + ); + const fallbackEvent: ServerGeminiModelFallbackEvent = { + type: GeminiEventType.ModelFallback, + fromModel: 'primary-model', + toModel: 'fallback-model', + fallbackIndex: 1, + }; + expect(heuristicService.addAndCheck(fallbackEvent)).toBe(false); + + // The fallback model restarts the poll from scratch and the board + // keeps changing: productive ABAB (task_list ↔ tool_b) well past the + // window fill. + let fired = false; + for ( + let round = 0; + round < ALTERNATING_PATTERN_CYCLES + 2 && !fired; + round++ + ) { + fired = heuristicService.addAndCheck(taskListEvent(`fb-${round}`)); + if (fired) break; + fired = heuristicService.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult(`board state v${round}`, `fb-${round}`), + ); + if (fired) break; + fired = heuristicService.addAndCheck( + createToolCallRequestEvent('tool_b', { step: 'work' }), + ); + } + expect(fired).toBe(false); + expect(heuristicService.getLastLoopType()).toBeNull(); + }); + + it('does not halt a fallback poller resuming after the primary stream died (issue #9450)', () => { + // Always-on tier (CLI default skipLoopDetection=true). The primary + // model streams three identical task_list requests, then the attempt + // dies before results land. Pre-fix checkAlwaysOnSafeties had no + // ModelFallback branch: the consecutive streak (3) and its + // never-answerable in-flight reservations carried into the fallback + // attempt, and the fallback model's second EXECUTED poll — the 5th + // consecutive request — halted CONSECUTIVE_IDENTICAL_TOOL_CALLS + // despite every executed result having changed. + const fallbackService = new LoopDetectionService(makeConfig()); + fallbackService.reset('fallback-consecutive-productive'); + for (let i = 0; i < 3; i++) { + expect( + fallbackService.checkAlwaysOnSafeties(taskListEvent(`primary-${i}`)), + ).toBe(false); + } + const fallbackEvent: ServerGeminiModelFallbackEvent = { + type: GeminiEventType.ModelFallback, + fromModel: 'primary-model', + toModel: 'fallback-model', + fallbackIndex: 1, + }; + expect(fallbackService.checkAlwaysOnSafeties(fallbackEvent)).toBe(false); + + const finishedEvent = { + type: GeminiEventType.Finished, + value: { reason: 'STOP' }, + } as unknown as ServerGeminiStreamEvent; + let fired = false; + for (let i = 0; i < 12 && !fired; i++) { + fired = fallbackService.checkAlwaysOnSafeties(taskListEvent(`fb-${i}`)); + if (fired) break; + fallbackService.checkAlwaysOnSafeties(finishedEvent); + fired = fallbackService.recordToolResultByCallId( + `fb-${i}`, + taskListResult(`board state v${i}`, `fb-${i}`), + ); + } + expect(fired).toBe(false); + expect(fallbackService.getLastLoopType()).toBeNull(); + }); + describe('persisted oversized results (issue #9450 follow-up)', () => { // Results over the response-finalizer budget are rewritten into // persistence stubs (utils/truncation.ts buildStub) whose envelope diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 807076310fd..2068d01dd52 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -1018,6 +1018,17 @@ export class LoopDetectionService { // replay-retry resets. this.globalToolCallCounts.clear(); this.recentToolCallKeys = []; + // The failed model's streamed requests never execute and never + // receive results (Turn discards them without a suppression note), + // so the stateful reservations they made can never unwind on their + // own: release them here like the replay-retry branch above, or the + // stale in-flight counts collapse the alternating-pattern carve-out + // (expectedResults drops to zero, the exoneration check is skipped) + // and the guard halts the fallback model's productive poller on + // arguments alone (issue #9450). + this.statefulAlternationHistory.clear(); + this.statefulRepeatKeys.clear(); + this.statefulInFlight.clear(); this.resetContentTracking(); this.thoughtHistory = []; break; @@ -1115,6 +1126,45 @@ export class LoopDetectionService { return false; } + // A model fallback restarts the attempt from scratch exactly like a + // replay retry (Turn clears pendingToolCalls on the fallback event), + // except the failed model's streamed tool calls are DISCARDED, not + // re-streamed: they never execute, no results land for them, and no + // suppression note unwinds their request-side state. Mirror the Retry + // resets so the failed attempt's evidence cannot poison the fallback + // attempt: roll the per-turn cap back to the last committed round-trip + // (the failed attempt's calls counted there but never produce results), + // drop the consecutive-identical streak (its in-flight requests can + // never be exonerated, so keeping it would false-halt the fallback + // model's resumed polling at the threshold), clear the cap's repeat + // trackers and the stateful result evidence (the fresh attempt is + // judged on its own results), release the stateful reservations the + // discarded requests made (they can never unwind on their own), and + // drop the still-unanswered callId pairings — results recorded between + // round-trips already consumed the prior rounds' entries, so whatever + // remains belongs to the discarded attempt and would otherwise + // accumulate toward the FIFO eviction cap (issue #9450). + if (event.type === GeminiEventType.ModelFallback) { + this.turnToolCallTotal = this.turnToolCallTotalCommitted; + this.resetToolCallCount(); + this.capKeyCounts.clear(); + this.capMaxKeyRepeat = 0; + this.statefulCapKeyRepeat = 0; + for (const state of this.statefulRepeatState.values()) { + state.resultsObserved = 0; + state.unchangedStreak = 0; + state.consecutiveIdenticalResults = 0; + state.suppressedRequests = 0; + } + this.statefulResultKeysSinceLastFinished.clear(); + this.statefulRequestedKeysSinceLastFinished.clear(); + this.statefulAlternationHistory.clear(); + this.statefulRepeatKeys.clear(); + this.statefulInFlight.clear(); + this.requestByCallId.clear(); + return false; + } + if (event.type !== GeminiEventType.ToolCallRequest) { return false; } From 23b6f8adff5b61804c7a904b0956b9f5a397c259 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Tue, 25 Aug 2026 11:39:31 +0800 Subject: [PATCH 45/51] fix(core): keep suppressed request counts when decay keeps the streak (#9450) --- .../src/services/loopDetectionService.test.ts | 92 +++++++++++++++++++ .../core/src/services/loopDetectionService.ts | 41 +++++++-- 2 files changed, 123 insertions(+), 10 deletions(-) diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index 7cd1db8cd22..7e48a7c4b62 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -4337,6 +4337,98 @@ describe('LoopDetectionService', () => { expect(fallbackService.getLastLoopType()).toBeNull(); }); + it('does not halt a resumed poller whose suppressed-only evidence decayed (issue #9450)', () => { + // Two identical task_list requests stream and are suppressed without + // executing (authorization rejection / scheduler not_started): the + // streak stands at repCount 2 with suppressedRequests 2 and no result + // evidence. Two tool-call-free round-trips then decay the entry at the + // second Finished boundary. Pre-fix the decay zeroed + // suppressedRequests while KEEPING the streak: when executed polling + // resumed, expectedResults = repCount - inFlight - 0 stayed + // permanently one above resultsObserved (every new request adds one + // to repCount and, a round later, one to resultsObserved), so the + // exoneration gate was never satisfiable again and the poller halted + // CONSECUTIVE_IDENTICAL_TOOL_CALLS at the 5th consecutive request + // despite every executed result having changed. The decay must keep + // suppressedRequests alongside the kept streak so the gate keeps + // subtracting the never-answerable requests. + const finishedEvent = { + type: GeminiEventType.Finished, + value: { reason: 'STOP' }, + } as unknown as ServerGeminiStreamEvent; + const gapService = new LoopDetectionService(makeConfig()); + gapService.reset('decay-suppressed-resume-productive'); + + // Suppressed-only entry: two identical requests, never executed. + expect(gapService.checkAlwaysOnSafeties(taskListEvent('sup-0'))).toBe( + false, + ); + expect(gapService.checkAlwaysOnSafeties(taskListEvent('sup-1'))).toBe( + false, + ); + gapService.noteSuppressedToolCallByCallId('sup-0'); + gapService.noteSuppressedToolCallByCallId('sup-1'); + // Two tool-call-free round-trips: the first boundary consumes the + // suppression marks, the second decays the entry (suppressed-only, so + // the streak is kept). + gapService.checkAlwaysOnSafeties(finishedEvent); + gapService.checkAlwaysOnSafeties(finishedEvent); + + // Executed polling resumes with the board still changing: no halt. + let fired = false; + for (let i = 0; i < 9 && !fired; i++) { + fired = gapService.checkAlwaysOnSafeties(taskListEvent(`poll-${i}`)); + if (fired) break; + gapService.checkAlwaysOnSafeties(finishedEvent); + fired = gapService.recordToolResultByCallId( + `poll-${i}`, + taskListResult(`board state v${i}`, `poll-${i}`), + ); + } + expect(fired).toBe(false); + expect(gapService.getLastLoopType()).toBeNull(); + }); + + it('still halts a pure suppressed stream crossing round-trip boundaries (fail-safe)', () => { + // Fail-safe twin of the suppressed-only decay fix: the decay keeps the + // suppressed-only streak armed, so a persistent stream of identical + // suppressed calls — no result ever lands for it — still halts at the + // threshold even when it crosses a decay boundary. Kept + // suppressedRequests keep balancing the gate exactly like the + // uninterrupted stream does. + const finishedEvent = { + type: GeminiEventType.Finished, + value: { reason: 'STOP' }, + } as unknown as ServerGeminiStreamEvent; + const gapService = new LoopDetectionService(makeConfig()); + gapService.reset('decay-suppressed-halt'); + + expect(gapService.checkAlwaysOnSafeties(taskListEvent('sup-0'))).toBe( + false, + ); + expect(gapService.checkAlwaysOnSafeties(taskListEvent('sup-1'))).toBe( + false, + ); + gapService.noteSuppressedToolCallByCallId('sup-0'); + gapService.noteSuppressedToolCallByCallId('sup-1'); + gapService.checkAlwaysOnSafeties(finishedEvent); + gapService.checkAlwaysOnSafeties(finishedEvent); + + let fired = false; + let requests = 2; + for (let i = 2; i < 10 && !fired; i++) { + fired = gapService.checkAlwaysOnSafeties(taskListEvent(`sup-${i}`)); + requests = i + 1; + if (fired) break; + gapService.noteSuppressedToolCallByCallId(`sup-${i}`); + } + expect(fired).toBe(true); + expect(requests).toBe(TOOL_CALL_LOOP_THRESHOLD); + expect(gapService.getLastLoopType()).toBe( + LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, + ); + }); + describe('persisted oversized results (issue #9450 follow-up)', () => { // Results over the response-finalizer budget are rewritten into // persistence stubs (utils/truncation.ts buildStub) whose envelope diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 2068d01dd52..e31a0fceb49 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -2047,10 +2047,17 @@ export class LoopDetectionService { * streak starts fresh and is judged on its own results; decay never runs * for a key with requests still in flight (the requested-set skip above), * so this cannot drop a streak the in-flight accounting is still - * deferring. lastFingerprint survives the decay: when polling - * resumes, the first fresh result is still judged against the last - * observed one (changed → productive, unchanged → the count - * re-accumulates toward the halt). + * deferring. Exception — a suppressedRequests-ONLY entry (no result + * evidence) keeps its standing streak AND its suppressedRequests: the + * gate subtracts the never-answerable requests, so a pure suppressed + * stream crossing round-trip boundaries still halts at the threshold, + * and resumed EXECUTED polling on the same key stays exonerable — + * zeroing suppressedRequests while the request-side count stands would + * leave expectedResults permanently one above resultsObserved and + * false-halt a changing-board poller (issue #9450). lastFingerprint + * survives the decay: when polling resumes, the first fresh result is + * still judged against the last observed one (changed → productive, + * unchanged → the count re-accumulates toward the halt). */ private decayAbandonedStatefulStreaks(): void { let decayed = false; @@ -2065,18 +2072,32 @@ export class LoopDetectionService { state.consecutiveIdenticalResults = 0; state.resultsObserved = 0; state.unchangedStreak = 0; - state.suppressedRequests = 0; if (hadResultEvidence && this.lastToolCallKey === key) { // Dropping the exoneration gate's result evidence while the // consecutive count stands would leave the gate permanently - // unsatisfiable, so drop the streak with it. A - // suppressedRequests-only entry keeps its count: the gate stays - // satisfiable for it (no result evidence to expect), so the - // threshold still halts a stream of identical suppressed calls - // crossing round-trip boundaries. + // unsatisfiable, so drop the streak with it — and the suppression + // count with the streak: it belongs to the dropped streak, and a + // later streak must not subtract requests it never made. + state.suppressedRequests = 0; this.lastToolCallKey = null; this.toolCallRepetitionCount = 0; + } else if (this.lastToolCallKey !== key) { + // The streak no longer belongs to this key (or was reset): the + // suppression count is read only while its streak stands, so it + // decays with the rest of the evidence. + state.suppressedRequests = 0; } + // Else: suppressed-only evidence (no result evidence) with the + // streak still standing — a stream of identical suppressed calls + // crossing round-trip boundaries. Keep the streak AND its + // suppressedRequests: the exoneration gate subtracts the + // never-answerable requests, so the threshold still halts a pure + // suppressed stream, and if EXECUTED polling resumes on the same + // key the gate stays satisfiable (zeroing suppressedRequests while + // the request-side count stands would leave expectedResults + // permanently one above resultsObserved and false-halt a + // changing-board poller — the #9450 false positive re-entering via + // the decay layer). decayed = true; } } From c30eadbf0a4f062b038d7eed9823875fd947282f Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Tue, 25 Aug 2026 11:40:11 +0800 Subject: [PATCH 46/51] fix(core): share one stub grammar between fitText and the loop guards (#9450) --- .../src/services/loopDetectionService.test.ts | 84 +++++++++- .../core/src/services/loopDetectionService.ts | 124 ++++++++------ .../src/tools/tool-response-finalizer.test.ts | 153 ++++++++++++++++++ .../core/src/tools/tool-response-finalizer.ts | 20 ++- packages/core/src/tools/truncation.ts | 86 +++++++--- 5 files changed, 390 insertions(+), 77 deletions(-) diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index 7e48a7c4b62..875ab104f7b 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -23,7 +23,10 @@ import { GeminiEventType } from '../core/turn.js'; import * as loggers from '../telemetry/loggers.js'; import { LoopType } from '../telemetry/types.js'; import type { DebugLogger } from '../utils/debugLogger.js'; -import { enforceFunctionResponseBudget } from '../tools/tool-response-finalizer.js'; +import { + BATCH_BUDGET_FIT_PREFIX, + enforceFunctionResponseBudget, +} from '../tools/tool-response-finalizer.js'; import { buildStub, FULL_OUTPUT_DIGEST_LABEL, @@ -33,6 +36,7 @@ import { } from '../tools/truncation.js'; import { DEFAULT_MAX_TOOL_CALLS_PER_TURN, + extractToolResultText, fingerprintToolResult, LoopDetectionService, } from './loopDetectionService.js'; @@ -4958,5 +4962,83 @@ describe('LoopDetectionService', () => { expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); }); + + describe('stub grammar parity across the batch-budget boundary (issue #9450)', () => { + // fitText (producer) and stripPersistenceEnvelope (guard) must + // recognize stub shapes with ONE grammar: the shared recognizers in + // tools/truncation.ts. These tests pin the guard side at the fixed + // positions the producers write digests to — a quoted stub header in + // payload content must never hijack the fingerprint, and content with + // no producer digest must canonicalize identically on both sides of + // the budget boundary. + const quotedHex = '0123456789abcdef'.repeat(4); + + const reducedText = (value: string): string => + extractToolResultText(taskListResult(value)) ?? ''; + + it('reduces a fit header digest at the fixed position only, never from the payload', () => { + // A fit-prefix-leading value whose PAYLOAD quotes a stub header + // carries no producer digest: pre-fix the guard scanned the whole + // value for a line-anchored digest and reduced to the quoted hex — + // changes below the quoted line stayed invisible and the reduction + // diverged from fitText, which hashes the full text when the fixed + // header position carries no digest. + const value = + `${BATCH_BUDGET_FIT_PREFIX}\n` + + `board quoting a stub header\n` + + `${FULL_OUTPUT_DIGEST_LABEL}${quotedHex}\n` + + 'more payload'; + const reduced = reducedText(value); + const expected = `sha256:${createHash('sha256') + .update(value) + .digest('hex')}`; + expect(reduced).toContain(expected); + expect(reduced).not.toContain(quotedHex); + }); + + it('collides a no-digest fit-prefix-leading value with its over-budget fit', () => { + // Content starting with the fit prefix but carrying no digest line + // fingerprinted VERBATIM pre-fix under budget while its over-budget + // fit wrapped to sha256(raw): two representations of identical + // content that never collide, so a frozen board oscillating around + // the budget boundary never accumulated unchanged-result evidence. + // Both sides must reduce to sha256(content). + const value = `${BATCH_BUDGET_FIT_PREFIX}\nplain content without any digest line`; + const digest = createHash('sha256').update(value).digest('hex'); + const reduced = reducedText(value); + expect(reduced).toContain(`sha256:${digest}`); + // The over-budget fit of the same content (fitText writes the + // sha256 at the fixed header position) reduces to the SAME marker. + const fit = + `${BATCH_BUDGET_FIT_PREFIX}\n` + + `${FULL_OUTPUT_DIGEST_LABEL}${digest}\n` + + 'Persisted tool-output artifact: /tmp/tool-results/call-a.txt'; + expect(reducedText(fit)).toBe(reduced); + }); + + it('keeps the shape-exact label arm reducing producer shapes to their digest', () => { + // The degenerate digest-line-only fit and the save-failure fallback + // both start with the label itself and carry their digest at the + // fixed leading position: they must still reduce to that digest + // under the shared shape-exact recognizer. + const exactLine = `${FULL_OUTPUT_DIGEST_LABEL}${quotedHex}`; + expect(reducedText(exactLine)).toContain( + `sha256:${quotedHex}`, + ); + // A quoted stub header (label + quoted hex + further payload) is + // content, not a stub: it canonicalizes to its own sha256 and its + // mutations stay visible. + const quotedA = `${FULL_OUTPUT_DIGEST_LABEL}${quotedHex}\npayload A`; + const quotedB = `${FULL_OUTPUT_DIGEST_LABEL}${quotedHex}\npayload B`; + const reducedA = reducedText(quotedA); + expect(reducedA).toContain( + `sha256:${createHash('sha256') + .update(quotedA) + .digest('hex')}`, + ); + expect(reducedA).not.toBe(reducedText(quotedB)); + expect(reducedA).not.toContain(quotedHex); + }); + }); }); }); diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index e31a0fceb49..69d592ecf34 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -22,13 +22,15 @@ import type { Config } from '../config/config.js'; import { getToolCallRepeatKey } from '../tools/tool-call-repeat-key.js'; import { BATCH_BUDGET_FIT_PREFIX } from '../tools/tool-response-finalizer.js'; import { + extractAnchoredStubDigest, + extractDigestCarryingShapeDigest, + extractStubDigestAt, FULL_OUTPUT_DIGEST_LABEL, OUTPUT_TOO_LARGE_PREFIX, PERSISTED_OUTPUT_OPEN_TAG, PERSISTED_PREVIEW_MARKER, TOOL_OUTPUT_TRUNCATED_PREFIX, TRUNCATED_PART_MARKER, - TRUNCATION_SAVE_FAILURE_NOTE, } from '../tools/truncation.js'; // Re-exported for existing importers (daemon turn-loop guard); the @@ -217,7 +219,7 @@ export function shouldHaltOnTurnToolCallCap( // per-call unique artifact path in its envelope, which is exactly why it // must be reduced to its digest; content that merely contains (or even // starts with) the digest label carries no per-call path and is -// fingerprinted verbatim instead. +// fingerprinted as ordinary content instead. const STUB_PRODUCER_PREFIXES: readonly string[] = [ PERSISTED_OUTPUT_OPEN_TAG, OUTPUT_TOO_LARGE_PREFIX, @@ -227,31 +229,16 @@ const STUB_PRODUCER_PREFIXES: readonly string[] = [ ]; /** - * Extracts the sha256 digest a stub producer embedded for the FULL - * pre-truncation output, anchored to a producer line: the label must start - * its line and be followed by exactly 64 hex chars ending the line. A - * mid-string mention of the label (e.g. board content quoting a stub) never - * matches. Returns null when no anchored digest is present. + * Canonicalizes a value no producer shape recognizes (see + * stripPersistenceEnvelope): its own sha256 under the `` + * sentinel, never the verbatim text — the batch-budget fit of the same + * content carries exactly this sha256 as its header digest (fitText), so + * both sides of the budget boundary collide. */ -function extractAnchoredStubDigest(value: string): string | null { - let searchFrom = 0; - for (;;) { - const index = value.indexOf(FULL_OUTPUT_DIGEST_LABEL, searchFrom); - if (index < 0) return null; - const digestStart = index + FULL_OUTPUT_DIGEST_LABEL.length; - const lineAnchored = index === 0 || value[index - 1] === '\n'; - if (lineAnchored) { - const digest = value.slice(digestStart, digestStart + 64); - const terminator = value[digestStart + 64]; - if ( - /^[0-9a-f]{64}$/.test(digest) && - (terminator === undefined || terminator === '\n' || terminator === '\r') - ) { - return digest; - } - } - searchFrom = index + 1; - } +function canonicalizeContent(value: string): string { + return `sha256:${createHash('sha256') + .update(value) + .digest('hex')}`; } /** @@ -279,9 +266,16 @@ function extractAnchoredStubDigest(value: string): string | null { * that matches the payload. * * Stub recognition is gated on the producer prefixes (see - * STUB_PRODUCER_PREFIXES) and the digest must be line-anchored with a full - * 64-hex payload, so arbitrary result text that merely contains the label - * is fingerprinted verbatim instead of being collapsed to a quoted window. + * STUB_PRODUCER_PREFIXES), and the guard parses stubs with the SAME grammar + * the producers write: the shared recognizers from tools/truncation.ts + * (extractAnchoredStubDigest / extractDigestCarryingShapeDigest / + * extractStubDigestAt) instead of a hand-mirrored copy. Digests are read + * only at the positions the producers write them — a batch-budget fit's + * header line right after its prefix, a label-leading shape's leading + * position — so a quoted stub header inside payload content can never + * hijack the fingerprint, and arbitrary result text that merely contains + * the label is canonicalized like ordinary content instead of being + * collapsed to a quoted window (issue #9450). * * Non-stub text is canonicalized to its own sha256 digest marker instead of * being carried verbatim: the batch-budget fit rewrites an over-budget @@ -293,7 +287,13 @@ function extractAnchoredStubDigest(value: string): string | null { * Hashing the full text preserves every distinction a changed board makes * (including inside the band a fit would drop), and the `` * sentinel keeps a canonicalized result from ever colliding with a literal - * small output that happens to match the raw text of another shape. + * small output that happens to match the raw text of another shape. The + * same collision rule applies to values that themselves start with a stub + * prefix but carry no producer digest (fit-prefix-leading board content): + * returning them verbatim would fingerprint them differently from their + * over-budget fit (whose digest is the sha256 of the full pre-fit text), + * so the cap's stuck signal could never arm for a frozen board oscillating + * around the boundary. */ function stripPersistenceEnvelope(value: string): string { const isProducerStub = STUB_PRODUCER_PREFIXES.some((prefix) => @@ -311,34 +311,50 @@ function stripPersistenceEnvelope(value: string): string { // board oscillating across the budget boundary would fingerprint // differently per poll despite byte-identical content, the // consecutive-identical streak would never accumulate, and the cap's - // stuck signal would never arm (issue #9450). Recognition stays - // shape-exact — the label alone is not enough: board content merely - // STARTING with the label carries no producer digest and must keep - // fingerprinting as ordinary content (the injection rationale above). - const isDigestCarryingShape = - !isProducerStub && - value.startsWith(FULL_OUTPUT_DIGEST_LABEL) && - (value.length === FULL_OUTPUT_DIGEST_LABEL.length + 64 || - value.endsWith(TRUNCATION_SAVE_FAILURE_NOTE)); - if (!isProducerStub && !isDigestCarryingShape) { - return `sha256:${createHash('sha256') - .update(value) - .digest('hex')}`; + // stuck signal would never arm (issue #9450). Recognition is shape-exact + // and reads the digest at the fixed leading position — the producer-side + // recognizer itself (extractDigestCarryingShapeDigest), so a quoted stub + // header (label + quoted hex + further payload) keeps fingerprinting as + // ordinary content on BOTH sides of the budget boundary instead of the + // fit carrying the quoted hex while the guard hashes the content. + if (!isProducerStub) { + const shapeDigest = extractDigestCarryingShapeDigest(value); + if (shapeDigest !== null) { + return `sha256:${shapeDigest}`; + } + return canonicalizeContent(value); + } + + // A batch-budget fit carries its digest at the fixed header position — + // the line right after the prefix. Read only there (fitText carries a + // nested digest through exactly that position): scanning the whole + // payload would adopt a quoted stub header from the fit's content, so the + // fingerprint would follow the quoted hex while content changes below it + // stay invisible. Without a header digest the value is canonicalized: + // fitText computes the same sha256 of the same text when it has no + // digest to carry, so the under-budget value and its over-budget fit + // collide even when the value itself starts with the fit prefix + // (issue #9450). + if (value.startsWith(BATCH_BUDGET_FIT_PREFIX)) { + const headerLabelStart = BATCH_BUDGET_FIT_PREFIX.length + 1; + const fitDigest = + value[BATCH_BUDGET_FIT_PREFIX.length] === '\n' && + value.startsWith(FULL_OUTPUT_DIGEST_LABEL, headerLabelStart) + ? extractStubDigestAt( + value, + headerLabelStart + FULL_OUTPUT_DIGEST_LABEL.length, + ) + : null; + if (fitDigest !== null) { + return `sha256:${fitDigest}`; + } + return canonicalizeContent(value); } const digest = extractAnchoredStubDigest(value); if (digest !== null) { return `sha256:${digest}`; } - if (!isProducerStub) { - // A digest-carrying shape whose label is not followed by a full - // line-anchored 64-hex digest (should not happen for the producers, - // which always embed one): fall back to hashing the whole value like - // ordinary content instead of returning it verbatim. - return `sha256:${createHash('sha256') - .update(value) - .digest('hex')}`; - } const isPreviewStub = value.startsWith(PERSISTED_OUTPUT_OPEN_TAG) || @@ -357,6 +373,10 @@ function stripPersistenceEnvelope(value: string): string { if (index >= 0) { return `${value.slice(index + marker.length)}`; } + // Truncation prefix with neither a digest line nor the truncated-part + // marker: no producer payload to fall back to — canonicalize for the + // same boundary-collision reason as the fit-prefix arm above. + return canonicalizeContent(value); } return value; } diff --git a/packages/core/src/tools/tool-response-finalizer.test.ts b/packages/core/src/tools/tool-response-finalizer.test.ts index defae9f2e89..90a2fdf90bf 100644 --- a/packages/core/src/tools/tool-response-finalizer.test.ts +++ b/packages/core/src/tools/tool-response-finalizer.test.ts @@ -952,6 +952,159 @@ describe('tool response finalization', () => { expect(output).toContain(BATCH_BUDGET_FIT_PREFIX); expect(fittedDigest(output)).toBe(digest); }); + + it('does not adopt a quoted stub digest from label-leading content', async () => { + // A tool result or peer-authored board that STARTS with a quoted + // `Full output sha256: ` line is content, not a stub: the guard + // side recognizes label-leading shapes shape-exactly, so the producer + // must hash the full text instead of carrying the quoted hex — + // pre-fix the fit carried the quoted hex and changes below the quoted + // line were invisible to the result-aware guards (issue #9450). + const quotedHex = 'ab'.repeat(32); + const quotedA = `${FULL_OUTPUT_DIGEST_LABEL}${quotedHex}\n${'payload A line\n'.repeat(40)}`; + const quotedB = `${FULL_OUTPUT_DIGEST_LABEL}${quotedHex}\n${'payload B line\n'.repeat(40)}`; + + const fitDigestOf = async (text: string): Promise => { + const finalized = await finalizeToolResponses( + config(150), + [ + entry('call-a', [ + { + functionResponse: { + id: 'call-a', + name: 'task_list', + response: { output: text }, + }, + }, + ]), + ], + new Map(), + ); + return fittedDigest( + finalized[0].responseParts[0].functionResponse?.response?.[ + 'output' + ] as string, + ); + }; + + expect(await fitDigestOf(quotedA)).toBe(boardDigest(quotedA)); + expect(await fitDigestOf(quotedB)).toBe(boardDigest(quotedB)); + expect(await fitDigestOf(quotedA)).not.toBe(quotedHex); + }); + + it('does not adopt a quoted digest buried in a fit-prefix-leading payload', async () => { + // Content starting with the fit prefix whose payload QUOTES a stub + // header carries no producer digest at the fixed header position: the + // carry-through must read that position only (where fitText writes + // it), never scan the payload — pre-fix the scan adopted the quoted + // hex, fingerprinting the fit to the quoted hex while the content + // below it changed (issue #9450). + const quotedHex = 'cd'.repeat(32); + const base = + `${BATCH_BUDGET_FIT_PREFIX}\n` + + `board quoting a stub header\n` + + `${FULL_OUTPUT_DIGEST_LABEL}${quotedHex}\n`; + const textA = `${base}payload A ${'x'.repeat(400)}`; + const textB = `${base}payload B ${'y'.repeat(400)}`; + + const fitDigestOf = async (text: string): Promise => { + const finalized = await finalizeToolResponses( + config(200), + [ + entry('call-a', [ + { + functionResponse: { + id: 'call-a', + name: 'task_list', + response: { output: text }, + }, + }, + ]), + ], + new Map(), + ); + return fittedDigest( + finalized[0].responseParts[0].functionResponse?.response?.[ + 'output' + ] as string, + ); + }; + + expect(await fitDigestOf(textA)).toBe(boardDigest(textA)); + expect(await fitDigestOf(textB)).toBe(boardDigest(textB)); + expect(await fitDigestOf(textA)).not.toBe(quotedHex); + }); + + it('collides quoted-stub-leading content across the budget boundary and stays change-sensitive', async () => { + // Both directions of the label-leading divergence: identical content + // must fingerprint identically raw (under budget) and fitted (over + // budget), and content changing BELOW the quoted line must + // fingerprint differently (issue #9450). + const quotedHex = 'ef'.repeat(32); + const make = (tail: string) => + `${FULL_OUTPUT_DIGEST_LABEL}${quotedHex}\nquoted stub header above\n${tail}`; + const textA = make(`payload v1 ${'a'.repeat(400)}`); + const textB = make(`payload v2 ${'b'.repeat(400)}`); + + const partsOf = (text: string): Part[] => [ + { + functionResponse: { + id: 'call-a', + name: 'task_list', + response: { output: text }, + }, + }, + ]; + const fitParts = async (text: string): Promise => { + const finalized = await finalizeToolResponses( + config(150), + [entry('call-a', partsOf(text))], + new Map(), + ); + return finalized[0].responseParts; + }; + + // Raw vs fitted representations of identical content collide. + expect(fingerprintToolResult(await fitParts(textA))).toBe( + fingerprintToolResult(partsOf(textA)), + ); + // Changes below the quoted line stay visible across the boundary. + expect(fingerprintToolResult(await fitParts(textA))).not.toBe( + fingerprintToolResult(await fitParts(textB)), + ); + }); + + it('collides a no-digest fit-prefix-leading value with its fit across the boundary', async () => { + // Entrance 2: content starting with the fit prefix but carrying no + // anchored digest line fingerprinted VERBATIM under budget (guard) + // while its over-budget fit wrapped to sha256(raw) — two + // representations of identical content that never collide, so a + // frozen board oscillating around the budget boundary never armed + // the cap's stuck signal. Both representations must reduce to the + // same sha256 (issue #9450). + const content = + `${BATCH_BUDGET_FIT_PREFIX}\n` + + `plain board content without any digest line\n` + + 'board line\n'.repeat(40); + + const partsOf = (text: string): Part[] => [ + { + functionResponse: { + id: 'call-a', + name: 'task_list', + response: { output: text }, + }, + }, + ]; + const finalized = await finalizeToolResponses( + config(150), + [entry('call-a', partsOf(content))], + new Map(), + ); + expect(fingerprintToolResult(finalized[0].responseParts)).toBe( + fingerprintToolResult(partsOf(content)), + ); + }); }); describe('degenerate batch-budget fits stay content-dependent (issue #9450)', () => { diff --git a/packages/core/src/tools/tool-response-finalizer.ts b/packages/core/src/tools/tool-response-finalizer.ts index 327ec13bccd..7eef798daad 100644 --- a/packages/core/src/tools/tool-response-finalizer.ts +++ b/packages/core/src/tools/tool-response-finalizer.ts @@ -17,8 +17,8 @@ import { type ToolResultBoundaryStage, } from './tool-result-boundary-diagnostics.js'; import { - extractAnchoredStubDigest, extractPersistedStubDigest, + extractStubDigestAt, FULL_OUTPUT_DIGEST_LABEL, normalizeToolResultCallId, persistAndTruncateToolResult, @@ -256,11 +256,23 @@ function fitText( // result-aware guards again (the guards' digest-first reduction would take // this header's outer digest), so carry the inner stub's own digest // instead — and likewise the digest of a prior batch-budget fit, whose - // header is per-call unique via its artifact note. + // header is per-call unique via its artifact note. The prior-fit digest is + // read at its FIXED header position (the line right after the prefix), + // never by scanning the payload: a quoted stub header inside the fit's + // content would otherwise be adopted as the digest, fingerprinting the fit + // to the quoted hex while content changes below it stay invisible — and + // diverging from the guard, which only recognizes producer-written + // positions (issue #9450). + const headerLabelStart = BATCH_BUDGET_FIT_PREFIX.length + 1; const digest = extractPersistedStubDigest(text) ?? - (text.startsWith(BATCH_BUDGET_FIT_PREFIX) - ? extractAnchoredStubDigest(text) + (text.startsWith(BATCH_BUDGET_FIT_PREFIX) && + text[BATCH_BUDGET_FIT_PREFIX.length] === '\n' && + text.startsWith(FULL_OUTPUT_DIGEST_LABEL, headerLabelStart) + ? extractStubDigestAt( + text, + headerLabelStart + FULL_OUTPUT_DIGEST_LABEL.length, + ) : null) ?? createHash('sha256').update(text).digest('hex'); const digestLine = `${FULL_OUTPUT_DIGEST_LABEL}${digest}`; diff --git a/packages/core/src/tools/truncation.ts b/packages/core/src/tools/truncation.ts index 8b643a41be3..9c886dd16a6 100644 --- a/packages/core/src/tools/truncation.ts +++ b/packages/core/src/tools/truncation.ts @@ -61,6 +61,29 @@ export const FULL_OUTPUT_DIGEST_LABEL = 'Full output sha256: '; export const TRUNCATION_SAVE_FAILURE_NOTE = '[Note: Could not save full output to file]'; +/** + * Extracts a full 64-hex stub digest occupying a FIXED position: the digest + * must span exactly `digestStart` .. `digestStart + 64` and end its line + * (terminator undefined, '\n' or '\r'). Returns null otherwise. Shared + * primitive for the positions the producers are known to write the digest + * at, so the producer-side carry-through and the loop guards' recognition + * parse one grammar and cannot drift (issue #9450). + */ +export function extractStubDigestAt( + value: string, + digestStart: number, +): string | null { + const digest = value.slice(digestStart, digestStart + 64); + const terminator = value[digestStart + 64]; + if ( + /^[0-9a-f]{64}$/.test(digest) && + (terminator === undefined || terminator === '\n' || terminator === '\r') + ) { + return digest; + } + return null; +} + /** * Extracts the sha256 digest a stub producer embedded for the FULL * pre-truncation output, anchored to a producer line: the label must start @@ -68,32 +91,50 @@ export const TRUNCATION_SAVE_FAILURE_NOTE = * mid-string mention of the label (e.g. board content quoting a stub) never * matches. Returns null when no anchored digest is present. Exported so the * batch-budget finalizer can carry a nested stub's digest through a fit - * (see fitText there) instead of hashing the per-call unique envelope - * (issue #9450); the loop guards keep their own private copy because they - * already import from this module and the recognition must not drift from - * the producer constants above. + * (see fitText there) instead of hashing the per-call unique envelope, and + * so the loop guards share this exact recognizer instead of hand-mirroring + * it — the recognition must not drift from the producer constants above + * (issue #9450). */ export function extractAnchoredStubDigest(value: string): string | null { let searchFrom = 0; for (;;) { const index = value.indexOf(FULL_OUTPUT_DIGEST_LABEL, searchFrom); if (index < 0) return null; - const digestStart = index + FULL_OUTPUT_DIGEST_LABEL.length; const lineAnchored = index === 0 || value[index - 1] === '\n'; if (lineAnchored) { - const digest = value.slice(digestStart, digestStart + 64); - const terminator = value[digestStart + 64]; - if ( - /^[0-9a-f]{64}$/.test(digest) && - (terminator === undefined || terminator === '\n' || terminator === '\r') - ) { - return digest; - } + const digest = extractStubDigestAt( + value, + index + FULL_OUTPUT_DIGEST_LABEL.length, + ); + if (digest !== null) return digest; } searchFrom = index + 1; } } +/** + * Returns the digest of a digest-carrying shape that starts with the digest + * label itself: the batch-budget finalizer's degenerate digest-line-only + * fits (exactly `Full output sha256: <64-hex>`) and the save-failure + * fallback of `truncateAndSaveToFile` (label + head/tail payload + + * save-failure note). Recognition is SHAPE-EXACT and the digest is read at + * the fixed position right after the label — the same grammar the loop + * guards apply (stripPersistenceEnvelope): content merely STARTING with the + * label (a task board or tool result quoting a stub header) carries no + * producer digest and must keep fingerprinting as ordinary content, or the + * two sides of the batch-budget boundary fingerprint the same content + * differently (issue #9450). Returns null for any other text. + */ +export function extractDigestCarryingShapeDigest(text: string): string | null { + if (!text.startsWith(FULL_OUTPUT_DIGEST_LABEL)) return null; + const isDigestCarryingShape = + text.length === FULL_OUTPUT_DIGEST_LABEL.length + 64 || + text.endsWith(TRUNCATION_SAVE_FAILURE_NOTE); + if (!isDigestCarryingShape) return null; + return extractStubDigestAt(text, FULL_OUTPUT_DIGEST_LABEL.length); +} + /** * Returns the embedded full-output digest when `text` itself is an * oversized-result stub produced by this module: a `` @@ -102,9 +143,11 @@ export function extractAnchoredStubDigest(value: string): string | null { * with the digest label itself — the save-failure fallback of * `truncateAndSaveToFile` (label + head/tail payload + save-failure note) * and the batch-budget finalizer's degenerate digest-line-only fits. - * Returns null for any other text (the anchored extraction below still - * requires a line-anchored full 64-hex digest, so text that merely starts - * with the label without one is not treated as a stub). Used by the + * Returns null for any other text: the label-leading shapes are recognized + * shape-exactly (see extractDigestCarryingShapeDigest), so content that + * merely STARTS with the label — a board quoting a stub header — is not + * treated as a stub and keeps fingerprinting as ordinary content + * (issue #9450). Used by the * batch-budget finalizer's fitText to make stub reduction idempotent across * nesting: the scheduler persists oversized results BEFORE the batch budget * runs, so a fit wrapping an already-persisted stub must carry the stub's @@ -118,10 +161,13 @@ export function extractPersistedStubDigest(text: string): string | null { const isProducerStub = text.startsWith(PERSISTED_OUTPUT_OPEN_TAG) || text.startsWith(OUTPUT_TOO_LARGE_PREFIX) || - text.startsWith(TOOL_OUTPUT_TRUNCATED_PREFIX) || - text.startsWith(FULL_OUTPUT_DIGEST_LABEL); - if (!isProducerStub) return null; - return extractAnchoredStubDigest(text); + text.startsWith(TOOL_OUTPUT_TRUNCATED_PREFIX); + if (isProducerStub) return extractAnchoredStubDigest(text); + // Label-leading shapes are recognized shape-exactly: a quoted stub header + // (label + quoted hex + further payload) is content, not a stub, and + // adopting its quoted digest would fingerprint the fit to the quoted hex + // instead of the content (issue #9450). + return extractDigestCarryingShapeDigest(text); } /** From d60309208cfc1cf87dddc082f544ef717da1c3d0 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Tue, 25 Aug 2026 14:13:07 +0800 Subject: [PATCH 47/51] test(core): realign the replay-in-streak halt pin with the kept suppression count (#9450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keep-suppressed-counts fix replaced the suppression unwind of the consecutive-identical increment: a suppressed call now keeps its request-side increment and is counted into the streak's suppressedRequests, which the exoneration gate subtracts from the expected results. The replayed poll therefore still counts toward the threshold, so the frozen-streak halt lands when poll_4 streams in as the fifth identical request — one round earlier than under the unwind the previous pin tracked: expectedResults is 5 - 1 in flight (poll_4) - 1 suppressed (the replay) = 3, exactly the three unchanged frozen-board results recorded for poll_1..poll_3, so the halt is corroborated by executed evidence at three executions. Mutation checks: reverting to unwind semantics moves the halt back to poll_5 (four executions), and recording the fabricated replay response as a result disarms the halt entirely (five executions). Co-authored-by: Qwen-Coder --- .../src/agents/runtime/agent-headless.test.ts | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/core/src/agents/runtime/agent-headless.test.ts b/packages/core/src/agents/runtime/agent-headless.test.ts index 16ddf394082..c4826f7ca48 100644 --- a/packages/core/src/agents/runtime/agent-headless.test.ts +++ b/packages/core/src/agents/runtime/agent-headless.test.ts @@ -2665,18 +2665,28 @@ describe('subagent.ts', () => { await scope.execute(new ContextState()); - // The replay never executes, and the suppression unwinds its - // streamed-in request count (keeping the exoneration gate - // `resultsObserved >= count - 1` reachable for changing boards and - // matching the daemon twin, which never counts suppressed calls), - // so the consecutive streak counts the four executable requests: - // the halt lands on the fifth countable identical request - // (poll_5's stream) after poll_4 executes as the fourth frozen - // board, corroborated by the four unchanged results. Pre-fix the - // fabricated replay error counted as a changed result, the streak - // restarted, and the run sailed to GOAL. - expect(taskListInvocation.execute).toHaveBeenCalledTimes(4); - expect(mockSendMessageStream).toHaveBeenCalledTimes(6); + // The replay never executes, but its suppression KEEPS the + // streamed-in repetition increment (the keep-suppressed-counts fix + // for #9450: unwinding it let a pure stream of identical + // suppressed calls oscillate the count forever and escape the + // threshold) and counts the replay into the streak's + // suppressedRequests instead, which the exoneration gate + // subtracts from the expected results so changing boards stay + // exonerable. The streak therefore counts FIVE identical requests + // by poll_4's stream — poll_1..poll_3, the replay, poll_4 — and + // the halt lands there, one round earlier than under the old + // suppression unwind the previous pin tracked: expectedResults is + // 5 - 1 in flight (poll_4) - 1 suppressed (the replay) = 3, + // exactly the three unchanged frozen-board results recorded for + // poll_1..poll_3 (unchangedStreak 2 >= expectedResults - 1), so + // the halt is corroborated by the executed evidence after three + // executions — under the unwind the replay's count was erased, + // the fifth countable request was poll_5, and the pin tracked + // four executions. Pre-fix the fabricated replay error counted + // as a changed result, the streak restarted, and the run sailed + // to GOAL. + expect(taskListInvocation.execute).toHaveBeenCalledTimes(3); + expect(mockSendMessageStream).toHaveBeenCalledTimes(5); expect(scope.getTerminateMode()).toBe(AgentTerminateMode.LOOP_DETECTED); expect(finishEvents).toHaveLength(1); expect(finishEvents[0].loopType).toBe( From 9b5089ffc7cde90993132a92699d42c1360be8ac Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Tue, 25 Aug 2026 21:12:27 +0800 Subject: [PATCH 48/51] fix(cli): carry live stateful streak marks across non-stateful replay suppressions (#9450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon's replay-suppression marks (pushDuplicateBatch / emitDuplicateBatch) re-added the replayed key only when the replay itself was stateful, while core's twin (carryStatefulStreakMarksAcrossSuppression) re-adds every live streak key on ANY replay suppression. A MIXED batch whose suppressed replay is NON-stateful (the provider re-emitting an already-handled read_file-style call id alongside an executable call) therefore left the polled task_list key in neither skip set once the previous poll's result mark was consumed: decayAbandonedDaemonStreaks wiped the live frozen-board streak at the next boundary and recomputed statefulMaxResultRepeat toward zero. Under the CLI default skipLoopDetection=true — where the cap's stateful stuck signal is the only live halt path — replays interleaved at <=5-poll intervals kept the peak below GLOBAL_DUPLICATE_THRESHOLD indefinitely and the turn ran toward the hard backstop, while core halts the identical event sequence just past the soft cap (requirement #6). Mirror core's carry: on any replay suppression, re-add every statefulResultStreaks key with consecutiveIdenticalResults > 0 to statefulResultKeysSinceLastBatch, in both pushDuplicateBatch (the batch's own boundary) and emitDuplicateBatch (the next boundary). Decayed streaks carry zero, so the carry only postpones an imminent decay by one boundary — it never resurrects an abandoned peak. Adds a Session.test.ts interleaving pinning the exact mixed non-stateful replay shape (frozen board, poll every 3rd round, halt at totalToolCalls 21); the pin fails with the carry reverted. Co-authored-by: Qwen-Coder --- .../acp-integration/session/Session.test.ts | 97 +++++++++++++++ .../src/acp-integration/session/Session.ts | 114 ++++++++++++------ 2 files changed, 176 insertions(+), 35 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 3754d608c08..c3d48f9a9d2 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -11589,6 +11589,103 @@ describe('Session', () => { expect(loopState.totalToolCalls).toBe(21); }); + it('still halts a frozen daemon poller when MIXED batches interleave a NON-stateful suppressed replay with an executable call (issue #9450)', async () => { + // Mixed-batch variant whose suppressed replay is NON-stateful + // (the provider re-emits an already-handled generic_tool call + // id): the replayed key itself carries no stateful mark, so the + // live frozen task_list streak survives the mixed boundaries + // only via the suppression carry — the daemon twin of core's + // carryStatefulStreakMarksAcrossSuppression, which re-adds the + // live streak keys on ANY replay suppression. Pre-fix the + // daemon's marks were gated on the replay itself being + // stateful, so once the previous poll's result mark was + // consumed the polled task_list key sat in NEITHER skip set + // (requestedStatefulKeys holds executable calls only) and + // decayAbandonedDaemonStreaks wiped the streak at the second + // mixed boundary of every cycle — statefulMaxResultRepeat + // oscillated 1,1,0,… below GLOBAL_DUPLICATE_THRESHOLD and the + // stuck signal never armed, while core carries the identical + // interleaving and halts just past the soft cap (issue #9450 + // requirement #6). + mockConfig.getApprovalMode = vi + .fn() + .mockReturnValue(ApprovalMode.YOLO); + mockConfig.getMaxToolCallsPerTurn = vi.fn().mockReturnValue(20); + mockConfig.isMaxToolCallsPerTurnExplicit = vi + .fn() + .mockReturnValue(false); + mockConfig.getSkipLoopDetection = vi.fn().mockReturnValue(true); + installTaskListAndGenericTools(() => 'frozen board'); + // Each replay round replays a DISTINCT already-handled id with + // the same (name, args) fingerprint, so its batch is suppressed + // without tripping the repeated-duplicate breaker. + const fingerprint = core.getToolCallFingerprint('generic_tool', { + step: 0, + }); + vi.mocked(mockChat.getHistoryToolCallFingerprints).mockReturnValue( + new Map( + Array.from({ length: 40 }, (_, index) => [ + `replayed_generic_${index}`, + fingerprint, + ]), + ), + ); + const loopState = freshLoopState(); + + let replayOrdinal = 0; + const runMixedRound = (round: number) => + ( + session as unknown as { + runToolCalls: ( + abortSignal: AbortSignal, + promptId: string, + calls: unknown[], + loopState: ReturnType, + ) => Promise<{ loopDetected?: boolean; parts: Part[] }>; + } + ).runToolCalls( + new AbortController().signal, + `prompt-mixed-nonstateful-${round}`, + [ + { + id: `replayed_generic_${replayOrdinal++}`, + name: 'generic_tool', + args: { step: 0 }, + }, + { + id: `generic_${round}`, + name: 'generic_tool', + args: { step: round }, + }, + ], + loopState, + ); + + let fired = false; + for (let round = 0; round < 60 && !fired; round++) { + if (round % 3 !== 0) { + // Two MIXED rounds per cycle (each one suppressed + // NON-stateful replay + one executable generic call): the + // first mixed boundary consumes the previous poll's result + // mark, so pre-fix the SECOND mixed boundary wiped the + // streak every cycle and the stuck signal never armed. + const mixedResult = await runMixedRound(round); + fired = mixedResult.loopDetected ?? false; + continue; + } + const result = await runTaskListPoll(loopState, round); + fired = result.loopDetected ?? false; + } + + // The carry protects the polled task_list key through every + // mixed boundary, so the streak arms the stuck signal exactly + // as in the stateful-replay control: the halt lands at + // totalToolCalls 21 (soft cap 20 + 1). + expect(fired).toBe(true); + expect(loopState.loopType).toBe(core.LoopType.TURN_TOOL_CALL_CAP); + expect(loopState.totalToolCalls).toBe(21); + }); + it('still halts a frozen daemon poller when a MIXED replay batch is followed by a gap batch (issue #9450)', async () => { // Mixed replay batch, then a GAP batch (other work, no // task_list), then the next poll — the shape the consecutive diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 37483a3bb4e..d68a046878b 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -889,6 +889,36 @@ function decayAbandonedDaemonStreaks( loopState.statefulMaxResultRepeat = peak; } +/** + * Daemon twin of core's carryStatefulStreakMarksAcrossSuppression (see + * loopDetectionService.noteSuppressedToolCallByCallId); the two runtimes + * must not drift (issue #9450 requirement #6). Called on ANY replay + * suppression — not only a stateful one: the carry is needed most when + * the suppressed replay is a NON-stateful tool (the provider re-emitting + * an already-handled read_file call id, say). A MIXED batch of that + * replay plus an executable call skips the empty-batch decay skip, and + * requestedStatefulKeys holds executable calls only, so without the carry + * the polled task_list key sits in NEITHER skip set once the previous + * result's mark is consumed — decayAbandonedDaemonStreaks wipes the live + * frozen-board streak and recomputes statefulMaxResultRepeat toward zero, + * keeping the stuck signal below GLOBAL_DUPLICATE_THRESHOLD indefinitely + * while core carries the identical interleaving and halts just past the + * soft cap. Decayed streaks carry consecutiveIdenticalResults === 0, so + * the carry never resurrects an abandoned streak — it only postpones an + * imminent decay by one boundary, exactly as the core twin. + */ +function carryStatefulStreakMarksAcrossDaemonSuppression( + loopState: DaemonToolLoopState, +): void { + const sinceLastBatch = (loopState.statefulResultKeysSinceLastBatch ??= + new Set()); + for (const [key, state] of loopState.statefulResultStreaks) { + if (state.consecutiveIdenticalResults > 0) { + sinceLastBatch.add(key); + } + } +} + function recordDaemonToolCalls( config: Config, promptId: string, @@ -9653,28 +9683,36 @@ export class Session implements SessionContext { this.duplicateProviderToolCallResponseIds, ); - // A suppressed stateful replay keeps its key alive across the batch - // boundary: mirror core's suppression mark (core marks - // statefulResultKeysSinceLastFinished in noteSuppressedToolCallByCallId) - // so the abandonment decay skips it. Without this, a MIXED batch — - // the suppressed replay alongside at least one executable call — - // skipped the empty-batch early return, found the replayed key in - // neither skip set (requestedStatefulKeys is built from executable - // calls only), and decayAbandonedDaemonStreaks wiped the live - // frozen-board streak — keeping statefulMaxResultRepeat below the - // stuck threshold indefinitely and drifting from core (issue #9450 - // requirement #6). This mark protects the replay batch's OWN - // boundary (recordDaemonToolCalls runs after batch construction and - // consumes it there); emitDuplicateBatch re-adds the key during the - // execution phase for the NEXT boundary, mirroring core's timing — - // core's mark lands when the fabricated response is submitted with - // the next round's ToolResult, after the replay stream's Finished + // A suppressed replay keeps the live stateful streaks alive across + // the batch boundary: mirror core's suppression handling (core marks + // statefulResultKeysSinceLastFinished in noteSuppressedToolCallByCallId). + // The carry re-adds EVERY key that still carries streak evidence on + // ANY replay suppression — needed most when the suppressed replay is + // a NON-stateful tool: a MIXED batch of that replay alongside an + // executable call would otherwise skip the empty-batch early return, + // find the polled task_list key in neither skip set + // (requestedStatefulKeys is built from executable calls only), and + // decayAbandonedDaemonStreaks would wipe the live frozen-board + // streak — keeping statefulMaxResultRepeat below the stuck threshold + // indefinitely and drifting from core (issue #9450 requirement #6). + // The replayed stateful key itself is marked unconditionally too, + // mirroring core's end-of-function mark: a suppression landing + // before the streak's first result must still protect its key. These + // marks protect the replay batch's OWN boundary + // (recordDaemonToolCalls runs after batch construction and consumes + // them there); emitDuplicateBatch re-adds during the execution + // phase for the NEXT boundary, mirroring core's timing — core's + // mark lands when the fabricated response is submitted with the + // next round's ToolResult, after the replay stream's Finished // boundary (issue #9450 requirement #6). - if (toolLoopState && isStatefulReadTool(request.name)) { - (toolLoopState.statefulResultKeysSinceLastBatch ??= - new Set()).add( - getToolCallRepeatKey(request.name, request.args), - ); + if (toolLoopState) { + carryStatefulStreakMarksAcrossDaemonSuppression(toolLoopState); + if (isStatefulReadTool(request.name)) { + (toolLoopState.statefulResultKeysSinceLastBatch ??= + new Set()).add( + getToolCallRepeatKey(request.name, request.args), + ); + } } const response = createDuplicateProviderToolCallResponse(request); @@ -9687,21 +9725,27 @@ export class Session implements SessionContext { const emitDuplicateBatch = async (batch: DuplicateBatch): Promise => { const { request, response } = batch; - // Next-boundary protection for the suppressed stateful replay: the - // mark pushDuplicateBatch added was consumed by THIS batch's own + // Next-boundary protection for the suppressed replay: the marks + // pushDuplicateBatch added were consumed by THIS batch's own // boundary decay (recordDaemonToolCalls ran after construction), so - // without a fresh mark a gap batch following a mixed replay batch - // would find the key in neither skip set and decay the live - // frozen-board streak — one boundary earlier than core's twin, whose - // suppression mark lands with the fabricated response AFTER the - // replay round's Finished boundary. Runs in the execution phase (the - // boundary has already run), so the mark survives to the next - // batch's decay (issue #9450 requirement #6). - if (toolLoopState && isStatefulReadTool(request.name)) { - (toolLoopState.statefulResultKeysSinceLastBatch ??= - new Set()).add( - getToolCallRepeatKey(request.name, request.args), - ); + // without fresh marks a gap batch following a mixed replay batch + // would find the live streak keys in neither skip set and decay the + // live frozen-board streak — one boundary earlier than core's twin, + // whose suppression mark lands with the fabricated response AFTER + // the replay round's Finished boundary. The carry mirrors core's on + // ANY replay suppression (a NON-stateful replay's own key marks + // nothing — its suppression must still protect the live streaks; + // see carryStatefulStreakMarksAcrossDaemonSuppression). Runs in the + // execution phase (the boundary has already run), so the marks + // survive to the next batch's decay (issue #9450 requirement #6). + if (toolLoopState) { + carryStatefulStreakMarksAcrossDaemonSuppression(toolLoopState); + if (isStatefulReadTool(request.name)) { + (toolLoopState.statefulResultKeysSinceLastBatch ??= + new Set()).add( + getToolCallRepeatKey(request.name, request.args), + ); + } } try { if (request.name === ToolNames.TODO_WRITE) { From dfadc01b19d93c1ceecbabf37a83a35c13435038 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Wed, 26 Aug 2026 20:23:12 +0800 Subject: [PATCH 49/51] refactor(core): trim PR back to the core result-aware loop-guard fix (#9450) The PR accreted 53 commits / +7634 lines over a week of review rounds, each adding speculative edge-case guards and tests far beyond the reported issue. This commit restores the PR to the original fix scope: - Keep the result-aware duplicate-detection core in loopDetectionService plus its attribution plumbing (client, agent-core, agent-headless, agent-interactive, agent-events, telemetry) and the matching tests, three-way merged onto current main. - Revert the 17 files that only carried accreted hardening (daemon Session path, truncation fingerprinting, tool-response-finalizer, synthetic-result exclusions, batch-budget fitting, decay logic, and their tests) to the main baseline. Net PR diff: 10 files, +1051/-14 (was 27 files, +7634/-44). Co-authored-by: Qwen-Coder --- .../acp-integration/session/Session.test.ts | 969 -------- .../src/acp-integration/session/Session.ts | 345 +-- packages/cli/src/nonInteractiveCli.test.ts | 94 - packages/cli/src/nonInteractiveCli.ts | 28 - .../cli/src/ui/hooks/useGeminiStream.test.tsx | 112 - packages/cli/src/ui/hooks/useGeminiStream.ts | 20 - .../src/agents/runtime/agent-core.test.ts | 308 --- .../core/src/agents/runtime/agent-core.ts | 67 +- .../core/src/agents/runtime/agent-events.ts | 6 +- .../src/agents/runtime/agent-headless.test.ts | 484 +--- .../core/src/agents/runtime/agent-headless.ts | 11 - .../agents/runtime/agent-interactive.test.ts | 50 +- packages/core/src/core/client.test.ts | 390 ---- packages/core/src/core/client.ts | 50 +- packages/core/src/core/turn.ts | 27 +- packages/core/src/index.ts | 2 - .../src/services/loopDetectionService.test.ts | 1984 +---------------- .../core/src/services/loopDetectionService.ts | 835 +------ .../telemetry/qwen-logger/qwen-logger.test.ts | 59 - packages/core/src/tools/agent/agent.test.ts | 37 - packages/core/src/tools/agent/agent.ts | 17 +- .../src/tools/tool-response-finalizer.test.ts | 548 +---- .../core/src/tools/tool-response-finalizer.ts | 97 +- packages/core/src/tools/truncation.ts | 183 +- 24 files changed, 85 insertions(+), 6638 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 20dbea95b07..35fbc38ddfa 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -9695,14 +9695,6 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, - statefulResultStreaks: new Map< - string, - { - consecutiveIdenticalResults: number; - lastFingerprint: string | undefined; - } - >(), - statefulMaxResultRepeat: 0, loopDetected: false, }; @@ -9766,14 +9758,6 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, - statefulResultStreaks: new Map< - string, - { - consecutiveIdenticalResults: number; - lastFingerprint: string | undefined; - } - >(), - statefulMaxResultRepeat: 0, loopDetected: false, }; @@ -9840,14 +9824,6 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, - statefulResultStreaks: new Map< - string, - { - consecutiveIdenticalResults: number; - lastFingerprint: string | undefined; - } - >(), - statefulMaxResultRepeat: 0, loopDetected: false, }; @@ -10074,14 +10050,6 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, - statefulResultStreaks: new Map< - string, - { - consecutiveIdenticalResults: number; - lastFingerprint: string | undefined; - } - >(), - statefulMaxResultRepeat: 0, loopDetected: false, }; const calls = Array.from({ length: 5 }, (_, index) => ({ @@ -10145,14 +10113,6 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, - statefulResultStreaks: new Map< - string, - { - consecutiveIdenticalResults: number; - lastFingerprint: string | undefined; - } - >(), - statefulMaxResultRepeat: 0, loopDetected: false, }; // Six identical (tool, args) calls below the cap. Core's always-on @@ -10222,14 +10182,6 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, - statefulResultStreaks: new Map< - string, - { - consecutiveIdenticalResults: number; - lastFingerprint: string | undefined; - } - >(), - statefulMaxResultRepeat: 0, loopDetected: false, }; const calls = Array.from({ length: 6 }, (_, index) => ({ @@ -10309,14 +10261,6 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, - statefulResultStreaks: new Map< - string, - { - consecutiveIdenticalResults: number; - lastFingerprint: string | undefined; - } - >(), - statefulMaxResultRepeat: 0, loopDetected: false, }; // The prompt loop calls runToolCalls once per model response against @@ -10412,14 +10356,6 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, - statefulResultStreaks: new Map< - string, - { - consecutiveIdenticalResults: number; - lastFingerprint: string | undefined; - } - >(), - statefulMaxResultRepeat: 0, loopDetected: false, }; // Six identical (tool, args) calls in one batch — three execute, and @@ -10520,14 +10456,6 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, - statefulResultStreaks: new Map< - string, - { - consecutiveIdenticalResults: number; - lastFingerprint: string | undefined; - } - >(), - statefulMaxResultRepeat: 0, loopDetected: false, }; // One diverse call plus six identical ones push the turn past the @@ -10623,14 +10551,6 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, - statefulResultStreaks: new Map< - string, - { - consecutiveIdenticalResults: number; - lastFingerprint: string | undefined; - } - >(), - statefulMaxResultRepeat: 0, loopDetected: false, }; const calls = [ @@ -10719,14 +10639,6 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, - statefulResultStreaks: new Map< - string, - { - consecutiveIdenticalResults: number; - lastFingerprint: string | undefined; - } - >(), - statefulMaxResultRepeat: 0, loopDetected: false, }; // 29 prior calls + a batch of 2 diverse calls crosses the backstop @@ -10833,14 +10745,6 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, - statefulResultStreaks: new Map< - string, - { - consecutiveIdenticalResults: number; - lastFingerprint: string | undefined; - } - >(), - statefulMaxResultRepeat: 0, loopDetected: false, }; const result = await ( @@ -10933,14 +10837,6 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, - statefulResultStreaks: new Map< - string, - { - consecutiveIdenticalResults: number; - lastFingerprint: string | undefined; - } - >(), - statefulMaxResultRepeat: 0, loopDetected: false, }; @@ -11028,14 +10924,6 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, - statefulResultStreaks: new Map< - string, - { - consecutiveIdenticalResults: number; - lastFingerprint: string | undefined; - } - >(), - statefulMaxResultRepeat: 0, loopDetected: false, }; @@ -11169,815 +11057,6 @@ describe('Session', () => { secondFollowUp.message[0].functionResponse?.response?.['error'], ).toContain('Duplicate provider tool call id "shell_1"'); }); - - describe('result-aware daemon guard for stateful reads (issue #9450)', () => { - const TASK_LIST_ARGS = { - status: 'in_progress', - owner: 'peer-a', - blockedBy: '', - }; - - const freshLoopState = () => ({ - totalToolCalls: 0, - invalidToolParamErrors: new Map(), - toolCallKeyCounts: new Map(), - maxToolCallKeyRepeat: 0, - statefulResultStreaks: new Map< - string, - { - consecutiveIdenticalResults: number; - lastFingerprint: string | undefined; - } - >(), - statefulMaxResultRepeat: 0, - loopDetected: false, - loopType: undefined as core.LoopType | undefined, - }); - - // A task_list mock whose executed result changes (or freezes) per - // call, mirroring a task board peers keep mutating. - const installTaskListTool = (boards: () => string) => { - const execute = vi.fn().mockImplementation(async () => ({ - llmContent: boards(), - returnDisplay: 'ok', - })); - mockToolRegistry.getTool.mockImplementation((name: string) => - name === 'task_list' - ? { - name: 'task_list', - kind: core.Kind.Read, - displayName: 'TaskList', - description: 'TaskList', - build: vi.fn().mockImplementation((args) => ({ - params: args, - getDefaultPermission: vi.fn().mockResolvedValue('allow'), - getDescription: vi.fn().mockReturnValue('task_list'), - toolLocations: vi.fn().mockReturnValue([]), - execute, - })), - canUpdateOutput: false, - isOutputMarkdown: false, - } - : undefined, - ); - return execute; - }; - - // Like installTaskListTool, but every non-task_list tool also - // resolves to an executable mock so diverse productive calls run - // instead of tripping the missing-tool guard. - const installTaskListAndGenericTools = (boards: () => string) => { - mockToolRegistry.getTool.mockImplementation((name: string) => { - const execute = - name === 'task_list' - ? vi.fn().mockImplementation(async () => ({ - llmContent: boards(), - returnDisplay: 'ok', - })) - : vi.fn().mockResolvedValue({ - llmContent: 'ok', - returnDisplay: 'ok', - }); - return { - name, - kind: core.Kind.Read, - displayName: name, - description: name, - build: vi.fn().mockImplementation((args) => ({ - params: args, - getDefaultPermission: vi.fn().mockResolvedValue('allow'), - getDescription: vi.fn().mockReturnValue(name), - toolLocations: vi.fn().mockReturnValue([]), - execute, - })), - canUpdateOutput: false, - isOutputMarkdown: false, - }; - }); - }; - - const runTaskListPoll = ( - loopState: ReturnType, - round: number, - ) => - ( - session as unknown as { - runToolCalls: ( - abortSignal: AbortSignal, - promptId: string, - calls: unknown[], - loopState: ReturnType, - ) => Promise<{ loopDetected?: boolean; parts: Part[] }>; - } - ).runToolCalls( - new AbortController().signal, - `prompt-task-list-${round}`, - [ - { - id: `task_list_${round}`, - name: 'task_list', - args: TASK_LIST_ARGS, - }, - ], - loopState, - ); - - it('does not halt a daemon task_list poller while the board keeps changing (skipLoopDetection=false)', async () => { - // The exact #9450 shape on the daemon runtime: identical args, - // every executed result differing. Pre-fix the request-time - // global-duplicate mirror halted at the 6th identical-args poll. - mockConfig.getApprovalMode = vi - .fn() - .mockReturnValue(ApprovalMode.YOLO); - mockConfig.getMaxToolCallsPerTurn = vi.fn().mockReturnValue(100); - mockConfig.isMaxToolCallsPerTurnExplicit = vi - .fn() - .mockReturnValue(false); - mockConfig.getSkipLoopDetection = vi.fn().mockReturnValue(false); - let boardVersion = 0; - const execute = installTaskListTool( - () => `board state v${++boardVersion}`, - ); - const loopState = freshLoopState(); - - for (let round = 0; round < 8; round++) { - const result = await runTaskListPoll(loopState, round); - expect(result.loopDetected ?? false).toBe(false); - } - expect(execute).toHaveBeenCalledTimes(8); - expect(loopState.maxToolCallKeyRepeat).toBe(0); - expect(loopState.loopDetected).toBe(false); - }); - - it('still halts a daemon task_list poller on a frozen board (skipLoopDetection=false)', async () => { - mockConfig.getApprovalMode = vi - .fn() - .mockReturnValue(ApprovalMode.YOLO); - mockConfig.getMaxToolCallsPerTurn = vi.fn().mockReturnValue(100); - mockConfig.isMaxToolCallsPerTurnExplicit = vi - .fn() - .mockReturnValue(false); - mockConfig.getSkipLoopDetection = vi.fn().mockReturnValue(false); - installTaskListTool(() => 'frozen board'); - const loopState = freshLoopState(); - - let haltedAt = -1; - for (let round = 0; round < 8 && haltedAt < 0; round++) { - const result = await runTaskListPoll(loopState, round); - if (result.loopDetected) haltedAt = round; - } - // The 6th identical result trips the result-time global-duplicate - // mirror (GLOBAL_DUPLICATE_THRESHOLD = 6); the 6th request is not - // executed because the batch is skipped whole. - expect(haltedAt).toBeGreaterThanOrEqual(0); - expect(loopState.loopType).toBe( - core.LoopType.GLOBAL_TOOL_CALL_DUPLICATE, - ); - }); - - it('keeps a frozen-then-thawed daemon poller alive past the adaptive cap', async () => { - // CLI defaults: skipLoopDetection=true, adaptive soft cap. A frozen - // phase builds the result-time stuck signal; once the board thaws - // the signal must disarm so productive polling continues past the - // soft cap (the cap-ratchet regression on the daemon mirror). - mockConfig.getApprovalMode = vi - .fn() - .mockReturnValue(ApprovalMode.YOLO); - mockConfig.getMaxToolCallsPerTurn = vi.fn().mockReturnValue(20); - mockConfig.isMaxToolCallsPerTurnExplicit = vi - .fn() - .mockReturnValue(false); - mockConfig.getSkipLoopDetection = vi.fn().mockReturnValue(true); - const boards: string[] = []; - for (let i = 0; i < 6; i++) boards.push('frozen board'); - let thawed = false; - const execute = installTaskListTool(() => { - if (boards.length > 0) return boards.shift()!; - thawed = true; - return `thawed board v${execute.mock.calls.length}`; - }); - const loopState = freshLoopState(); - - let fired = false; - for (let round = 0; round < 40 && !fired; round++) { - const result = await runTaskListPoll(loopState, round); - fired = result.loopDetected ?? false; - } - expect(fired).toBe(false); - expect(thawed).toBe(true); - expect(loopState.loopDetected).toBe(false); - }); - - it('releases the daemon cap when a frozen task_list poller is abandoned for productive work', async () => { - // CLI defaults: skipLoopDetection=true, adaptive soft cap — the - // cap's stateful stuck signal is the ONLY live halt path. The - // streak map must not latch a stale peak from an abandoned key: - // interleaved frozen polls peak the signal, then the model stops - // polling and does diverse productive work. Pre-fix the add-only - // streak map kept the peak for the whole turn, so the productive - // turn was halted as TURN_TOOL_CALL_CAP just past the soft cap - // (issue #9450; core twin in loopDetectionService). - mockConfig.getApprovalMode = vi - .fn() - .mockReturnValue(ApprovalMode.YOLO); - mockConfig.getMaxToolCallsPerTurn = vi.fn().mockReturnValue(20); - mockConfig.isMaxToolCallsPerTurnExplicit = vi - .fn() - .mockReturnValue(false); - mockConfig.getSkipLoopDetection = vi.fn().mockReturnValue(true); - installTaskListAndGenericTools(() => 'frozen board'); - const loopState = freshLoopState(); - - // 8 interleaved frozen task_list polls: the stuck signal peaks at - // 8 without halting (still under the soft cap). - for (let round = 0; round < 8; round++) { - const result = await runTaskListPoll(loopState, round); - expect(result.loopDetected ?? false).toBe(false); - } - expect(loopState.statefulMaxResultRepeat).toBe(8); - - // Abandon polling; do diverse productive work past the soft cap - // of 20. The abandoned key's peak must decay at the batch - // boundaries, so no TURN_TOOL_CALL_CAP halt fires. - const runDiverseBatch = (round: number) => - ( - session as unknown as { - runToolCalls: ( - abortSignal: AbortSignal, - promptId: string, - calls: unknown[], - loopState: ReturnType, - ) => Promise<{ loopDetected?: boolean; parts: Part[] }>; - } - ).runToolCalls( - new AbortController().signal, - `prompt-diverse-${round}`, - [ - { - id: `diverse_${round}`, - name: 'generic_tool', - args: { step: round }, - }, - ], - loopState, - ); - - let fired = false; - for (let round = 0; round < 20 && !fired; round++) { - const result = await runDiverseBatch(round); - fired = result.loopDetected ?? false; - } - expect(fired).toBe(false); - expect(loopState.loopDetected).toBe(false); - expect(loopState.totalToolCalls).toBeGreaterThan(20); - }); - - it('still halts a frozen daemon poller when replay-only rounds are interleaved in the streak (issue #9450)', async () => { - // CLI defaults: skipLoopDetection=true, adaptive soft cap — the - // cap's stateful stuck signal is the ONLY live halt path. A - // replay-suppressed round (every call pushed as a duplicate batch) - // executes nothing and records zero results BY DESIGN; the - // batch-boundary decay must not mistake it for abandonment and - // wipe the live frozen-board streak. Pre-fix, replays interleaved - // at <=5-poll intervals kept statefulMaxResultRepeat below the - // stuck threshold indefinitely while the replay batches added 0 - // to totalToolCalls — the detected stuck loop ran to the hard - // backstop instead of halting just past the soft cap. - mockConfig.getApprovalMode = vi - .fn() - .mockReturnValue(ApprovalMode.YOLO); - mockConfig.getMaxToolCallsPerTurn = vi.fn().mockReturnValue(20); - mockConfig.isMaxToolCallsPerTurnExplicit = vi - .fn() - .mockReturnValue(false); - mockConfig.getSkipLoopDetection = vi.fn().mockReturnValue(true); - const execute = installTaskListTool(() => 'frozen board'); - // Each replay round replays a DISTINCT already-handled id with the - // same (name, args) fingerprint, so its batch is suppressed whole - // without tripping the repeated-duplicate breaker (which fires on - // a second replay of the SAME id — a different, also-correct halt). - const fingerprint = core.getToolCallFingerprint( - 'task_list', - TASK_LIST_ARGS, - ); - vi.mocked(mockChat.getHistoryToolCallFingerprints).mockReturnValue( - new Map( - Array.from({ length: 15 }, (_, index) => [ - `replayed_task_list_${index}`, - fingerprint, - ]), - ), - ); - const loopState = freshLoopState(); - - let replayOrdinal = 0; - const runReplayRound = (round: number) => - ( - session as unknown as { - runToolCalls: ( - abortSignal: AbortSignal, - promptId: string, - calls: unknown[], - loopState: ReturnType, - ) => Promise<{ loopDetected?: boolean; parts: Part[] }>; - } - ).runToolCalls( - new AbortController().signal, - `prompt-replay-${round}`, - [ - { - id: `replayed_task_list_${replayOrdinal++}`, - name: 'task_list', - args: TASK_LIST_ARGS, - }, - ], - loopState, - ); - - let fired = false; - for (let round = 0; round < 60 && !fired; round++) { - if (round > 0 && round % 5 === 4) { - // Every fifth round is a replay-only round: zero executable - // calls, zero recorded results (four executed polls between - // replays, matching the finding's <=5-poll interleave). - const replayResult = await runReplayRound(round); - expect(replayResult.loopDetected ?? false).toBe(false); - expect( - (replayResult.parts[0]?.functionResponse?.response?.[ - 'error' - ] as string) ?? '', - ).toContain('Duplicate provider tool call id'); - continue; - } - const result = await runTaskListPoll(loopState, round); - fired = result.loopDetected ?? false; - } - - // The streak survives the replay rounds and arms the cap's stuck - // signal: the halt lands at totalToolCalls 21 (soft cap 20 + 1), - // before the 21st poll executes. - expect(fired).toBe(true); - expect(loopState.loopType).toBe(core.LoopType.TURN_TOOL_CALL_CAP); - expect(loopState.totalToolCalls).toBe(21); - expect(execute).toHaveBeenCalledTimes(20); - }); - - it('still halts a frozen daemon poller when MIXED batches interleave a suppressed replay with an executable call (issue #9450)', async () => { - // Mixed-batch variant of the replay-interleave regression: one - // executed poll per cycle, then two MIXED rounds that each - // suppress a task_list replay alongside an EXECUTABLE generic - // call. Pre-fix the batch was non-empty (no - // calls.length === 0 early return), requestedStatefulKeys held - // only the executable call, and once the previous result's mark - // was consumed the replayed key sat in neither skip set — - // decayAbandonedDaemonStreaks wiped the frozen-board streak and - // recomputed statefulMaxResultRepeat to 0, so the stuck signal - // never reached GLOBAL_DUPLICATE_THRESHOLD and the detected - // stuck loop ran to the hard backstop (core survives this shape - // via noteSuppressedToolCallByCallId's mark; requirement #6). - mockConfig.getApprovalMode = vi - .fn() - .mockReturnValue(ApprovalMode.YOLO); - mockConfig.getMaxToolCallsPerTurn = vi.fn().mockReturnValue(20); - mockConfig.isMaxToolCallsPerTurnExplicit = vi - .fn() - .mockReturnValue(false); - mockConfig.getSkipLoopDetection = vi.fn().mockReturnValue(true); - installTaskListAndGenericTools(() => 'frozen board'); - const fingerprint = core.getToolCallFingerprint( - 'task_list', - TASK_LIST_ARGS, - ); - vi.mocked(mockChat.getHistoryToolCallFingerprints).mockReturnValue( - new Map( - Array.from({ length: 15 }, (_, index) => [ - `replayed_task_list_${index}`, - fingerprint, - ]), - ), - ); - const loopState = freshLoopState(); - - let replayOrdinal = 0; - const runMixedRound = (round: number) => - ( - session as unknown as { - runToolCalls: ( - abortSignal: AbortSignal, - promptId: string, - calls: unknown[], - loopState: ReturnType, - ) => Promise<{ loopDetected?: boolean; parts: Part[] }>; - } - ).runToolCalls( - new AbortController().signal, - `prompt-mixed-${round}`, - [ - { - id: `replayed_task_list_${replayOrdinal++}`, - name: 'task_list', - args: TASK_LIST_ARGS, - }, - { - id: `generic_${round}`, - name: 'generic_tool', - args: { step: round }, - }, - ], - loopState, - ); - - let fired = false; - for (let round = 0; round < 60 && !fired; round++) { - if (round % 3 !== 0) { - // Two MIXED rounds per cycle (each one suppressed replay + - // one executable generic call): the first mixed boundary - // consumes the previous poll's result mark, so pre-fix the - // SECOND mixed boundary wiped the streak every cycle - // (peakSeries 1,1,0,…) and the stuck signal never armed. - const mixedResult = await runMixedRound(round); - fired = mixedResult.loopDetected ?? false; - continue; - } - const result = await runTaskListPoll(loopState, round); - fired = result.loopDetected ?? false; - } - - // The suppression mark carries the replayed key through the - // mixed-batch boundaries, so the streak arms the stuck signal - // exactly as in the replay-only control: the halt lands at - // totalToolCalls 21 (soft cap 20 + 1). - expect(fired).toBe(true); - expect(loopState.loopType).toBe(core.LoopType.TURN_TOOL_CALL_CAP); - expect(loopState.totalToolCalls).toBe(21); - }); - - it('still halts a frozen daemon poller when MIXED batches interleave a NON-stateful suppressed replay with an executable call (issue #9450)', async () => { - // Mixed-batch variant whose suppressed replay is NON-stateful - // (the provider re-emits an already-handled generic_tool call - // id): the replayed key itself carries no stateful mark, so the - // live frozen task_list streak survives the mixed boundaries - // only via the suppression carry — the daemon twin of core's - // carryStatefulStreakMarksAcrossSuppression, which re-adds the - // live streak keys on ANY replay suppression. Pre-fix the - // daemon's marks were gated on the replay itself being - // stateful, so once the previous poll's result mark was - // consumed the polled task_list key sat in NEITHER skip set - // (requestedStatefulKeys holds executable calls only) and - // decayAbandonedDaemonStreaks wiped the streak at the second - // mixed boundary of every cycle — statefulMaxResultRepeat - // oscillated 1,1,0,… below GLOBAL_DUPLICATE_THRESHOLD and the - // stuck signal never armed, while core carries the identical - // interleaving and halts just past the soft cap (issue #9450 - // requirement #6). - mockConfig.getApprovalMode = vi - .fn() - .mockReturnValue(ApprovalMode.YOLO); - mockConfig.getMaxToolCallsPerTurn = vi.fn().mockReturnValue(20); - mockConfig.isMaxToolCallsPerTurnExplicit = vi - .fn() - .mockReturnValue(false); - mockConfig.getSkipLoopDetection = vi.fn().mockReturnValue(true); - installTaskListAndGenericTools(() => 'frozen board'); - // Each replay round replays a DISTINCT already-handled id with - // the same (name, args) fingerprint, so its batch is suppressed - // without tripping the repeated-duplicate breaker. - const fingerprint = core.getToolCallFingerprint('generic_tool', { - step: 0, - }); - vi.mocked(mockChat.getHistoryToolCallFingerprints).mockReturnValue( - new Map( - Array.from({ length: 40 }, (_, index) => [ - `replayed_generic_${index}`, - fingerprint, - ]), - ), - ); - const loopState = freshLoopState(); - - let replayOrdinal = 0; - const runMixedRound = (round: number) => - ( - session as unknown as { - runToolCalls: ( - abortSignal: AbortSignal, - promptId: string, - calls: unknown[], - loopState: ReturnType, - ) => Promise<{ loopDetected?: boolean; parts: Part[] }>; - } - ).runToolCalls( - new AbortController().signal, - `prompt-mixed-nonstateful-${round}`, - [ - { - id: `replayed_generic_${replayOrdinal++}`, - name: 'generic_tool', - args: { step: 0 }, - }, - { - id: `generic_${round}`, - name: 'generic_tool', - args: { step: round }, - }, - ], - loopState, - ); - - let fired = false; - for (let round = 0; round < 60 && !fired; round++) { - if (round % 3 !== 0) { - // Two MIXED rounds per cycle (each one suppressed - // NON-stateful replay + one executable generic call): the - // first mixed boundary consumes the previous poll's result - // mark, so pre-fix the SECOND mixed boundary wiped the - // streak every cycle and the stuck signal never armed. - const mixedResult = await runMixedRound(round); - fired = mixedResult.loopDetected ?? false; - continue; - } - const result = await runTaskListPoll(loopState, round); - fired = result.loopDetected ?? false; - } - - // The carry protects the polled task_list key through every - // mixed boundary, so the streak arms the stuck signal exactly - // as in the stateful-replay control: the halt lands at - // totalToolCalls 21 (soft cap 20 + 1). - expect(fired).toBe(true); - expect(loopState.loopType).toBe(core.LoopType.TURN_TOOL_CALL_CAP); - expect(loopState.totalToolCalls).toBe(21); - }); - - it('still halts a frozen daemon poller when a MIXED replay batch is followed by a gap batch (issue #9450)', async () => { - // Mixed replay batch, then a GAP batch (other work, no - // task_list), then the next poll — the shape the consecutive - // mixed-batch control above cannot see. Pre-fix the suppression - // mark was added during batch construction and consumed by the - // replay batch's OWN boundary decay (which the previous poll's - // result mark already protected), leaving the NEXT boundary — - // the gap batch's — exposed: decayAbandonedDaemonStreaks wiped - // the live frozen-board streak there, one boundary earlier than - // core's twin, whose noteSuppressedToolCallByCallId mark lands - // with the fabricated response AFTER the replay round's - // Finished boundary. The streak restarted every cycle and never - // reached the stuck threshold, so the turn ran past the soft - // cap toward the hard backstop instead of halting (issue #9450 - // requirement #6). - mockConfig.getApprovalMode = vi - .fn() - .mockReturnValue(ApprovalMode.YOLO); - mockConfig.getMaxToolCallsPerTurn = vi.fn().mockReturnValue(20); - mockConfig.isMaxToolCallsPerTurnExplicit = vi - .fn() - .mockReturnValue(false); - mockConfig.getSkipLoopDetection = vi.fn().mockReturnValue(true); - installTaskListAndGenericTools(() => 'frozen board'); - const fingerprint = core.getToolCallFingerprint( - 'task_list', - TASK_LIST_ARGS, - ); - vi.mocked(mockChat.getHistoryToolCallFingerprints).mockReturnValue( - new Map( - Array.from({ length: 25 }, (_, index) => [ - `replayed_task_list_${index}`, - fingerprint, - ]), - ), - ); - const loopState = freshLoopState(); - - let replayOrdinal = 0; - const runRound = (round: number) => { - const calls = - round % 3 === 0 - ? [ - { - id: `task_list_${round}`, - name: 'task_list', - args: TASK_LIST_ARGS, - }, - ] - : round % 3 === 1 - ? [ - { - id: `replayed_task_list_${replayOrdinal++}`, - name: 'task_list', - args: TASK_LIST_ARGS, - }, - { - id: `generic_${round}`, - name: 'generic_tool', - args: { step: round }, - }, - ] - : [ - { - id: `generic_${round}`, - name: 'generic_tool', - args: { step: round }, - }, - ]; - return ( - session as unknown as { - runToolCalls: ( - abortSignal: AbortSignal, - promptId: string, - calls: unknown[], - loopState: ReturnType, - ) => Promise<{ loopDetected?: boolean; parts: Part[] }>; - } - ).runToolCalls( - new AbortController().signal, - `prompt-mixed-gap-${round}`, - calls, - loopState, - ); - }; - - let fired = false; - for (let round = 0; round < 60 && !fired; round++) { - const result = await runRound(round); - fired = result.loopDetected ?? false; - } - - // The emit-phase mark gives the replayed key next-boundary - // protection, so the streak survives the mixed→gap cycles and - // arms the stuck signal: the halt lands at totalToolCalls 21 - // (soft cap 20 + 1), far below the hard backstop (200). - expect(fired).toBe(true); - expect(loopState.loopType).toBe(core.LoopType.TURN_TOOL_CALL_CAP); - expect(loopState.totalToolCalls).toBe(21); - }); - - it('still halts a frozen daemon poller when NON-STATEFUL replay-only rounds are interleaved (issue #9450 requirement #6)', async () => { - // Non-stateful twin of the replay-only regression: the replayed - // id belongs to a NON-stateful tool (generic_tool), so no - // suppression mark exists for it anywhere — the empty-batch - // early return in recordDaemonToolCalls is the ONLY mechanism - // carrying the last executed round's result marks across the - // replay batch to the gap batch's decay. task_list is the only - // stateful tool, so every other replayed tool takes this path; - // without the skip the daemon would wipe the frozen streak and - // run to the hard backstop while the exact same event sequence - // halts core just past the soft cap (requirement #6 parity — - // core's twin carries via noteSuppressedToolCallByCallId's - // replaySuppression mark). - mockConfig.getApprovalMode = vi - .fn() - .mockReturnValue(ApprovalMode.YOLO); - mockConfig.getMaxToolCallsPerTurn = vi.fn().mockReturnValue(20); - mockConfig.isMaxToolCallsPerTurnExplicit = vi - .fn() - .mockReturnValue(false); - mockConfig.getSkipLoopDetection = vi.fn().mockReturnValue(true); - installTaskListAndGenericTools(() => 'frozen board'); - const fingerprint = core.getToolCallFingerprint('generic_tool', { - step: 0, - }); - vi.mocked(mockChat.getHistoryToolCallFingerprints).mockReturnValue( - new Map( - Array.from({ length: 30 }, (_, index) => [ - `replayed_generic_${index}`, - fingerprint, - ]), - ), - ); - const loopState = freshLoopState(); - - let replayOrdinal = 0; - const runRound = (round: number) => { - const calls = - round % 3 === 0 - ? [ - { - id: `task_list_${round}`, - name: 'task_list', - args: TASK_LIST_ARGS, - }, - ] - : round % 3 === 1 - ? [ - { - id: `replayed_generic_${replayOrdinal++}`, - name: 'generic_tool', - args: { step: 0 }, - }, - ] - : [ - { - id: `generic_${round}`, - name: 'generic_tool', - args: { step: round }, - }, - ]; - return ( - session as unknown as { - runToolCalls: ( - abortSignal: AbortSignal, - promptId: string, - calls: unknown[], - loopState: ReturnType, - ) => Promise<{ loopDetected?: boolean; parts: Part[] }>; - } - ).runToolCalls( - new AbortController().signal, - `prompt-nonstateful-replay-${round}`, - calls, - loopState, - ); - }; - - let fired = false; - for (let round = 0; round < 90 && !fired; round++) { - const result = await runRound(round); - if (round % 3 === 1) { - // The replay-only batch is suppressed whole and executes - // nothing (its repeat keys never count toward the stuck - // signal — only executable calls do). - expect(result.loopDetected ?? false).toBe(false); - expect( - (result.parts[0]?.functionResponse?.response?.[ - 'error' - ] as string) ?? '', - ).toContain('Duplicate provider tool call id'); - continue; - } - fired = result.loopDetected ?? false; - } - - // The empty-batch decay skip carries the poll's result mark - // through the replay batch, so the streak survives the gap - // batch's boundary and arms the stuck signal: the halt lands at - // totalToolCalls 21 (soft cap 20 + 1; replay batches add 0), - // far below the hard backstop (200). - expect(fired).toBe(true); - expect(loopState.loopType).toBe(core.LoopType.TURN_TOOL_CALL_CAP); - expect(loopState.totalToolCalls).toBe(21); - }); - - it('still halts a frozen daemon poller interleaved every other batch with other work (issue #9450)', async () => { - // CLI defaults: skipLoopDetection=true, adaptive soft cap — the - // cap's stateful stuck signal is the ONLY live halt path. A - // frozen board polled every OTHER batch between varied work: - // pre-fix the poll batch's boundary found the key absent from - // the result set (the gap batch recorded no task_list result and - // consumed the previous mark at its own boundary), decayed the - // streak back to zero, and the stuck signal never armed — the - // turn ran to the hard backstop. The re-requested skip (mirror - // of core's requested-keys skip in - // decayAbandonedStatefulStreaks) keeps the streak alive across - // gap batches; the runtimes must not drift (requirement #6). - mockConfig.getApprovalMode = vi - .fn() - .mockReturnValue(ApprovalMode.YOLO); - mockConfig.getMaxToolCallsPerTurn = vi.fn().mockReturnValue(20); - mockConfig.isMaxToolCallsPerTurnExplicit = vi - .fn() - .mockReturnValue(false); - mockConfig.getSkipLoopDetection = vi.fn().mockReturnValue(true); - installTaskListAndGenericTools(() => 'frozen board'); - const loopState = freshLoopState(); - - const runDiverseBatch = (round: number) => - ( - session as unknown as { - runToolCalls: ( - abortSignal: AbortSignal, - promptId: string, - calls: unknown[], - loopState: ReturnType, - ) => Promise<{ loopDetected?: boolean; parts: Part[] }>; - } - ).runToolCalls( - new AbortController().signal, - `prompt-diverse-${round}`, - [ - { - id: `diverse_${round}`, - name: 'generic_tool', - args: { step: round }, - }, - ], - loopState, - ); - - let fired = false; - for (let round = 0; round < 40 && !fired; round++) { - const poll = await runTaskListPoll(loopState, round); - fired = poll.loopDetected ?? false; - if (fired) break; - const gap = await runDiverseBatch(round); - fired = gap.loopDetected ?? false; - } - // The streak survives the gap batches and arms the stuck signal: - // the halt lands just past the soft cap (20), far below the hard - // backstop (200). - expect(fired).toBe(true); - expect(loopState.loopType).toBe(core.LoopType.TURN_TOOL_CALL_CAP); - expect(loopState.totalToolCalls).toBeLessThanOrEqual(24); - }); - }); }); describe('repeated tool execution failure guard', () => { @@ -12514,14 +11593,6 @@ describe('Session', () => { invalidToolParamErrors: Map; toolCallKeyCounts: Map; maxToolCallKeyRepeat: number; - statefulResultStreaks: Map< - string, - { - consecutiveIdenticalResults: number; - lastFingerprint: string | undefined; - } - >; - statefulMaxResultRepeat: number; loopDetected: boolean; }, ) => Promise; @@ -12535,14 +11606,6 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, - statefulResultStreaks: new Map< - string, - { - consecutiveIdenticalResults: number; - lastFingerprint: string | undefined; - } - >(), - statefulMaxResultRepeat: 0, loopDetected: false, }, ); @@ -28734,14 +27797,6 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, - statefulResultStreaks: new Map< - string, - { - consecutiveIdenticalResults: number; - lastFingerprint: string | undefined; - } - >(), - statefulMaxResultRepeat: 0, loopDetected: false, repeatedToolFailureMode: 'off', repeatedToolFailureState: createRepeatedToolFailureGuardState(), @@ -29483,14 +28538,6 @@ describe('Session', () => { invalidToolParamErrors: new Map([[core.ToolNames.AGENT, 2]]), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, - statefulResultStreaks: new Map< - string, - { - consecutiveIdenticalResults: number; - lastFingerprint: string | undefined; - } - >(), - statefulMaxResultRepeat: 0, loopDetected: false, repeatedToolFailureMode: 'off', repeatedToolFailureState: createRepeatedToolFailureGuardState(), @@ -29590,14 +28637,6 @@ describe('Session', () => { invalidToolParamErrors: new Map([[core.ToolNames.AGENT, 2]]), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, - statefulResultStreaks: new Map< - string, - { - consecutiveIdenticalResults: number; - lastFingerprint: string | undefined; - } - >(), - statefulMaxResultRepeat: 0, loopDetected: false, repeatedToolFailureMode: 'off', repeatedToolFailureState: createRepeatedToolFailureGuardState(), @@ -30712,14 +29751,6 @@ describe('Session', () => { invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, - statefulResultStreaks: new Map< - string, - { - consecutiveIdenticalResults: number; - lastFingerprint: string | undefined; - } - >(), - statefulMaxResultRepeat: 0, loopDetected: false, repeatedToolFailureMode: 'off', repeatedToolFailureState: createRepeatedToolFailureGuardState(), diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index bea872c7394..8227b9b8ae4 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -157,9 +157,7 @@ import { ConversationFinishedEvent, GLOBAL_DUPLICATE_THRESHOLD, canonicalToolName, - fingerprintToolResult, getToolCallRepeatKey, - isStatefulReadTool, shouldHaltOnTurnToolCallCap, logLoopDetected, logRepeatedToolFailureGuard, @@ -643,34 +641,6 @@ export type DaemonToolLoopState = { toolCallKeyCounts: Map; /** Highest repeat count of any single (tool, args) pair this turn. */ maxToolCallKeyRepeat: number; - /** - * Result-aware evidence for stateful read tools (issue #9450), keyed by - * repeat key — mirrors core's LoopDetectionService.statefulRepeatState. - * `consecutiveIdenticalResults` counts executed results that repeat the - * key's immediately preceding result and restarts at 1 on a changed - * result; `lastFingerprint` is the preceding result's fingerprint. - */ - statefulResultStreaks: Map< - string, - { - consecutiveIdenticalResults: number; - lastFingerprint: string | undefined; - } - >; - /** - * Running max of the CURRENT stateful result streaks — the cap's stuck - * signal for stateful reads (mirrors core's statefulCapKeyRepeat). - * Disarmed when a result changes, so a thawed board releases the cap. - */ - statefulMaxResultRepeat: number; - /** - * Stateful keys that recorded a result since the previous batch (mirrors - * core's statefulResultKeysSinceLastFinished). At each batch boundary, - * keys absent from this set are abandoned and their streaks stop feeding - * statefulMaxResultRepeat (issue #9450). Optional: lazily initialized so - * pre-existing hand-built states stay compatible. - */ - statefulResultKeysSinceLastBatch?: Set; loopDetected: boolean; loopType?: LoopType; repeatedToolFailureMode: RepeatedToolFailureGuardMode; @@ -699,9 +669,6 @@ function createDaemonToolLoopState( invalidToolParamErrors: new Map(), toolCallKeyCounts: new Map(), maxToolCallKeyRepeat: 0, - statefulResultStreaks: new Map(), - statefulMaxResultRepeat: 0, - statefulResultKeysSinceLastBatch: new Set(), loopDetected: false, repeatedToolFailureMode, repeatedToolFailureState: createRepeatedToolFailureGuardState(), @@ -842,83 +809,6 @@ function isLoopDetectedTurnError(error: unknown): boolean { ); } -/** - * Batch-boundary decay for the cap's stateful stuck signal — the daemon - * twin of core's LoopDetectionService.decayAbandonedStatefulStreaks; the - * two runtimes must not drift (issue #9450 requirement #6). A stateful key - * that produced no result since the previous batch was abandoned: the model - * moved on to other work, so its frozen-phase streak must stop feeding - * statefulMaxResultRepeat. Without this the streak map is add-only and the - * peak latches for the whole turn — under the CLI default - * skipLoopDetection=true the cap's stuck signal is the ONLY live halt path - * for a frozen daemon poller, and the latched peak would halt a productive - * turn just past the soft cap. Keys polled in every batch appear in the set - * and keep their streaks, so a continuously frozen board still arms the cap. - * Keys requested in the CURRENT batch (`requestedKeys`) are skipped too — - * the mirror of core's skip for keys requested since the last Finished - * boundary (loopDetectionService.decayAbandonedStatefulStreaks): a poll - * batch's own results are recorded after this boundary runs, and any gap - * batch (other tools between polls) consumes the previous result's mark at - * its own boundary, so decaying a key that is still being polled would - * wipe its streak at the next poll's boundary and disarm the stuck signal - * for an every-other-batch frozen poller (issue #9450 requirement #6). - */ -function decayAbandonedDaemonStreaks( - loopState: DaemonToolLoopState, - requestedKeys?: ReadonlySet, -): void { - const sinceLastBatch = (loopState.statefulResultKeysSinceLastBatch ??= - new Set()); - let decayed = false; - for (const [key, state] of loopState.statefulResultStreaks) { - if (sinceLastBatch.has(key)) continue; - if (requestedKeys?.has(key)) continue; - if (state.consecutiveIdenticalResults > 0) { - state.consecutiveIdenticalResults = 0; - decayed = true; - } - } - sinceLastBatch.clear(); - if (!decayed) return; - let peak = 0; - for (const state of loopState.statefulResultStreaks.values()) { - if (state.consecutiveIdenticalResults > peak) { - peak = state.consecutiveIdenticalResults; - } - } - loopState.statefulMaxResultRepeat = peak; -} - -/** - * Daemon twin of core's carryStatefulStreakMarksAcrossSuppression (see - * loopDetectionService.noteSuppressedToolCallByCallId); the two runtimes - * must not drift (issue #9450 requirement #6). Called on ANY replay - * suppression — not only a stateful one: the carry is needed most when - * the suppressed replay is a NON-stateful tool (the provider re-emitting - * an already-handled read_file call id, say). A MIXED batch of that - * replay plus an executable call skips the empty-batch decay skip, and - * requestedStatefulKeys holds executable calls only, so without the carry - * the polled task_list key sits in NEITHER skip set once the previous - * result's mark is consumed — decayAbandonedDaemonStreaks wipes the live - * frozen-board streak and recomputes statefulMaxResultRepeat toward zero, - * keeping the stuck signal below GLOBAL_DUPLICATE_THRESHOLD indefinitely - * while core carries the identical interleaving and halts just past the - * soft cap. Decayed streaks carry consecutiveIdenticalResults === 0, so - * the carry never resurrects an abandoned streak — it only postpones an - * imminent decay by one boundary, exactly as the core twin. - */ -function carryStatefulStreakMarksAcrossDaemonSuppression( - loopState: DaemonToolLoopState, -): void { - const sinceLastBatch = (loopState.statefulResultKeysSinceLastBatch ??= - new Set()); - for (const [key, state] of loopState.statefulResultStreaks) { - if (state.consecutiveIdenticalResults > 0) { - sinceLastBatch.add(key); - } - } -} - function recordDaemonToolCalls( config: Config, promptId: string, @@ -927,48 +817,8 @@ function recordDaemonToolCalls( ): boolean { if (!loopState || loopState.loopDetected) return loopState?.loopDetected ?? false; - // A batch that executes nothing — every call suppressed as a replay of an - // already-handled provider call id (pushDuplicateBatch) — records zero - // results BY DESIGN (the result-recording filter excludes - // providerDuplicate / not_started records). Running the abandonment decay - // for it would mistake that for the model moving on: the next batch's - // decay would find the live frozen-board key absent and wipe its streak, - // letting replays interleaved at ≤5-poll intervals keep - // statefulMaxResultRepeat below the stuck threshold indefinitely — - // disarming the cap's stuck signal while the replay batches also add 0 to - // totalToolCalls and push the hard backstop away (issue #9450). Skip it: - // the still-populated statefulResultKeysSinceLastBatch set carries the - // last EXECUTED round's keys through the empty batch, so abandonment - // decay still runs (and clears) on the next non-empty batch. The cap - // check cannot newly fire on an empty batch: totalToolCalls is unchanged - // and skipping the decay can only keep the repeat peak higher, which a - // prior non-halting check at the same total already tolerated. - if (calls.length === 0) return false; - // Stateful keys requested in THIS batch: the boundary decay must skip - // them (see decayAbandonedDaemonStreaks) — their results have not landed - // yet, exactly as core's Finished-boundary decay skips keys requested - // since the last boundary. - const requestedStatefulKeys = new Set(); - for (const call of calls) { - const name = call.name ?? ''; - if (isStatefulReadTool(name)) { - requestedStatefulKeys.add(getToolCallRepeatKey(name, call.args ?? {})); - } - } - // Batch boundary: the previous batch's results have all been recorded by - // now (results are recorded during execution, before the next batch is - // streamed), so this is the safe point to decay stateful keys absent from - // them — the daemon twin of core's Finished-boundary decay (issue #9450). - decayAbandonedDaemonStreaks(loopState, requestedStatefulKeys); loopState.totalToolCalls += calls.length; for (const call of calls) { - // Stateful read tools are counted post-execution in - // recordDaemonToolResult, keyed on (call, result fingerprint) instead - // of args alone (issue #9450) — identical arguments to task_list do - // not imply an identical result while peers keep mutating the board. - // Mirrors core's checkAlwaysOnSafeties exemption; the two runtimes - // must not drift (requirement #6). - if (isStatefulReadTool(call.name ?? '')) continue; const key = getToolCallRepeatKey(call.name ?? '', call.args ?? {}); const count = (loopState.toolCallKeyCounts.get(key) ?? 0) + 1; loopState.toolCallKeyCounts.set(key, count); @@ -995,14 +845,7 @@ function recordDaemonToolCalls( if ( shouldHaltOnTurnToolCallCap( loopState.totalToolCalls, - // Request-time evidence (deterministic tools) and result-time - // evidence (stateful reads) feed the same stuck signal, exactly as - // core's checkTurnToolCallCap. The stateful half disarms when - // results change (recordDaemonToolResult). - Math.max( - loopState.maxToolCallKeyRepeat, - loopState.statefulMaxResultRepeat, - ), + loopState.maxToolCallKeyRepeat, config.getMaxToolCallsPerTurn(), config.isMaxToolCallsPerTurnExplicit(), ) @@ -1025,9 +868,7 @@ function recordDaemonToolCalls( // always-on regardless. "Off by default" depends on the CLI layer: core's // Config defaults skipLoopDetection to false and loadCliConfig applies // `?? true` (cli config.ts), so a Config constructed without that layer - // would ship this halt on. Stateful read tools are counted - // post-execution in recordDaemonToolResult instead (their repetition is - // only meaningful when the results are unchanged too). + // would ship this halt on. if ( !config.getSkipLoopDetection() && loopState.maxToolCallKeyRepeat >= GLOBAL_DUPLICATE_THRESHOLD @@ -1043,87 +884,6 @@ function recordDaemonToolCalls( return false; } -/** - * Result-aware mirror of core's LoopDetectionService.recordToolResult for - * the daemon/ACP runtime (issue #9450 requirement #6). Records the executed - * result of a stateful read tool so identical arguments whose results keep - * changing are treated as productive polling, not a loop. Feeds both the - * adaptive cap's stuck signal (statefulMaxResultRepeat, which disarms on a - * changed result) and the result-time global-duplicate count (gated on - * skipLoopDetection, exactly as in core). Call once per executed call. - */ -function recordDaemonToolResult( - config: Config, - promptId: string, - loopState: DaemonToolLoopState | undefined, - toolCall: { name: string; args: object }, - responseParts: readonly Part[], -): boolean { - if (!loopState || loopState.loopDetected) - return loopState?.loopDetected ?? false; - if (!isStatefulReadTool(toolCall.name)) return false; - - const fingerprint = fingerprintToolResult(responseParts); - if (fingerprint === null) return false; - const key = getToolCallRepeatKey(toolCall.name, toolCall.args); - - // Batch bookkeeping: this key produced a result in the current batch, so - // the next batch boundary must not decay it (see - // decayAbandonedDaemonStreaks). - (loopState.statefulResultKeysSinceLastBatch ??= new Set()).add(key); - - let state = loopState.statefulResultStreaks.get(key); - if (!state) { - state = { consecutiveIdenticalResults: 0, lastFingerprint: undefined }; - loopState.statefulResultStreaks.set(key, state); - } - const firstResult = state.lastFingerprint === undefined; - const fingerprintChanged = - !firstResult && state.lastFingerprint !== fingerprint; - - // Consecutive identical-result counting (mirrors core): a result that - // differs from the key's predecessor restarts the count, so an - // oscillating board never accumulates toward either halt. - const consecutiveIdentical = fingerprintChanged - ? 1 - : state.consecutiveIdenticalResults + 1; - state.consecutiveIdenticalResults = consecutiveIdentical; - state.lastFingerprint = fingerprint; - - // Cap stuck signal from result evidence. A raised peak must NOT latch: - // when a result changes, recompute the peak from the keys' CURRENT - // streaks so a thawed board disarms the adaptive cap exactly as it - // disarms the result-time global-duplicate count. Mirrors core's - // statefulCapKeyRepeat. - if (consecutiveIdentical > loopState.statefulMaxResultRepeat) { - loopState.statefulMaxResultRepeat = consecutiveIdentical; - } else if (fingerprintChanged) { - let peak = consecutiveIdentical; - for (const other of loopState.statefulResultStreaks.values()) { - if (other.consecutiveIdenticalResults > peak) { - peak = other.consecutiveIdenticalResults; - } - } - loopState.statefulMaxResultRepeat = peak; - } - - // The result-time global-duplicate detector is gated on skipLoopDetection - // exactly as its core counterpart in recordToolResult. - if ( - !config.getSkipLoopDetection() && - consecutiveIdentical >= GLOBAL_DUPLICATE_THRESHOLD - ) { - return recordDaemonLoopDetected( - config, - promptId, - LoopType.GLOBAL_TOOL_CALL_DUPLICATE, - `Stopping ACP turn after the same ${toolCall.name} result repeated ${consecutiveIdentical} times.`, - loopState, - ); - } - return false; -} - function recordDaemonInvalidToolParams( config: Config, promptId: string, @@ -9485,29 +9245,6 @@ export class Session implements SessionContext { ordinal: dedupedFunctionCalls.indexOf(fc), sequence: toolResultRecordSequence++, }); - // Result-aware loop guards (issue #9450): feed EXECUTED stateful-read - // results to the daemon guard so identical task_list arguments whose - // results keep changing stay productive. Skipped/duplicate records - // (executionStatus 'not_started', providerDuplicate) never executed, - // so they carry no result evidence. A detection sets - // loopState.loopDetected; the batch loops and runTool entry checks - // below observe it and stop the turn. - if ( - toolLoopState && - !record.providerDuplicate && - record.metadata.executionStatus !== 'not_started' - ) { - recordDaemonToolResult( - this.config, - promptId, - toolLoopState, - { - name: record.toolName, - args: (fc.args ?? {}) as object, - }, - record.responseParts, - ); - } }; const finalizeRunToolResult = async ( result: RunToolResult, @@ -9702,38 +9439,6 @@ export class Session implements SessionContext { this.duplicateProviderToolCallResponseIds, ); - // A suppressed replay keeps the live stateful streaks alive across - // the batch boundary: mirror core's suppression handling (core marks - // statefulResultKeysSinceLastFinished in noteSuppressedToolCallByCallId). - // The carry re-adds EVERY key that still carries streak evidence on - // ANY replay suppression — needed most when the suppressed replay is - // a NON-stateful tool: a MIXED batch of that replay alongside an - // executable call would otherwise skip the empty-batch early return, - // find the polled task_list key in neither skip set - // (requestedStatefulKeys is built from executable calls only), and - // decayAbandonedDaemonStreaks would wipe the live frozen-board - // streak — keeping statefulMaxResultRepeat below the stuck threshold - // indefinitely and drifting from core (issue #9450 requirement #6). - // The replayed stateful key itself is marked unconditionally too, - // mirroring core's end-of-function mark: a suppression landing - // before the streak's first result must still protect its key. These - // marks protect the replay batch's OWN boundary - // (recordDaemonToolCalls runs after batch construction and consumes - // them there); emitDuplicateBatch re-adds during the execution - // phase for the NEXT boundary, mirroring core's timing — core's - // mark lands when the fabricated response is submitted with the - // next round's ToolResult, after the replay stream's Finished - // boundary (issue #9450 requirement #6). - if (toolLoopState) { - carryStatefulStreakMarksAcrossDaemonSuppression(toolLoopState); - if (isStatefulReadTool(request.name)) { - (toolLoopState.statefulResultKeysSinceLastBatch ??= - new Set()).add( - getToolCallRepeatKey(request.name, request.args), - ); - } - } - const response = createDuplicateProviderToolCallResponse(request); debugLogger.debug( `[Session.runToolCalls] Suppressing duplicate provider tool-call id: ` + @@ -9744,28 +9449,6 @@ export class Session implements SessionContext { const emitDuplicateBatch = async (batch: DuplicateBatch): Promise => { const { request, response } = batch; - // Next-boundary protection for the suppressed replay: the marks - // pushDuplicateBatch added were consumed by THIS batch's own - // boundary decay (recordDaemonToolCalls ran after construction), so - // without fresh marks a gap batch following a mixed replay batch - // would find the live streak keys in neither skip set and decay the - // live frozen-board streak — one boundary earlier than core's twin, - // whose suppression mark lands with the fabricated response AFTER - // the replay round's Finished boundary. The carry mirrors core's on - // ANY replay suppression (a NON-stateful replay's own key marks - // nothing — its suppression must still protect the live streaks; - // see carryStatefulStreakMarksAcrossDaemonSuppression). Runs in the - // execution phase (the boundary has already run), so the marks - // survive to the next batch's decay (issue #9450 requirement #6). - if (toolLoopState) { - carryStatefulStreakMarksAcrossDaemonSuppression(toolLoopState); - if (isStatefulReadTool(request.name)) { - (toolLoopState.statefulResultKeysSinceLastBatch ??= - new Set()).add( - getToolCallRepeatKey(request.name, request.args), - ); - } - } try { if (request.name === ToolNames.TODO_WRITE) { const provenance = ToolCallEmitter.resolveToolProvenance( @@ -10031,13 +9714,7 @@ export class Session implements SessionContext { executing.add(p); if (executing.size >= maxConcurrency) { await Promise.race(executing); - // toolLoopState.loopDetected also covers result-time detections - // (recordDaemonToolResult) that the settled result object does - // not carry. - if ( - results.some((result) => result?.loopDetected) || - toolLoopState?.loopDetected - ) { + if (results.some((result) => result?.loopDetected)) { await Promise.all(executing); await fillLoopSkippedFrom(idx + 1); return results; @@ -10049,10 +9726,7 @@ export class Session implements SessionContext { ); if (invalidToolErrorNearThreshold && executing.size > 0) { await Promise.all(executing); - if ( - results.some((result) => result?.loopDetected) || - toolLoopState?.loopDetected - ) { + if (results.some((result) => result?.loopDetected)) { await fillLoopSkippedFrom(idx + 1); return results; } @@ -10123,9 +9797,6 @@ export class Session implements SessionContext { shouldStop ||= r.stopAfterPermissionCancel; shouldStopForLoop ||= r.loopDetected === true; } - // Result-time detections (recordDaemonToolResult) land on the - // shared loop state, not on an individual result object. - shouldStopForLoop ||= toolLoopState?.loopDetected === true; if (shouldStopForLoop) { await appendSkippedAfter( parts, @@ -10166,10 +9837,7 @@ export class Session implements SessionContext { ); parts.push(...r.parts); collectMemoryWriteCandidates(r); - // toolLoopState.loopDetected also covers result-time detections - // (recordDaemonToolResult) fired while this call's result was - // queued — the result object itself does not carry them. - if (r.loopDetected || toolLoopState?.loopDetected) { + if (r.loopDetected) { await appendSkippedAfter( parts, fc, @@ -10197,9 +9865,6 @@ export class Session implements SessionContext { return await finalizeRunToolResult({ parts, stopAfterPermissionCancel: false, - // A result-time detection on the LAST executed call leaves no later - // call to observe it; surface it so the turn loop still stops. - ...(toolLoopState?.loopDetected ? { loopDetected: true } : {}), memoryWriteCandidates, }); } finally { diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index ef21d3948c1..c63d886a979 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -256,9 +256,7 @@ describe('runNonInteractive', () => { consumePendingMemoryTaskPromises: Mock; recordCompletedToolCall: Mock; addHistory: Mock; - getLoopDetectionService: Mock; }; - let mockNoteSuppressedToolCallByCallId: Mock; let mockGetDebugResponses: Mock; let goalRuntime: GoalRuntime; @@ -319,7 +317,6 @@ describe('runNonInteractive', () => { abortAll: vi.fn(), }; - mockNoteSuppressedToolCallByCallId = vi.fn(); mockGeminiClient = { sendMessageStream: vi.fn(), consumePendingMemoryTaskPromises: vi.fn().mockReturnValue([]), @@ -334,9 +331,6 @@ describe('runNonInteractive', () => { })), getChat: vi.fn(() => ({})), getHistoryToolCallFingerprints: vi.fn(() => new Map()), - getLoopDetectionService: vi.fn(() => ({ - noteSuppressedToolCallByCallId: mockNoteSuppressedToolCallByCallId, - })), }; let currentModel = 'test-model'; @@ -2818,22 +2812,6 @@ describe('runNonInteractive', () => { .filter((metadata) => metadata.callId !== 'enter-plan') .map((metadata) => metadata.executionStatus), ).toEqual(['not_started', 'not_started', 'not_started']); - // The skipped siblings are marked executed (they get a fabricated - // response in the next turn), but they never ran: the loop guards' - // request-side reservations must unwind so the constant fabricated - // error fingerprint is never recorded as result evidence (issue - // #9450 — the daemon excludes this class via its not_started - // filter; the CLI has to unwind it explicitly). - expect(mockNoteSuppressedToolCallByCallId).toHaveBeenCalledTimes(3); - expect(mockNoteSuppressedToolCallByCallId).toHaveBeenCalledWith( - 'write-before-entry', - ); - expect(mockNoteSuppressedToolCallByCallId).toHaveBeenCalledWith( - 'read-after-entry-1', - ); - expect(mockNoteSuppressedToolCallByCallId).toHaveBeenCalledWith( - 'read-after-entry-2', - ); }); it('runs a batch of concurrency-safe tool calls concurrently', async () => { @@ -6967,78 +6945,6 @@ describe('runNonInteractive', () => { expect(leadingContent).not.toMatch(/Re-issue this call/); }); - it('marks suppressed sibling calls as never-executed for the loop guards (issue #9450)', async () => { - // The fabricated skipped-output responses carry the original callId - // but never executed. client.ts's recording feed excludes only the - // duplicate-message synthetic class, so the constant fabricated - // fingerprint would be recorded as a "changed" result every round, - // exonerating a stuck frozen-board poller. The CLI must unwind the - // request-side reservations via noteSuppressedToolCallByCallId — - // mirroring the daemon's not_started filter and agent-core's - // neverExecutedCallIds exclusion (requirement #6). - (mockConfig.getJsonSchema as Mock).mockReturnValue({ - type: 'object', - properties: { summary: { type: 'string' } }, - }); - (mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON); - setupMetricsMock(); - - (mockConfig.getBackgroundTaskRegistry as Mock).mockReturnValue({ - setNotificationCallback: vi.fn(), - setRegisterCallback: vi.fn(), - getAll: vi.fn().mockReturnValue([]), - hasUnfinalizedTasks: vi.fn().mockReturnValue(false), - abortAll: vi.fn(), - }); - - // A task_list poll suppressed by the same-turn structured_output: - // exactly the call class the result-aware loop guards track. - const suppressedCall: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, - value: { - callId: 'tool-suppressed-poll', - name: 'task_list', - args: { status: 'in_progress' }, - isClientInitiated: false, - prompt_id: 'prompt-id-suppressed', - }, - }; - const structuredCall: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, - value: { - callId: 'tool-structured-main', - name: 'structured_output', - args: { summary: 'done' }, - isClientInitiated: false, - prompt_id: 'prompt-id-suppressed', - }, - }; - - mockCoreExecuteToolCall.mockResolvedValue({ - responseParts: [{ text: 'ok' }], - }); - - mockGeminiClient.sendMessageStream.mockReturnValueOnce( - createStreamFromEvents([suppressedCall, structuredCall]), - ); - - await runNonInteractive( - mockConfig, - mockSettings, - 'Emit structured output', - 'prompt-id-suppressed', - ); - - // Only structured_output executed; the suppressed poll must be - // marked never-executed so its fabricated skipped-output response - // is excluded from the loop guards' result evidence. - expect(mockCoreExecuteToolCall).toHaveBeenCalledTimes(1); - expect(mockNoteSuppressedToolCallByCallId).toHaveBeenCalledTimes(1); - expect(mockNoteSuppressedToolCallByCallId).toHaveBeenCalledWith( - 'tool-suppressed-poll', - ); - }); - it('tries multiple structured_output calls in the same turn until one succeeds', async () => { // Same-turn batch: [structured_output(bad), structured_output(good)]. // The first fails validation; the second has valid args and should diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index b094a63f302..9e91837b2a2 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -2019,22 +2019,6 @@ export async function runNonInteractive( const finalizePlanModeEntrySiblingSkip = ( requestInfo: ToolCallRequestInfo, ): void => { - // Never executed: the fabricated skip response below carries no - // result evidence, so unwind the request-side reservations the - // loop guards made when the call streamed in — the same unwind - // the never-executed skipped-output synthesis runs below. The - // fabricated error is not of the duplicate-provider synthetic - // class (client.ts's recording feed excludes ONLY that class), - // and this path marks the request executed, so without the - // unwind the feed pairs the constant fabricated fingerprint - // with the streamed request and records it as a "changed" - // result every occurrence — resetting the frozen-board streaks - // and disarming the result-aware halts (issue #9450). Mirrors - // the daemon twin, whose not_started filter excludes exactly - // this class from its result recording. - geminiClient - .getLoopDetectionService() - .noteSuppressedToolCallByCallId(requestInfo.callId); const error = new Error(PLAN_MODE_ENTRY_SIBLING_SKIP_MESSAGE); const responseParts: Part[] = [ { @@ -2196,18 +2180,6 @@ export async function runNonInteractive( structuredSubmission !== undefined, ); for (const call of unexecutedCalls) { - // Never executed: the fabricated skipped-output response - // carries no result evidence, so unwind the request-side - // reservations the loop guards made when the call streamed in — - // mirroring the daemon's not_started filter and agent-core's - // neverExecutedCallIds exclusion. Without this the constant - // fabricated fingerprint passes client.ts's recording feed - // (isDuplicateProviderToolCallResponse is false for it) and - // counts as a "changed" result every round, exonerating a - // stuck frozen-board poller (issue #9450 requirement #6). - geminiClient - .getLoopDetectionService() - .noteSuppressedToolCallByCallId(call.callId); const responseParts: Part[] = [ { functionResponse: { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index d9d82c093a2..ca8b024313b 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -1671,118 +1671,6 @@ describe('useGeminiStream', () => { ); }); - it('unwinds never-executed not_started scheduler responses before submitting them (issue #9450)', async () => { - // A scheduler response with executionStatus 'not_started' — the - // plan-mode-entry sibling skip, pre-validation cancellations, - // permission / tool-not-found / validation rejections — never executed, - // so its fabricated constant error carries no result evidence. The hook - // must unwind the loop guards' request-side reservations before - // submission: client.ts's recording feed excludes only the - // duplicate-provider synthetic class, so without the unwind the - // fabricated error pairs with the streamed request in requestByCallId - // and records as a "changed" result — resetting frozen-board streaks - // and disarming every result-aware halt (the daemon twin excludes this - // class via its not_started filter). - const noteSuppressedToolCallByCallId = vi.fn(); - const client = new MockedGeminiClientClass(mockConfig); - client.getLoopDetectionService = vi.fn().mockReturnValue({ - noteSuppressedToolCallByCallId, - }); - - const completedToolCalls: TrackedToolCall[] = [ - { - request: { - callId: 'skipped-sibling', - name: 'task_list', - args: {}, - isClientInitiated: false, - prompt_id: 'prompt-not-started', - }, - status: 'error', - responseSubmittedToGemini: false, - response: { - callId: 'skipped-sibling', - responseParts: [ - { - functionResponse: { - id: 'skipped-sibling', - name: 'task_list', - response: { error: 'plan mode entry sibling skip' }, - }, - }, - ], - errorType: ToolErrorType.EXECUTION_DENIED, - executionStatus: 'not_started', - }, - } as unknown as TrackedCompletedToolCall, - { - request: { - callId: 'executed-tool', - name: 'shell', - args: {}, - isClientInitiated: false, - prompt_id: 'prompt-not-started', - }, - status: 'success', - responseSubmittedToGemini: false, - response: { - callId: 'executed-tool', - responseParts: [{ text: 'executed output' }], - errorType: undefined, - executionStatus: 'success', - }, - } as unknown as TrackedCompletedToolCall, - ]; - - let capturedOnComplete: - | ((completedTools: TrackedToolCall[]) => Promise) - | null = null; - - mockUseReactToolScheduler.mockImplementation((onComplete) => { - capturedOnComplete = onComplete; - return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted]; - }); - - renderHook(() => - useGeminiStream( - client, - [], - mockAddItem, - mockConfig, - true, - mockLoadedSettings, - mockOnDebugMessage, - mockHandleSlashCommand, - false, - () => 'vscode' as EditorType, - () => {}, - () => Promise.resolve(), - false, - () => {}, - () => {}, - () => {}, - () => {}, - 80, - 24, - ), - ); - - await act(async () => { - if (capturedOnComplete) { - await capturedOnComplete(completedToolCalls); - } - }); - - // Only the never-executed synthetic is unwound; the executed response - // keeps its result evidence. - await waitFor(() => { - expect(noteSuppressedToolCallByCallId).toHaveBeenCalledTimes(1); - }); - expect(noteSuppressedToolCallByCallId).toHaveBeenCalledWith( - 'skipped-sibling', - ); - }); - it('stamps the committed tool_group with the batch id minted at schedule time (#9420)', async () => { const makeCompletedTool = (callId: string): TrackedCompletedToolCall => ({ diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 094fd9f2848..989682bc7e4 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -4760,26 +4760,6 @@ export const useGeminiStream = ( toolCall.request.name, toolCall.request.args as Record, ); - // Never-executed scheduler synthetics — the plan-mode-entry sibling - // skip, pre-validation cancellations, and permission / - // tool-not-found / validation rejections (executionStatus - // 'not_started') — carry no result evidence. Unwind the - // request-side reservations the loop guards made when the call - // streamed in BEFORE submission: client.ts's recording feed - // excludes only the duplicate-provider synthetic class - // (isDuplicateProviderToolCallResponse), so the fabricated constant - // error would otherwise pair with the streamed request in - // requestByCallId and record as a "changed" result — resetting the - // frozen-board streaks and disarming every result-aware halt for a - // genuinely stuck task_list poller (issue #9450). Mirrors the - // daemon twin, whose result-recording filter excludes exactly the - // not_started class (Session.queueToolResultRecord), and the - // non-interactive runner's never-executed unwinds. - if (toolCall.response.executionStatus === 'not_started') { - geminiClient - ?.getLoopDetectionService() - ?.noteSuppressedToolCallByCallId(toolCall.request.callId); - } } if (geminiTools.length === 0 && pendingDuplicateResponses.length === 0) { diff --git a/packages/core/src/agents/runtime/agent-core.test.ts b/packages/core/src/agents/runtime/agent-core.test.ts index 47e794507a6..9f74d732131 100644 --- a/packages/core/src/agents/runtime/agent-core.test.ts +++ b/packages/core/src/agents/runtime/agent-core.test.ts @@ -26,20 +26,6 @@ import { import { subagentNameContext } from '../../utils/subagentNameContext.js'; import { runInForkContext } from '../../tools/agent/fork-subagent.js'; import { ToolNames } from '../../tools/tool-names.js'; -import { - LoopDetectionService, - fingerprintToolResult, -} from '../../services/loopDetectionService.js'; -import { GeminiEventType } from '../../core/turn.js'; -import type { ServerGeminiStreamEvent } from '../../core/turn.js'; -import { MockTool } from '../../test-utils/mock-tool.js'; -import { BATCH_BUDGET_FIT_PREFIX } from '../../tools/tool-response-finalizer.js'; -import { - ApprovalMode, - DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, - DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, -} from '../../index.js'; -import type { ToolRegistry } from '../../tools/tool-registry.js'; import { getAgentName, getTeammateContext, @@ -1740,297 +1726,3 @@ describe('extractParentToolNames', () => { expect(extractParentToolNames(configWithTools([{}]))).toEqual([]); }); }); - -describe('AgentCore.processFunctionCalls loop-detector result feed', () => { - // The loop detector's result-aware guards must see representation-stable - // parts (issue #9450): a frozen board whose batch oscillates around the - // toolOutputBatchBudget boundary would otherwise alternate between a raw - // JSON-verbatim fingerprint (under-budget batch) and a digest-reduced - // batch-budget-fit fingerprint (over-budget batch) — two representations - // of identical content that never collide, so every poll counts as - // "changed" and no result-aware halt ever fires. - - const BOARD = 'task row for a frozen board\n'.repeat(80); // ~2.2KB - const SIBLING_OUTPUT = 'sibling payload line\n'.repeat(100); // ~2.1KB - const TASK_LIST_ARGS = { board: 'shared' }; - // BOARD fits the budget solo; BOARD + SIBLING_OUTPUT exceeds it, so - // alternating solo/co-batched rounds oscillate across the fit boundary. - // Both stay far below the per-result truncation threshold, so executed - // parts carry the raw board text (no scheduler persistence). - const BATCH_BUDGET = 3000; - - const fnDecls: FunctionDeclaration[] = [ - { name: 'task_list', description: 'list tasks' } as FunctionDeclaration, - { name: 'big_sibling', description: 'big sibling' } as FunctionDeclaration, - ]; - - function buildAgentForExecutedTools(tmpDir: string): { - core: AgentCore; - config: Config; - } { - const boardTool = new MockTool({ - name: 'task_list', - execute: async () => ({ - llmContent: BOARD, - returnDisplay: 'board', - }), - }); - const siblingTool = new MockTool({ - name: 'big_sibling', - execute: async () => ({ - llmContent: SIBLING_OUTPUT, - returnDisplay: 'sibling', - }), - }); - const byName = new Map([ - [boardTool.name, boardTool], - [siblingTool.name, siblingTool], - ]); - const registry = { - getTool: (name: string) => byName.get(name), - ensureTool: async (name: string) => byName.get(name), - getAllToolNames: () => [...byName.keys()], - getFunctionDeclarations: () => [], - warmAll: async () => undefined, - } as unknown as ToolRegistry; - const config = { - getDebugLogger: vi.fn().mockReturnValue({ - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), - getSessionId: () => 'session-loop-feed', - getUsageStatisticsEnabled: () => false, - getTelemetryEnabled: () => false, - getDebugMode: () => false, - getApprovalMode: () => ApprovalMode.DEFAULT, - getPermissionsAllow: () => [], - getPermissionsDeny: () => undefined, - getContentGeneratorConfig: () => ({ - model: 'test-model', - authType: 'gemini', - }), - getShellExecutionConfig: () => ({ - terminalWidth: 90, - terminalHeight: 30, - }), - storage: { getProjectTempDir: () => tmpDir }, - getTruncateToolOutputThreshold: () => - DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, - getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, - getToolRegistry: () => registry, - getUseModelRouter: () => false, - getGeminiClient: () => null, - getChatRecordingService: () => undefined, - getMessageBus: vi.fn().mockReturnValue(undefined), - getDisableAllHooks: vi.fn().mockReturnValue(true), - getDisabledTools: () => new Set(), - getSkillManager: () => undefined, - getConditionalRulesRegistry: () => undefined, - getCwd: () => tmpDir, - getTargetDir: () => tmpDir, - getSdkMode: () => false, - getIdeMode: () => false, - getExperimentalZedIntegration: () => false, - getInputFormat: () => undefined, - getPlanFilePath: () => path.join(tmpDir, 'plan.md'), - getToolOutputBatchBudget: () => BATCH_BUDGET, - getToolResultBytesWritten: () => 500 * 1024 * 1024, - getMaxSubagentDepth: () => 5, - getSkipLoopDetection: () => false, - getMaxToolCallsPerTurn: () => 100, - isMaxToolCallsPerTurnExplicit: () => false, - } as unknown as Config; - const core = new AgentCore( - 'loop-feed-subagent', - config, - { systemPrompt: '' }, - { model: 'test-model' }, - { max_turns: 1 }, - { tools: ['*'] }, - ); - return { core, config }; - } - - const taskListCall = (id: string) => ({ - name: 'task_list', - args: TASK_LIST_ARGS, - id, - }); - - const toolCallRequestEvent = ( - name: string, - args: Record, - callId: string, - ): ServerGeminiStreamEvent => ({ - type: GeminiEventType.ToolCallRequest, - value: { - name, - args, - callId, - isClientInitiated: false, - prompt_id: 'prompt-loop-feed', - }, - }); - - it('feeds representation-stable parts to the guards across the budget boundary', async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-loop-feed-')); - try { - const { core } = buildAgentForExecutedTools(tmpDir); - - const solo = await runWithAgentContext('loop-feed', () => - core.runInAgentFrames(() => - core.processFunctionCalls( - [taskListCall('tl-solo')], - new AbortController(), - 'prompt-loop-feed', - 1, - fnDecls, - ), - ), - ); - const coBatched = await runWithAgentContext('loop-feed', () => - core.runInAgentFrames(() => - core.processFunctionCalls( - [ - taskListCall('tl-co'), - { name: 'big_sibling', args: { step: 1 }, id: 'sib-co' }, - ], - new AbortController(), - 'prompt-loop-feed', - 2, - fnDecls, - ), - ), - ); - - const visibleOutput = ( - messages: unknown, - callIdSuffix: string, - ): string => { - const parts = ((messages as Array<{ parts?: unknown[] }>)[0]?.parts ?? - []) as Array<{ - functionResponse?: { - id?: string; - response?: { output?: string }; - }; - }>; - const part = parts.find((p) => - p.functionResponse?.id?.endsWith(callIdSuffix), - ); - return part?.functionResponse?.response?.output ?? ''; - }; - // Sanity: the budget actually ran — the over-budget co-batch fitted - // the model-visible board result while the solo batch kept it raw. - expect(visibleOutput(coBatched.messages, 'tl-co')).toContain( - BATCH_BUDGET_FIT_PREFIX, - ); - expect(visibleOutput(solo.messages, 'tl-solo')).not.toContain( - BATCH_BUDGET_FIT_PREFIX, - ); - - // The guard feed must not depend on batch composition: identical - // content fingerprints identically whether its batch fitted or not. - const soloBoard = solo.results.find((r) => r.toolName === 'task_list'); - const coBoard = coBatched.results.find((r) => r.toolName === 'task_list'); - expect(soloBoard).toBeDefined(); - expect(coBoard).toBeDefined(); - expect(fingerprintToolResult(soloBoard!.responseParts)).toBe( - fingerprintToolResult(coBoard!.responseParts), - ); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it('halts a frozen board polled across the fit boundary (oscillating batches)', async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-loop-feed-')); - try { - const { core, config } = buildAgentForExecutedTools(tmpDir); - const loopDetector = new LoopDetectionService(config); - loopDetector.reset('session-loop-feed#loop-feed-subagent'); - const handledToolCallFingerprints = new Map(); - const duplicateProviderToolCallResponseIds = new Set(); - - let halted = false; - let rounds = 0; - for (let round = 0; round < 10 && !halted; round++) { - rounds++; - const coBatched = round % 2 === 1; - // Request-time feed, exactly as the reasoning loop streams events. - const events = [ - toolCallRequestEvent('task_list', TASK_LIST_ARGS, `tl-${round}`), - ]; - if (coBatched) { - events.push( - toolCallRequestEvent( - 'big_sibling', - { step: round }, - `sib-${round}`, - ), - ); - } - for (const event of events) { - if ( - loopDetector.checkAlwaysOnSafeties(event) || - loopDetector.addAndCheckHeuristicLoops(event) - ) { - halted = true; - break; - } - } - if (halted) break; - - const calls: Array<{ - name: string; - args: Record; - id: string; - }> = [taskListCall(`tl-${round}`)]; - if (coBatched) { - calls.push({ - name: 'big_sibling', - args: { step: round }, - id: `sib-${round}`, - }); - } - const result = await runWithAgentContext('loop-feed', () => - core.runInAgentFrames(() => - core.processFunctionCalls( - calls, - new AbortController(), - 'prompt-loop-feed', - round + 1, - fnDecls, - undefined, - false, - handledToolCallFingerprints, - duplicateProviderToolCallResponseIds, - loopDetector, - ), - ), - ); - // Result-time feed, exactly as the reasoning loop records results. - for (const toolResult of result.results) { - if ( - loopDetector.recordToolResult( - { name: toolResult.toolName, args: toolResult.args }, - toolResult.responseParts, - ) - ) { - halted = true; - break; - } - } - } - - // The result-aware global-duplicate guard fires on the 6th identical - // frozen result. Pre-fix, the raw/fitted representation oscillation - // judged every poll "changed" and no guard fired within these rounds. - expect(halted).toBe(true); - expect(rounds).toBeLessThanOrEqual(6); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 50a49bf5f30..70df0d24b35 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -975,13 +975,6 @@ 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(); let roundText = ''; let roundThoughtText = ''; let lastUsage: GenerateContentResponseUsageMetadata | undefined = @@ -1019,7 +1012,6 @@ export class AgentCore { stickyMaxOutputTokens = streamEvent.maxOutputTokensEscalated; } functionCalls.length = 0; - loopGuardStreamedCallIds.clear(); roundText = ''; roundThoughtText = ''; lastUsage = undefined; @@ -1101,17 +1093,6 @@ 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, @@ -1196,7 +1177,6 @@ export class AgentCore { wasOutputTruncated, handledToolCallFingerprints, duplicateProviderToolCallResponseIds, - loopDetector, ); if (toolCallResult.repeatedDuplicateProviderToolCall) { terminateMode = AgentTerminateMode.LOOP_DETECTED; @@ -1645,27 +1625,18 @@ export class AgentCore { wasOutputTruncated = false, handledToolCallFingerprints = new Map(), duplicateProviderToolCallResponseIds = new Set(), - loopDetector?: LoopDetectionService, ): Promise<{ messages: Content[]; repeatedDuplicateProviderToolCall: boolean; /** Executed calls with their model-visible results, in call order. * Consumed by the loop detector for result-aware stateful-read guards - * (issue #9450). Never-executed synthetic responses (duplicate-replay - * and authorization rejections) are excluded — they carry no result - * evidence and would reset the guards' streaks as "changed" results. */ + * (issue #9450). */ results: Array<{ toolName: string; args: Record; responseParts: Part[]; }>; }> { - // callIds whose responses are synthetic and were NEVER executed: replay - // suppressions and authorization rejections (plus abort synthetics). - // Their results must not feed the result-aware loop guards (issue #9450) - // — the daemon twin excludes the same class (Session's result-recording - // filter on providerDuplicate / executionStatus 'not_started'). - const neverExecutedCallIds = new Set(); const responseByCallId = new Map< string, { @@ -1769,17 +1740,6 @@ export class AgentCore { responseParts: [functionResponsePart], durationMs: 0, }); - // Never executed: keep the synthetic error out of the loop guards' - // result evidence and unwind the request-time reservations it made - // when streamed (issue #9450). The request-side repetition - // increment is KEPT (see noteSuppressedToolCallByCallId): a - // subagent persistently re-emitting an unavailable task_list is a - // pure stream of rejected calls — no result ever lands to exonerate - // it, so the always-on consecutive-identical guard must still halt - // it on the 5th identical request instead of oscillating the count - // back down forever. - neverExecutedCallIds.add(callId); - loopDetector?.noteSuppressedToolCallByCallId(callId); continue; } @@ -1828,20 +1788,6 @@ export class AgentCore { persistedOutputFiles: response.persistedOutputFiles, durationMs: 0, }); - // Never executed (cross-round replay of an already-handled call - // id): the fabricated duplicate response carries no result - // evidence. Exclude it from the loop guards and unwind the - // request-time reservations the replayed request made when - // streamed — the daemon twin excludes this class via its - // providerDuplicate / not_started filter (issue #9450). The - // replaySuppression mark carries the live streak evidence across - // the next Finished boundary, mirroring the daemon's all-replay - // (empty) batch decay skip — a replay of a NON-stateful tool - // marks nothing on its own (issue #9450 requirement #6). - neverExecutedCallIds.add(callId); - loopDetector?.noteSuppressedToolCallByCallId(callId, { - replaySuppression: true, - }); continue; } recordHandledToolCall( @@ -2260,10 +2206,6 @@ export class AgentCore { responseParts, durationMs: 0, }); - // Never executed (cancelled before emission): same exclusion as - // the other synthetic responses (issue #9450). - neverExecutedCallIds.add(req.callId); - loopDetector?.noteSuppressedToolCallByCallId(req.callId); } }; abortController.signal.addEventListener('abort', onAbort, { once: true }); @@ -2337,12 +2279,8 @@ export class AgentCore { timestamp: Date.now(), }); - // Pair each EXECUTED call with its model-visible (finalized) result so + // Pair each executed call with its model-visible (finalized) result so // the reasoning loop can feed the loop detector's result-aware guards. - // Never-executed synthetic responses are skipped: recording one would - // pair a fabricated error with the replayed/rejected call's request and - // reset the guards' streaks as a "changed" result, disarming every - // result-aware halt (issue #9450). const finalizedByCallId = new Map( finalizedResponses.map((response) => [response.callId, response]), ); @@ -2353,7 +2291,6 @@ export class AgentCore { }> = []; for (const fc of uniqueFunctionCalls) { const callId = callIdByFunctionCall.get(fc) ?? fc.id ?? ''; - if (neverExecutedCallIds.has(callId)) continue; const finalized = finalizedByCallId.get(callId); if (!finalized) continue; results.push({ diff --git a/packages/core/src/agents/runtime/agent-events.ts b/packages/core/src/agents/runtime/agent-events.ts index f0f4eac2942..0736216d48a 100644 --- a/packages/core/src/agents/runtime/agent-events.ts +++ b/packages/core/src/agents/runtime/agent-events.ts @@ -206,10 +206,8 @@ export interface AgentFinishEvent { terminateReason: string; /** * Which loop detector fired when terminateReason is LOOP_DETECTED - * (issue #9450), so stops are attributable instead of collapsing into - * one generic label. Read by AgentTool's FINISH handler, which appends - * it to the failed task card's terminateReason; the journaled sink is - * SubagentExecutionEvent.loop_type (see agent-headless.ts). + * (issue #9450), so stops are attributable in journals/telemetry instead + * of collapsing into one generic label. */ loopType?: string; timestamp: number; diff --git a/packages/core/src/agents/runtime/agent-headless.test.ts b/packages/core/src/agents/runtime/agent-headless.test.ts index c4826f7ca48..4f86d9f0991 100644 --- a/packages/core/src/agents/runtime/agent-headless.test.ts +++ b/packages/core/src/agents/runtime/agent-headless.test.ts @@ -62,9 +62,9 @@ import { AgentTerminateMode } from './agent-types.js'; import { WriteFileTool } from '../../tools/write-file.js'; import { ToolNames } from '../../tools/tool-names.js'; import { normalizeToolNameForProvider } from '../../utils/tool-name-utils.js'; +import { LoopDetectionService } from '../../services/loopDetectionService.js'; import { logSubagentExecution } from '../../telemetry/loggers.js'; import type { SubagentExecutionEvent } from '../../telemetry/types.js'; -import { LoopDetectionService } from '../../services/loopDetectionService.js'; vi.mock('../../core/geminiChat.js'); vi.mock('../../core/contentGenerator.js', async (importOriginal) => { @@ -2432,488 +2432,6 @@ describe('subagent.ts', () => { ); }); - it('halts interleaved frozen task_list polling via the result-time guard (issue #9450)', async () => { - const taskListToolDef: FunctionDeclaration = { - name: 'task_list', - description: 'Lists team tasks', - parameters: { type: Type.OBJECT, properties: {} }, - }; - const fillerToolDef: FunctionDeclaration = { - name: 'tool_b', - description: 'Distinct filler work', - parameters: { type: Type.OBJECT, properties: {} }, - }; - - const { config } = await createMockConfig({ - getFunctionDeclarationsFiltered: vi - .fn() - .mockReturnValue([taskListToolDef, fillerToolDef]), - getTool: vi.fn().mockReturnValue(undefined), - }); - const toolConfig: ToolConfig = { tools: ['task_list', 'tool_b'] }; - const taskListArgs = { - status: 'in_progress', - owner: 'peer-a', - blockedBy: '', - }; - - // Interleave a DISTINCT call between identical task_list polls: the - // consecutive-identical guard never fires (streaks reset), the - // alternating guard never fires (filler args differ every round), - // and request-time counting is bypassed for stateful tools — only - // the result-time global-duplicate guard in agent-core catches it. - const turns: Array = []; - for (let poll = 1; poll <= 6; poll++) { - turns.push([ - { id: `poll_${poll}`, name: 'task_list', args: taskListArgs }, - ]); - if (poll < 6) { - turns.push([ - { id: `fill_${poll}`, name: 'tool_b', args: { step: poll } }, - ]); - } - } - turns.push('stop'); - mockSendMessageStream.mockImplementation(createMockStream(turns)); - - const taskListInvocation = { - params: taskListArgs, - getDescription: vi.fn().mockReturnValue('List tasks'), - toolLocations: vi.fn().mockReturnValue([]), - getDefaultPermission: vi.fn().mockResolvedValue('allow'), - // No teammate activity: every poll returns the identical board. - execute: vi.fn().mockResolvedValue({ - llmContent: '#7 [in_progress] @peer-a — task', - 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; - const fillerInvocation = { - params: {}, - getDescription: vi.fn().mockReturnValue('Filler'), - toolLocations: vi.fn().mockReturnValue([]), - getDefaultPermission: vi.fn().mockResolvedValue('allow'), - execute: vi.fn().mockResolvedValue({ - llmContent: 'filler done', - returnDisplay: 'Filler done', - }), - }; - const fillerTool = { - name: 'tool_b', - displayName: 'Tool B', - description: 'Distinct filler work', - kind: 'READ' as const, - schema: fillerToolDef, - build: vi.fn().mockImplementation(() => fillerInvocation), - canUpdateOutput: false, - isOutputMarkdown: false, - } as unknown as AnyDeclarativeTool; - vi.mocked( - (config.getToolRegistry() as unknown as ToolRegistry).getTool, - ).mockImplementation((name: string) => - name === 'task_list' - ? taskListTool - : name === 'tool_b' - ? fillerTool - : undefined, - ); - - const finishEvents: Array<{ loopType?: string }> = []; - const eventEmitter = new AgentEventEmitter(); - eventEmitter.on(AgentEventType.FINISH, (event: unknown) => { - finishEvents.push(event as { loopType?: string }); - }); - - const scope = await AgentHeadless.create( - 'test-agent', - config, - promptConfig, - defaultModelConfig, - { ...defaultRunConfig, max_turns: 20 }, - toolConfig, - eventEmitter, - ); - - await scope.execute(new ContextState()); - - // 11 model turns: six task_list polls interleaved with five filler - // calls; the halt lands when the sixth frozen (call, result) pair is - // recorded, before a twelfth request. - expect(mockSendMessageStream).toHaveBeenCalledTimes(11); - expect(taskListInvocation.execute).toHaveBeenCalledTimes(6); - expect(scope.getTerminateMode()).toBe(AgentTerminateMode.LOOP_DETECTED); - expect(finishEvents).toHaveLength(1); - expect(finishEvents[0].loopType).toBe('global_tool_call_duplicate'); - }); - - // Cross-round replays of an already-handled call id are suppressed and - // answered with a fabricated duplicate error that never executed. That - // synthetic response must not feed the result-aware guards: recording - // it pairs a "changed" result with the replayed request and resets the - // frozen-board streaks, disarming every result-aware halt (the daemon - // twin excludes the class via its providerDuplicate/not_started - // filter). These two tests interleave one replay into a frozen streak - // and assert the halt still fires (issue #9450). - const installFrozenTaskListTool = (taskListArgs: object) => { - const taskListInvocation = { - params: taskListArgs, - getDescription: vi.fn().mockReturnValue('List tasks'), - toolLocations: vi.fn().mockReturnValue([]), - getDefaultPermission: vi.fn().mockResolvedValue('allow'), - // No teammate activity: every poll returns the identical board. - execute: vi.fn().mockResolvedValue({ - llmContent: '#7 [in_progress] @peer-a — task', - returnDisplay: 'Listed tasks', - }), - }; - const taskListTool = { - name: 'task_list', - displayName: 'Task List', - description: 'List tasks in the team task list', - kind: 'READ' as const, - schema: { - name: 'task_list', - description: 'Lists team tasks', - parameters: { type: Type.OBJECT, properties: {} }, - }, - build: vi.fn().mockImplementation(() => taskListInvocation), - canUpdateOutput: false, - isOutputMarkdown: false, - } as unknown as AnyDeclarativeTool; - return { taskListInvocation, taskListTool }; - }; - - it('still halts a frozen task_list streak when one poll is a cross-round replay of a handled id (issue #9450)', async () => { - 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 4 replays poll_1 (already handled in round 1): the provider - // re-emits a handled call id, exactly the misbehavior the - // suppression machinery exists for. - const [replayedPart] = normalizeModelToolCallIds( - [ - { - functionCall: { - id: 'poll_1', - name: 'task_list', - args: taskListArgs, - }, - }, - ], - new Set(['poll_1']), - new Set(), - ); - mockSendMessageStream.mockImplementation( - createMockStream([ - [{ id: 'poll_1', name: 'task_list', args: taskListArgs }], - [{ id: 'poll_2', name: 'task_list', args: taskListArgs }], - [{ id: 'poll_3', name: 'task_list', args: taskListArgs }], - [replayedPart!.functionCall!], - [{ id: 'poll_4', name: 'task_list', args: taskListArgs }], - [{ id: 'poll_5', name: 'task_list', args: taskListArgs }], - 'stop', - ]), - ); - - const { taskListInvocation, taskListTool } = - installFrozenTaskListTool(taskListArgs); - vi.mocked( - (config.getToolRegistry() as unknown as ToolRegistry).getTool, - ).mockImplementation((name: string) => - name === 'task_list' ? taskListTool : undefined, - ); - - const finishEvents: Array<{ loopType?: string }> = []; - const eventEmitter = new AgentEventEmitter(); - eventEmitter.on(AgentEventType.FINISH, (event: unknown) => { - finishEvents.push(event as { loopType?: string }); - }); - - const scope = await AgentHeadless.create( - 'test-agent', - config, - promptConfig, - defaultModelConfig, - { ...defaultRunConfig, max_turns: 20 }, - toolConfig, - eventEmitter, - ); - - await scope.execute(new ContextState()); - - // The replay never executes, but its suppression KEEPS the - // streamed-in repetition increment (the keep-suppressed-counts fix - // for #9450: unwinding it let a pure stream of identical - // suppressed calls oscillate the count forever and escape the - // threshold) and counts the replay into the streak's - // suppressedRequests instead, which the exoneration gate - // subtracts from the expected results so changing boards stay - // exonerable. The streak therefore counts FIVE identical requests - // by poll_4's stream — poll_1..poll_3, the replay, poll_4 — and - // the halt lands there, one round earlier than under the old - // suppression unwind the previous pin tracked: expectedResults is - // 5 - 1 in flight (poll_4) - 1 suppressed (the replay) = 3, - // exactly the three unchanged frozen-board results recorded for - // poll_1..poll_3 (unchangedStreak 2 >= expectedResults - 1), so - // the halt is corroborated by the executed evidence after three - // executions — under the unwind the replay's count was erased, - // the fifth countable request was poll_5, and the pin tracked - // four executions. Pre-fix the fabricated replay error counted - // as a changed result, the streak restarted, and the run sailed - // to GOAL. - expect(taskListInvocation.execute).toHaveBeenCalledTimes(3); - expect(mockSendMessageStream).toHaveBeenCalledTimes(5); - expect(scope.getTerminateMode()).toBe(AgentTerminateMode.LOOP_DETECTED); - expect(finishEvents).toHaveLength(1); - expect(finishEvents[0].loopType).toBe( - 'consecutive_identical_tool_calls', - ); - }); - - it('still halts interleaved frozen task_list polling when one poll is a cross-round replay (issue #9450)', async () => { - const taskListToolDef: FunctionDeclaration = { - name: 'task_list', - description: 'Lists team tasks', - parameters: { type: Type.OBJECT, properties: {} }, - }; - const fillerToolDef: FunctionDeclaration = { - name: 'tool_b', - description: 'Distinct filler work', - parameters: { type: Type.OBJECT, properties: {} }, - }; - const { config } = await createMockConfig({ - getFunctionDeclarationsFiltered: vi - .fn() - .mockReturnValue([taskListToolDef, fillerToolDef]), - getTool: vi.fn().mockReturnValue(undefined), - }); - const toolConfig: ToolConfig = { tools: ['task_list', 'tool_b'] }; - const taskListArgs = { - status: 'in_progress', - owner: 'peer-a', - blockedBy: '', - }; - - // Interleaved shape of the result-time-guard test above, with one - // cross-round replay of poll_1 between poll_3 and poll_4. - const [replayedPart] = normalizeModelToolCallIds( - [ - { - functionCall: { - id: 'poll_1', - name: 'task_list', - args: taskListArgs, - }, - }, - ], - new Set(['poll_1']), - new Set(), - ); - const turns: Array = [ - [{ id: 'poll_1', name: 'task_list', args: taskListArgs }], - [{ id: 'fill_1', name: 'tool_b', args: { step: 1 } }], - [{ id: 'poll_2', name: 'task_list', args: taskListArgs }], - [{ id: 'fill_2', name: 'tool_b', args: { step: 2 } }], - [{ id: 'poll_3', name: 'task_list', args: taskListArgs }], - [replayedPart!.functionCall!], - [{ id: 'poll_4', name: 'task_list', args: taskListArgs }], - [{ id: 'fill_4', name: 'tool_b', args: { step: 4 } }], - [{ id: 'poll_5', name: 'task_list', args: taskListArgs }], - [{ id: 'fill_5', name: 'tool_b', args: { step: 5 } }], - [{ id: 'poll_6', name: 'task_list', args: taskListArgs }], - // Extra rounds the pre-fix run consumes after its streak reset. - [{ id: 'fill_6', name: 'tool_b', args: { step: 6 } }], - [{ id: 'poll_7', name: 'task_list', args: taskListArgs }], - [{ id: 'fill_7', name: 'tool_b', args: { step: 7 } }], - [{ id: 'poll_8', name: 'task_list', args: taskListArgs }], - 'stop', - ]; - mockSendMessageStream.mockImplementation(createMockStream(turns)); - - const { taskListInvocation, taskListTool } = - installFrozenTaskListTool(taskListArgs); - const fillerInvocation = { - params: {}, - getDescription: vi.fn().mockReturnValue('Filler'), - toolLocations: vi.fn().mockReturnValue([]), - getDefaultPermission: vi.fn().mockResolvedValue('allow'), - execute: vi.fn().mockResolvedValue({ - llmContent: 'filler done', - returnDisplay: 'Filler done', - }), - }; - const fillerTool = { - name: 'tool_b', - displayName: 'Tool B', - description: 'Distinct filler work', - kind: 'READ' as const, - schema: fillerToolDef, - build: vi.fn().mockImplementation(() => fillerInvocation), - canUpdateOutput: false, - isOutputMarkdown: false, - } as unknown as AnyDeclarativeTool; - vi.mocked( - (config.getToolRegistry() as unknown as ToolRegistry).getTool, - ).mockImplementation((name: string) => - name === 'task_list' - ? taskListTool - : name === 'tool_b' - ? fillerTool - : undefined, - ); - - const finishEvents: Array<{ loopType?: string }> = []; - const eventEmitter = new AgentEventEmitter(); - eventEmitter.on(AgentEventType.FINISH, (event: unknown) => { - finishEvents.push(event as { loopType?: string }); - }); - - const scope = await AgentHeadless.create( - 'test-agent', - config, - promptConfig, - defaultModelConfig, - { ...defaultRunConfig, max_turns: 20 }, - toolConfig, - eventEmitter, - ); - - await scope.execute(new ContextState()); - - // Six executed frozen polls: the replay round never executes and - // its synthetic response is excluded, so the result-time - // global-duplicate count runs through it and halts when the sixth - // identical (call, result) pair is recorded. Pre-fix the fabricated - // replay error reset the streak and the halt slipped past poll_6. - expect(taskListInvocation.execute).toHaveBeenCalledTimes(6); - expect(mockSendMessageStream).toHaveBeenCalledTimes(11); - expect(scope.getTerminateMode()).toBe(AgentTerminateMode.LOOP_DETECTED); - expect(finishEvents).toHaveLength(1); - 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', diff --git a/packages/core/src/agents/runtime/agent-headless.ts b/packages/core/src/agents/runtime/agent-headless.ts index 0f97cf87305..1f675520495 100644 --- a/packages/core/src/agents/runtime/agent-headless.ts +++ b/packages/core/src/agents/runtime/agent-headless.ts @@ -483,17 +483,6 @@ export class AgentHeadless { return this.terminateMode; } - /** - * Which loop detector fired when terminateMode is LOOP_DETECTED (issue - * #9450), or null otherwise. Lets consumers that read the terminal state - * AFTER execute() returns (the agent tool's post-await display update) - * attribute the stop the same way the FINISH event handler does, since - * the event alone is overwritten by that update. - */ - getLoopType(): string | null { - return this.loopType; - } - /** * Sets a callback that the reasoning loop calls between tool rounds * to drain external messages (e.g. from SendMessage tool). diff --git a/packages/core/src/agents/runtime/agent-interactive.test.ts b/packages/core/src/agents/runtime/agent-interactive.test.ts index 8d21f680f47..2ff6d965bf6 100644 --- a/packages/core/src/agents/runtime/agent-interactive.test.ts +++ b/packages/core/src/agents/runtime/agent-interactive.test.ts @@ -16,8 +16,6 @@ import type { } from './agent-events.js'; import { ContextState } from './agent-headless.js'; import type { AgentInteractiveConfig } from './agent-types.js'; -import { AgentTerminateMode } from './agent-types.js'; -import { LoopType } from '../../telemetry/types.js'; import { AgentStatus } from './agent-types.js'; import { getCurrentAgentDepth, @@ -35,12 +33,7 @@ function createMockCore( overrides: { chatValue?: unknown; nullChat?: boolean; - loopResult?: { - text: string; - terminateMode: AgentTerminateMode | null; - loopType?: LoopType | null; - turnsUsed: number; - }; + loopResult?: { text: string; terminateMode: null; turnsUsed: number }; } = {}, ) { const emitter = new AgentEventEmitter(); @@ -314,47 +307,6 @@ describe('AgentInteractive', () => { expect(agent.getStatus()).toBe('completed'); }); - it('surfaces the exact loop detector in the stop message and lastRoundError (#9450)', async () => { - const { core } = createMockCore({ - loopResult: { - text: '', - terminateMode: AgentTerminateMode.LOOP_DETECTED, - loopType: LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, - turnsUsed: 3, - }, - }); - const config = createConfig({ initialTask: 'poll tasks' }); - const agent = new AgentInteractive(config, core); - - await agent.start(context); - await vi.waitFor(() => { - // A round that terminates on a detected loop settles as failed - // (lastRoundError is set), not idle. - expect(agent.getStatus()).toBe('failed'); - }); - - // The user-visible stop message names the detector instead of the - // generic loop label. - const infoTexts = agent - .getMessages() - .filter((m) => m.role === 'info') - .map((m) => String(m.content)); - expect( - infoTexts.some((text) => - text.includes( - `duplicate tool-call loop detected (${LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS})`, - ), - ), - ).toBe(true); - - // Arena-facing attribution keeps the detector too. - expect(agent.getLastRoundError()).toBe( - `Terminated: ${AgentTerminateMode.LOOP_DETECTED} (${LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS})`, - ); - - await agent.shutdown(); - }); - it('should process enqueued messages', async () => { const { core } = createMockCore(); const config = createConfig(); diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index ee49b403d29..3f938fe661e 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -47,11 +47,9 @@ import { UnauthorizedError } from '../utils/errors.js'; import { retryWithBackoff } from '../utils/retry.js'; import { CompressionStatus, - createDuplicateProviderToolCallResponse, GeminiEventType, Turn, type ServerGeminiStreamEvent, - type ServerGeminiToolCallRequestEvent, } from './turn.js'; import { LoopType } from '../telemetry/types.js'; import { logMemoryRecallDelivery } from '../telemetry/index.js'; @@ -8024,394 +8022,6 @@ hello expect(client['pendingMemoryPrefetch']).toBeUndefined(); }); - // Drives sendMessageStream with ToolResult messages whose - // functionResponse ids match previously streamed ToolCallRequest - // callIds, exercising the result-aware recording branch on the main - // interactive path (issue #9450). - async function runTaskListPollTurns( - board: (round: number) => string, - maxRounds = 9, - ) { - const promptId = 'prompt-task-list-poll'; - const taskListArgs = { status: 'in_progress', owner: 'peer-a' }; - const allEvents: Array<{ type: string; value?: unknown }> = []; - for (let round = 0; round <= maxRounds; round++) { - mockTurnRunFn.mockReturnValueOnce( - (async function* () { - yield { - type: GeminiEventType.ToolCallRequest, - value: { - callId: `tl-${round}`, - name: 'task_list', - args: taskListArgs, - isClientInitiated: false, - prompt_id: promptId, - }, - }; - yield { - type: GeminiEventType.ToolCallRequest, - value: { - callId: `other-${round}`, - name: 'tool_b', - args: { step: round }, - isClientInitiated: false, - prompt_id: promptId, - }, - }; - })(), - ); - const contents = - round === 0 - ? [{ text: 'poll the board' }] - : [ - { - functionResponse: { - id: `tl-${round - 1}`, - name: 'task_list', - response: { output: board(round - 1) }, - }, - }, - { - functionResponse: { - id: `other-${round - 1}`, - name: 'tool_b', - response: { output: `step ${round - 1}` }, - }, - }, - ]; - const events = await fromAsync( - client.sendMessageStream( - contents as never, - new AbortController().signal, - promptId, - { - type: - round === 0 - ? SendMessageType.UserQuery - : SendMessageType.ToolResult, - }, - ), - ); - allEvents.push(...(events as Array<{ type: string; value?: unknown }>)); - if ( - allEvents.some((e) => e.type === GeminiEventType.LoopDetected) || - !events.some((e) => e.type === GeminiEventType.ToolCallRequest) - ) { - return allEvents; - } - } - return allEvents; - } - - it('halts the interactive turn when paired ToolResults show a frozen stateful board (#9450)', async () => { - const events = await runTaskListPollTurns(() => 'frozen board'); - const loopEvent = events.find( - (e) => e.type === GeminiEventType.LoopDetected, - ); - expect(loopEvent).toBeDefined(); - expect( - (loopEvent?.value as { loopType?: string } | undefined)?.loopType, - ).toBe('global_tool_call_duplicate'); - }); - - it('keeps the interactive turn alive while paired ToolResults keep changing (#9450)', async () => { - const events = await runTaskListPollTurns((round) => `board v${round}`); - expect(events.some((e) => e.type === GeminiEventType.LoopDetected)).toBe( - false, - ); - }); - - // Variant of runTaskListPollTurns that polls ONLY task_list (no - // interleaved tool), so identical (name, args) build one unbroken - // consecutive streak across rounds. Round 0 streams the same call id - // twice — the provider-duplicate emission dedupeRequestsByCallId - // collapses into one executed call and one functionResponse — so request - // counts and result evidence desync unless the loop-guard feed counts one - // event per call id per attempt (issue #9450). - async function runDuplicateIdTaskListPollTurns( - board: (round: number) => string, - maxRounds = 6, - ) { - const promptId = 'prompt-task-list-dup-poll'; - const taskListArgs = { status: 'in_progress', owner: 'peer-a' }; - const allEvents: Array<{ type: string; value?: unknown }> = []; - for (let round = 0; round <= maxRounds; round++) { - const request = (callId: string) => ({ - type: GeminiEventType.ToolCallRequest, - value: { - callId, - name: 'task_list', - args: taskListArgs, - isClientInitiated: false, - prompt_id: promptId, - }, - }); - mockTurnRunFn.mockReturnValueOnce( - (async function* () { - yield request(`tl-${round}`); - if (round === 0) { - // Provider-duplicate emission of the same call id: execution - // collapses it (one functionResponse comes back below), so the - // loop-guard feed must count it once. - yield request(`tl-${round}`); - } - })(), - ); - const contents = - round === 0 - ? [{ text: 'poll the board' }] - : [ - { - functionResponse: { - id: `tl-${round - 1}`, - name: 'task_list', - response: { output: board(round - 1) }, - }, - }, - ]; - const events = await fromAsync( - client.sendMessageStream( - contents as never, - new AbortController().signal, - promptId, - { - type: - round === 0 - ? SendMessageType.UserQuery - : SendMessageType.ToolResult, - }, - ), - ); - allEvents.push(...(events as Array<{ type: string; value?: unknown }>)); - if ( - allEvents.some((e) => e.type === GeminiEventType.LoopDetected) || - !events.some((e) => e.type === GeminiEventType.ToolCallRequest) - ) { - return allEvents; - } - } - return allEvents; - } - - it('counts a provider-duplicate call id once so changed-board polls never halt (#9450)', async () => { - const events = await runDuplicateIdTaskListPollTurns( - (round) => `board v${round}`, - ); - expect(events.some((e) => e.type === GeminiEventType.LoopDetected)).toBe( - false, - ); - // The turn kept polling through every round: 7 rounds, 7 unique call - // ids, 8 streamed events (round 0's id is emitted twice and both - // emissions still reach consumers — only the guard feed is deduped). - // Without the feed dedup the duplicate round-0 emission desyncs the - // request counter one ahead of the result evidence and the guard halts - // the streak mid-poll. - const taskListRequests = events.filter( - (e) => - e.type === GeminiEventType.ToolCallRequest && - (e.value as { name?: string }).name === 'task_list', - ); - expect(taskListRequests).toHaveLength(8); - expect( - new Set( - taskListRequests.map((e) => (e.value as { callId: string }).callId), - ).size, - ).toBe(7); - }); - - it('still halts a frozen board despite the duplicate-call-id feed dedup (#9450)', async () => { - const events = await runDuplicateIdTaskListPollTurns( - () => 'frozen board', - ); - const loopEvent = events.find( - (e) => e.type === GeminiEventType.LoopDetected, - ); - expect(loopEvent).toBeDefined(); - expect( - (loopEvent?.value as { loopType?: string } | undefined)?.loopType, - ).toBe('consecutive_identical_tool_calls'); - }); - - // Variant of runTaskListPollTurns where one mid-streak poll arrives as - // a cross-round replay of an already-handled call id: useGeminiStream - // suppresses the execution and submits the fabricated duplicate error - // back as a ToolResult message (executionStatus 'not_started' — never - // executed). Streams also yield Finished events so the round-trip - // boundary decay runs between rounds, exactly as in production. The - // synthetic response must be excluded from the result-aware recording — - // the daemon twin filters the same class (issue #9450 requirement #6). - async function runReplayedHandledIdPollTurns( - board: (round: number) => string, - maxRounds = 9, - ) { - const promptId = 'prompt-task-list-replay-poll'; - const taskListArgs = { status: 'in_progress', owner: 'peer-a' }; - const replayRound = 3; // streams a replay of the handled id 'tl-1' - const allEvents: Array<{ type: string; value?: unknown }> = []; - const request = (callId: string, name: string, args: object) => ({ - type: GeminiEventType.ToolCallRequest, - value: { - callId, - name, - args, - isClientInitiated: false, - prompt_id: promptId, - }, - }); - for (let round = 0; round <= maxRounds; round++) { - const taskListCallId = round === replayRound ? 'tl-1' : `tl-${round}`; - mockTurnRunFn.mockReturnValueOnce( - (async function* () { - yield request(taskListCallId, 'task_list', taskListArgs); - yield request(`other-${round}`, 'tool_b', { step: round }); - yield { type: GeminiEventType.Finished }; - })(), - ); - let contents: object[]; - if (round === 0) { - contents = [{ text: 'poll the board' }]; - } else if (round - 1 === replayRound) { - // The replayed call never executed: its ToolResult is the - // fabricated duplicate error useGeminiStream submits back. - const synthetic = createDuplicateProviderToolCallResponse({ - callId: 'tl-1', - name: 'task_list', - args: taskListArgs, - } as never); - contents = [ - synthetic.responseParts[0], - { - functionResponse: { - id: `other-${round - 1}`, - name: 'tool_b', - response: { output: `step ${round - 1}` }, - }, - }, - ]; - } else { - contents = [ - { - functionResponse: { - id: `tl-${round - 1}`, - name: 'task_list', - response: { output: board(round - 1) }, - }, - }, - { - functionResponse: { - id: `other-${round - 1}`, - name: 'tool_b', - response: { output: `step ${round - 1}` }, - }, - }, - ]; - } - const events = await fromAsync( - client.sendMessageStream( - contents as never, - new AbortController().signal, - promptId, - { - type: - round === 0 - ? SendMessageType.UserQuery - : SendMessageType.ToolResult, - }, - ), - ); - allEvents.push(...(events as Array<{ type: string; value?: unknown }>)); - if ( - allEvents.some((e) => e.type === GeminiEventType.LoopDetected) || - !events.some((e) => e.type === GeminiEventType.ToolCallRequest) - ) { - return allEvents; - } - } - return allEvents; - } - - it('still halts a frozen board when one poll arrives as a replayed handled id (#9450)', async () => { - const events = await runReplayedHandledIdPollTurns(() => 'frozen board'); - const loopEvent = events.find( - (e) => e.type === GeminiEventType.LoopDetected, - ); - // The replay's fabricated error never executed: it must not pair with - // the replayed request as a "changed" result. With it excluded, the - // six frozen boards record consecutively across the replay round and - // trip the result-time global-duplicate count; with it recorded (the - // pre-fix behavior) the streak restarts and no halt lands in budget. - expect(loopEvent).toBeDefined(); - expect( - (loopEvent?.value as { loopType?: string } | undefined)?.loopType, - ).toBe('global_tool_call_duplicate'); - // The halt lands at the sixth recorded frozen board — the seventh - // ToolResult turn — before that turn's model stream runs: 7 streams - // (rounds 0-6) out of the 10 the harness would otherwise consume. - expect(mockTurnRunFn).toHaveBeenCalledTimes(7); - }); - - it('feeds the loop guards one event per call id per attempt, re-feeding after retries (#9450)', async () => { - const loopDetector = client['loopDetector']; - const alwaysOnSpy = vi - .spyOn(loopDetector, 'checkAlwaysOnSafeties') - .mockReturnValue(false); - const heuristicSpy = vi - .spyOn(loopDetector, 'addAndCheckHeuristicLoops') - .mockReturnValue(false); - - const request = (callId: string) => ({ - type: GeminiEventType.ToolCallRequest, - value: { - callId, - name: 'task_list', - args: { status: 'in_progress' }, - isClientInitiated: false, - prompt_id: 'prompt-dup-feed', - }, - }); - mockTurnRunFn.mockReturnValue( - (async function* () { - yield request('dup-1'); - // Provider-duplicate emission within the same attempt. - yield request('dup-1'); - yield { type: GeminiEventType.Retry }; - // Fresh attempt: the attempt boundary re-feeds the same id. - yield request('dup-1'); - yield request('unique-2'); - })(), - ); - - const events = await fromAsync( - client.sendMessageStream( - [{ text: 'poll' }] as never, - new AbortController().signal, - 'prompt-dup-feed', - { type: SendMessageType.UserQuery }, - ), - ); - - const fedCallIds = (spy: typeof alwaysOnSpy) => - spy.mock.calls - .map((call) => call[0]) - .filter( - (e): e is ServerGeminiToolCallRequestEvent => - e.type === GeminiEventType.ToolCallRequest, - ) - .map((e) => e.value.callId); - - // One feed per call id per attempt: the in-attempt duplicate is - // skipped, and the retry clears the attempt boundary so the re-streamed - // id is fed again. - expect(fedCallIds(alwaysOnSpy)).toEqual(['dup-1', 'dup-1', 'unique-2']); - expect(fedCallIds(heuristicSpy)).toEqual(['dup-1', 'dup-1', 'unique-2']); - - // The dedup happens only at the guard feed: every emission still - // reaches stream consumers. - expect( - events.filter((e) => e.type === GeminiEventType.ToolCallRequest), - ).toHaveLength(4); - }); - it('should halt via the always-on turn cap before the skipLoopDetection gate', async () => { let abortHandlerInvoked = false; mockMemoryManager.recall.mockImplementation((_root, _query, opts) => { diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 3d664d147d9..2dcea487a62 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -71,7 +71,6 @@ import { import { CompressionStatus, GeminiEventType, - isDuplicateProviderToolCallResponse, Turn, type ChatCompressionInfo, type ServerGeminiStreamEvent, @@ -3576,24 +3575,6 @@ export class GeminiClient { } const functionResponseId = (part as Part).functionResponse?.id; if (!functionResponseId) continue; - // Synthetic duplicate responses (cross-round replays of - // already-handled call ids, suppressed by useGeminiStream) never - // executed, so they carry no result evidence. Recording one would - // pair the fabricated error with the replayed request and reset - // the guards' streaks as a "changed" result, disarming every - // result-aware halt — the daemon twin excludes this class via its - // providerDuplicate / not_started filter (issue #9450). The - // replaySuppression mark carries the live streak evidence across - // the next Finished boundary — the daemon twin skips decay for - // all-replay batches, and a replay of a NON-stateful tool marks - // nothing on its own (issue #9450 requirement #6). - if (isDuplicateProviderToolCallResponse(part as Part)) { - this.loopDetector.noteSuppressedToolCallByCallId( - functionResponseId, - { replaySuppression: true }, - ); - continue; - } if ( this.loopDetector.recordToolResultByCallId(functionResponseId, [ part as Part, @@ -3706,16 +3687,6 @@ export class GeminiClient { const resultStream = turn.run(model, requestToSend, signal); let didUpdateIdeContextState = false; let steerInputSettled = false; - // callIds already fed to the loop guards this attempt. Mirrors the - // execution-side dedup (coreToolScheduler.dedupeRequestsByCallId / the - // interactive duplicate-call-id suppression), which collapses - // provider-duplicate emissions into one executed call and one result: - // feeding the guards once per call id keeps request counts and result - // evidence on the same population (main-session twin of the agent-core - // fix, issue #9450). Id-less requests are never deduped, mirroring - // dedupeRequestsByCallId. Cleared on retry/fallback alongside the - // attempt's accumulated state. - const loopGuardFedCallIds = new Set(); try { for await (const event of resultStream) { if (!steerInputSettled) { @@ -3735,7 +3706,6 @@ export class GeminiClient { event.type === GeminiEventType.ModelFallback ) { hasToolCalls = false; - loopGuardFedCallIds.clear(); agentOutput.restartAttempt( event.type === GeminiEventType.Retry && event.isContinuation === true, @@ -3755,28 +3725,11 @@ export class GeminiClient { didUpdateIdeContextState = true; } - // A provider-duplicate emission of an already-fed call id executes - // once (the schedulers collapse it), so feed the loop guards once — - // counting both emissions would leave the request counters one ahead - // of the executed result evidence and fail-safe-halt a productive - // stateful poller (issue #9450). The event itself still flows to - // consumers below; only the guard feed is deduped. - let duplicateLoopGuardRequest = false; - if (event.type === GeminiEventType.ToolCallRequest) { - const fedCallId = event.value.callId; - if (fedCallId) { - duplicateLoopGuardRequest = loopGuardFedCallIds.has(fedCallId); - loopGuardFedCallIds.add(fedCallId); - } - } - // Always-on safety checks (consecutive-identical tool-call guard, // shell inspection stagnation, and per-turn tool-call cap). These fire // before the skipLoopDetection gate so they cannot be bypassed by // configuration. - const alwaysOnLoop = - !duplicateLoopGuardRequest && - this.loopDetector.checkAlwaysOnSafeties(event); + const alwaysOnLoop = this.loopDetector.checkAlwaysOnSafeties(event); if (alwaysOnLoop) { // Drop every tool call collected before the guard fired so the run // halts here instead of spawning a continuation that re-trips it. @@ -3814,7 +3767,6 @@ export class GeminiClient { // relaxes the heuristics (see nonInteractiveCli.ts). const skipLoopDetection = this.config.getSkipLoopDetection(); const heuristicLoop = - !duplicateLoopGuardRequest && !skipLoopDetection && this.loopDetector.addAndCheckHeuristicLoops(event); if (heuristicLoop) { diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 17ed3f32b09..09e826c3ed9 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -220,40 +220,15 @@ function buildApiErrorReportContext(chat: GeminiChat, req: PartListUnion) { }; } -// Stable prefix of the synthetic duplicate response's error text (see -// duplicateProviderToolCallMessage). Shared by the producer and the -// isDuplicateProviderToolCallResponse discriminator so the two cannot -// drift apart. -const DUPLICATE_PROVIDER_TOOL_CALL_MESSAGE_PREFIX = - 'Duplicate provider tool call id "'; - function duplicateProviderToolCallMessage(providerCallId: string): string { return ( - `${DUPLICATE_PROVIDER_TOOL_CALL_MESSAGE_PREFIX}${providerCallId}" was already handled. ` + + `Duplicate provider tool call id "${providerCallId}" was already handled. ` + `The duplicate tool call was ignored and not executed again. If you ` + `intended to run this tool again, re-issue the call with a new unique ` + `tool-call id (or explicitly different arguments).` ); } -/** - * Whether a response part is the synthetic error fabricated for a - * suppressed duplicate provider tool call (see - * createDuplicateProviderToolCallResponse). Such a part never executed, so - * it carries no result evidence for the result-aware loop guards and must - * be excluded from their recording feeds (issue #9450) — the daemon twin - * filters the same class via executionStatus/providerDuplicate metadata, - * which main-session parts do not carry, leaving the message text as the - * only discriminator here. - */ -export function isDuplicateProviderToolCallResponse(part: Part): boolean { - const error = part.functionResponse?.response?.['error']; - return ( - typeof error === 'string' && - error.startsWith(DUPLICATE_PROVIDER_TOOL_CALL_MESSAGE_PREFIX) - ); -} - export function createDuplicateProviderToolCallResponse( request: ToolCallRequestInfo, ): ToolCallResponseInfo { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 54caa3879ba..07f64109199 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -327,8 +327,6 @@ export { DEFAULT_MAX_TOOL_CALLS_PER_TURN, GLOBAL_DUPLICATE_THRESHOLD, getToolCallRepeatKey, - isStatefulReadTool, - fingerprintToolResult, shouldHaltOnTurnToolCallCap, } from './services/loopDetectionService.js'; export * from './services/visionBridge/vision-bridge-service.js'; diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index 875ab104f7b..09b8eb56619 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -5,10 +5,6 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createHash } from 'node:crypto'; -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import * as path from 'node:path'; import type { Part } from '@google/genai'; import type { Config } from '../config/config.js'; import type { @@ -23,21 +19,8 @@ import { GeminiEventType } from '../core/turn.js'; import * as loggers from '../telemetry/loggers.js'; import { LoopType } from '../telemetry/types.js'; import type { DebugLogger } from '../utils/debugLogger.js'; -import { - BATCH_BUDGET_FIT_PREFIX, - enforceFunctionResponseBudget, -} from '../tools/tool-response-finalizer.js'; -import { - buildStub, - FULL_OUTPUT_DIGEST_LABEL, - PREVIEW_SIZE_CHARS, - TRUNCATION_SAVE_FAILURE_NOTE, - truncateAndSaveToFile, -} from '../tools/truncation.js'; import { DEFAULT_MAX_TOOL_CALLS_PER_TURN, - extractToolResultText, - fingerprintToolResult, LoopDetectionService, } from './loopDetectionService.js'; @@ -55,7 +38,6 @@ const FILE_READ_WINDOW = 15; const GLOBAL_DUPLICATE_THRESHOLD = 6; const SHELL_COMMAND_STAGNATION_THRESHOLD = 8; const ALTERNATING_PATTERN_CYCLES = 3; -const MAX_TRACKED_TOOL_REQUESTS = 500; describe('LoopDetectionService', () => { let service: LoopDetectionService; @@ -75,8 +57,8 @@ describe('LoopDetectionService', () => { getTelemetryEnabled: () => true, getMaxToolCallsPerTurn: () => cap, isMaxToolCallsPerTurnExplicit: () => explicit, - getSkipLoopDetection: () => skipLoopDetection, getDebugLogger: () => mockDebugLogger, + getSkipLoopDetection: () => skipLoopDetection, }) as unknown as Config; beforeEach(() => { @@ -2803,88 +2785,6 @@ describe('LoopDetectionService', () => { expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); - it('does not halt a parallel-batch task_list poller whose results keep changing (issue #9450)', () => { - // Parallel same-round identical requests: ALL of a round's requests - // stream through the always-on guard before ANY of that round's - // results is recorded (production ordering: requests → Finished → - // results). Pre-fix the exoneration gate assumed the prior N-1 - // results of the Nth identical request had all landed — with rounds - // [poll], [poll, poll], [poll, poll] the 5th request saw only 3 - // recorded results against expectedResults 4, the gate was skipped, - // and a productive changing-board poller halted - // CONSECUTIVE_IDENTICAL_TOOL_CALLS (the #9450 false positive - // re-entering via a parallel batch). - const finishedEvent = { - type: GeminiEventType.Finished, - value: { reason: 'STOP' }, - } as unknown as ServerGeminiStreamEvent; - const parallelService = new LoopDetectionService(makeConfig()); - parallelService.reset('parallel-productive'); - - const roundSizes = [1, 2, 2]; - let poll = 0; - let fired = false; - for (const roundSize of roundSizes) { - for (let i = 0; i < roundSize && !fired; i++) { - fired = parallelService.checkAlwaysOnSafeties( - taskListEvent(`poll-${poll}`), - ); - poll++; - } - if (fired) break; - parallelService.checkAlwaysOnSafeties(finishedEvent); - for (let i = 0; i < roundSize; i++) { - fired = parallelService.recordToolResultByCallId( - `poll-${poll - roundSize + i}`, - taskListResult( - `board state v${poll - roundSize + i}`, - `poll-${poll - roundSize + i}`, - ), - ); - if (fired) break; - } - } - expect(fired).toBe(false); - expect(parallelService.getLastLoopType()).toBeNull(); - }); - - it('still halts a parallel-batch task_list poller on a frozen board (fail-safe)', () => { - // Fail-safe twin of the parallel-batch regression: with an unchanged - // board the recorded results corroborate the repetition, so the - // in-flight-aware gate still halts at the 5th identical request. - const finishedEvent = { - type: GeminiEventType.Finished, - value: { reason: 'STOP' }, - } as unknown as ServerGeminiStreamEvent; - const parallelService = new LoopDetectionService(makeConfig()); - parallelService.reset('parallel-frozen'); - - const roundSizes = [1, 2, 2]; - let poll = 0; - let fired = false; - for (const roundSize of roundSizes) { - for (let i = 0; i < roundSize && !fired; i++) { - fired = parallelService.checkAlwaysOnSafeties( - taskListEvent(`poll-${poll}`), - ); - poll++; - } - if (fired) break; - parallelService.checkAlwaysOnSafeties(finishedEvent); - for (let i = 0; i < roundSize; i++) { - fired = parallelService.recordToolResultByCallId( - `poll-${poll - roundSize + i}`, - taskListResult('frozen board', `poll-${poll - roundSize + i}`), - ); - if (fired) break; - } - } - expect(fired).toBe(true); - expect(parallelService.getLastLoopType()).toBe( - LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, - ); - }); - it('keeps productive polling alive past the adaptive per-turn cap', () => { // With the default (adaptive) cap, a turn beyond the soft cap halts // only on a stuck-repetition signal. Changed results must not build @@ -2998,67 +2898,6 @@ describe('LoopDetectionService', () => { ).toBe(false); }); - it('keeps task_list pairing evidence alive through a flood of non-stateful callIds', () => { - // Pins the `&& stateful` condition of the requestByCallId population - // guard (checkAlwaysOnSafeties). If it is dropped, every callId-carrying - // call of a large turn accumulates its full args in the map, the - // eviction past MAX_TRACKED_TOOL_REQUESTS discards the oldest entry — - // here the task_list request itself — and its result can never pair, - // so the result-aware consecutive guard loses its evidence and halts - // productive polling (the #9450 false positive re-shipped). - const floodEvent = (i: number): ServerGeminiToolCallRequestEvent => ({ - type: GeminiEventType.ToolCallRequest, - value: { - name: 'tool_b', - args: { step: i }, - callId: `flood-${i}`, - isClientInitiated: false, - prompt_id: 'test-prompt-id', - }, - }); - - // Request #1 of the task_list streak. - expect(service.checkAlwaysOnSafeties(taskListEvent('tl-1'))).toBe(false); - - // A turn large enough to overflow the callId pairing map with - // non-stateful entries — only possible if the stateful condition goes. - for (let i = 0; i < MAX_TRACKED_TOOL_REQUESTS + 10; i++) { - expect(service.checkAlwaysOnSafeties(floodEvent(i))).toBe(false); - } - - // Resume the identical task_list streak; the interrupted streak - // restarts its result evidence. - expect(service.checkAlwaysOnSafeties(taskListEvent('tl-2'))).toBe(false); - - // Results arrive through the callId pairing, each poll returning a - // changed board. The pre-flood request (tl-1) must still pair even - // though the flood filled the map past its cap. - expect( - service.recordToolResultByCallId('tl-1', taskListResult('v1', 'tl-1')), - ).toBe(false); - expect( - service.recordToolResultByCallId('tl-2', taskListResult('v2', 'tl-2')), - ).toBe(false); - expect(service.checkAlwaysOnSafeties(taskListEvent('tl-3'))).toBe(false); - expect( - service.recordToolResultByCallId('tl-3', taskListResult('v3', 'tl-3')), - ).toBe(false); - expect(service.checkAlwaysOnSafeties(taskListEvent('tl-4'))).toBe(false); - expect( - service.recordToolResultByCallId('tl-4', taskListResult('v4', 'tl-4')), - ).toBe(false); - expect(service.checkAlwaysOnSafeties(taskListEvent('tl-5'))).toBe(false); - - // 5th request of the resumed streak: the result-aware guard wants all - // 4 prior results as evidence. With the stateful condition intact, - // tl-1's changed result survived the flood, the evidence is complete, - // and the changed results keep the polling alive. Without `&& stateful` - // tl-1 was evicted by the flood, evidence falls to 3 < 4, and the - // guard fails safe into a halt. - expect(service.checkAlwaysOnSafeties(taskListEvent('tl-6'))).toBe(false); - expect(loggers.logLoopDetected).not.toHaveBeenCalled(); - }); - it('counts global duplicates on (call, result) pairs when heuristics run', () => { const heuristicService = new LoopDetectionService( makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), @@ -3066,13 +2905,10 @@ describe('LoopDetectionService', () => { heuristicService.reset('global-dup'); // Identical task_list calls whose results CHANGE never reach the - // global-duplicate threshold, no matter how they are interleaved. Run - // past GLOBAL_DUPLICATE_THRESHOLD rounds so an args-only mutant (which - // would halt on the 6th same-args request) cannot hide in a short - // phase. + // global-duplicate threshold, no matter how they are interleaved. const interleaved = ['task_list', 'tool_b', 'tool_c']; let stateOrdinal = 0; - for (let round = 0; round < GLOBAL_DUPLICATE_THRESHOLD + 1; round++) { + for (let round = 0; round < 3; round++) { for (const name of interleaved) { const args = name === 'task_list' ? TASK_LIST_ARGS : { step: round }; expect( @@ -3127,249 +2963,6 @@ describe('LoopDetectionService', () => { ); }); - it('does not halt a board oscillating between two states (order-aware pair counts)', () => { - // A board flipping between two byte-identical states returns a result - // that differs from its predecessor on EVERY poll. Turn-wide (key, - // fingerprint) counting would accumulate each state to the - // global-duplicate threshold and halt this productive poller; the - // count must restart on every changed result. Run well past - // GLOBAL_DUPLICATE_THRESHOLD rounds so the accumulation is visible. - const heuristicService = new LoopDetectionService( - makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), - ); - heuristicService.reset('oscillating-board'); - - const interleaved = ['task_list', 'tool_b', 'tool_c']; - const states = ['state-a', 'state-b']; - let stateIndex = 0; - for (let round = 0; round < 2 * GLOBAL_DUPLICATE_THRESHOLD + 1; round++) { - for (const name of interleaved) { - const args = name === 'task_list' ? TASK_LIST_ARGS : { step: round }; - expect( - heuristicService.addAndCheck( - createToolCallRequestEvent(name, args), - ), - ).toBe(false); - if (name === 'task_list') { - expect( - heuristicService.recordToolResult( - { name, args }, - taskListResult(states[stateIndex++ % states.length]), - ), - ).toBe(false); - } - } - } - expect(loggers.logLoopDetected).not.toHaveBeenCalled(); - }); - - it('keeps the adaptive cap from arming on oscillating results', () => { - // CLI default: skipLoopDetection=true, so the cap's stuck signal fed - // by recordToolResult is the live halt path. An oscillating board - // must not build the stuck signal; the turn then sails past the soft - // cap toward the hard backstop instead of halting just above it. - const capService = new LoopDetectionService(makeConfig(20)); - capService.reset('cap-oscillating'); - - const states = ['state-a', 'state-b']; - let fired = false; - let totalCalls = 0; - for (let round = 0; round < 40 && !fired; round++) { - fired = capService.checkAlwaysOnSafeties(taskListEvent(`tl-${round}`)); - totalCalls++; - if (fired) break; - fired = capService.checkAlwaysOnSafeties( - createToolCallRequestEvent('tool_b', { step: round }), - ); - totalCalls++; - if (fired) break; - capService.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult(states[round % states.length]), - ); - } - expect(fired).toBe(false); - expect(totalCalls).toBe(80); - }); - - it('does not halt an ABAB task_list poller whose results keep changing', () => { - // A teammate alternating task_list with another call (check board, - // do work, check board…) is exactly the ABAB shape this detector - // hunts. With changing board results it is productive polling; the - // result-aware carve-out must restart the window instead of halting - // at the first full ABAB window (6th request). tool_b keeps constant - // args so the window holds a stable B key; the run stays short - // enough that tool_b's own request count stays below the - // global-duplicate threshold. - const heuristicService = new LoopDetectionService( - makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), - ); - heuristicService.reset('alternating-productive'); - - let fired = false; - for ( - let round = 0; - round < ALTERNATING_PATTERN_CYCLES + 1 && !fired; - round++ - ) { - fired = heuristicService.addAndCheck( - createToolCallRequestEvent('task_list', TASK_LIST_ARGS), - ); - if (fired) break; - fired = heuristicService.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult(`board state v${round}`), - ); - if (fired) break; - fired = heuristicService.addAndCheck( - createToolCallRequestEvent('tool_b', { step: 'work' }), - ); - } - expect(fired).toBe(false); - expect(loggers.logLoopDetected).not.toHaveBeenCalled(); - }); - - it('still halts an ABAB pattern with a stateful participant on frozen results', () => { - // Same alternation, but the board never changes: the recorded - // results corroborate the loop, so the halt stands. - const heuristicService = new LoopDetectionService( - makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), - ); - heuristicService.reset('alternating-frozen'); - - let fired = false; - for (let round = 0; round < 8 && !fired; round++) { - fired = heuristicService.addAndCheck( - createToolCallRequestEvent('task_list', TASK_LIST_ARGS), - ); - if (fired) break; - fired = heuristicService.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult('frozen board'), - ); - if (fired) break; - fired = heuristicService.addAndCheck( - createToolCallRequestEvent('tool_b', { step: 'work' }), - ); - } - expect(fired).toBe(true); - expect(heuristicService.getLastLoopType()).toBe( - LoopType.ALTERNATING_TOOL_CALL_PATTERN, - ); - }); - - it('still halts ABAB with a stateful participant when no results were recorded (fail-safe)', () => { - // A wiring gap must never loosen the guard: without result evidence - // the argument-only halt fires exactly as pre-fix. - const heuristicService = new LoopDetectionService( - makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), - ); - heuristicService.reset('alternating-no-evidence'); - - let fired = false; - for (let round = 0; round < 8 && !fired; round++) { - fired = heuristicService.addAndCheck( - createToolCallRequestEvent('task_list', TASK_LIST_ARGS), - ); - if (fired) break; - fired = heuristicService.addAndCheck( - createToolCallRequestEvent('tool_b', { step: 'work' }), - ); - } - expect(fired).toBe(true); - expect(heuristicService.getLastLoopType()).toBe( - LoopType.ALTERNATING_TOOL_CALL_PATTERN, - ); - }); - - it('does not halt a batched [task_list, tool_b] ABAB poller whose results keep changing', () => { - // Parallel batches feed BOTH requests of a round to the heuristic - // tier before that round's results land, with the stateful call - // LEADING the batch. Pre-fix the carve-out encoded a strictly - // sequential in-flight model (only the window-tail key got - // occurrences - 1): when the 6th request filled the window, the - // leading key's 3rd occurrence was still in flight, history held 2 - // fingerprints but expectedResults=3, the exonerating check was - // skipped, and the guard halted ALTERNATING_TOOL_CALL_PATTERN on - // args alone despite every result having changed (issue #9450). The - // run stays at 4 rounds so tool_b's constant-args request count (4) - // stays below the global-duplicate threshold. - const heuristicService = new LoopDetectionService( - makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), - ); - heuristicService.reset('batched-alternating-productive'); - - let fired = false; - for (let round = 0; round < 4 && !fired; round++) { - fired = heuristicService.addAndCheck(taskListEvent(`tl-${round}`)); - if (fired) break; - fired = heuristicService.addAndCheck( - createToolCallRequestEvent('tool_b', { step: 'work' }), - ); - if (fired) break; - fired = heuristicService.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult(`board state v${round}`), - ); - } - expect(fired).toBe(false); - expect(loggers.logLoopDetected).not.toHaveBeenCalled(); - }); - - it('keeps the batched [tool_b, task_list] ordering exonerated', () => { - // Ordering twin: with the stateful call TRAILING the batch the - // window fills on a task_list request, which was already exonerated - // pre-fix (the tail key lost one expected result). Pins that the - // in-flight counter does not regress this ordering. - const heuristicService = new LoopDetectionService( - makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), - ); - heuristicService.reset('batched-alternating-reversed'); - - let fired = false; - for (let round = 0; round < 4 && !fired; round++) { - fired = heuristicService.addAndCheck( - createToolCallRequestEvent('tool_b', { step: 'work' }), - ); - if (fired) break; - fired = heuristicService.addAndCheck(taskListEvent(`tl-${round}`)); - if (fired) break; - fired = heuristicService.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult(`board state v${round}`), - ); - } - expect(fired).toBe(false); - }); - - it('still halts a batched ABAB pattern when the stateful results are frozen (fail-safe)', () => { - // Fail-safe twin of the batched regression: with an unchanged board - // the recorded results corroborate the alternation, so the halt must - // still fire under the batched [task_list, tool_b] ordering. - const heuristicService = new LoopDetectionService( - makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), - ); - heuristicService.reset('batched-alternating-frozen'); - - let fired = false; - for (let round = 0; round < 4 && !fired; round++) { - fired = heuristicService.addAndCheck(taskListEvent(`tl-${round}`)); - if (fired) break; - fired = heuristicService.addAndCheck( - createToolCallRequestEvent('tool_b', { step: 'work' }), - ); - if (fired) break; - fired = heuristicService.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult('frozen board'), - ); - } - expect(fired).toBe(true); - expect(heuristicService.getLastLoopType()).toBe( - LoopType.ALTERNATING_TOOL_CALL_PATTERN, - ); - }); - it('treats changed results as progress for action stagnation', () => { const heuristicService = new LoopDetectionService( makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), @@ -3469,1576 +3062,5 @@ describe('LoopDetectionService', () => { } expect(fired).toBe(false); }); - - it('restarts result-aware pair counts on Retry under the heuristic gate', () => { - const heuristicService = new LoopDetectionService( - makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), - ); - heuristicService.reset('retry-pair-reset'); - - // Record GLOBAL_DUPLICATE_THRESHOLD - 1 identical frozen (call, - // result) pairs, interleaved with a distinct tool so the consecutive - // guard never fires. - for (let i = 0; i < GLOBAL_DUPLICATE_THRESHOLD - 1; i++) { - expect( - heuristicService.addAndCheck( - createToolCallRequestEvent('tool_b', { step: i }), - ), - ).toBe(false); - expect(heuristicService.addAndCheck(taskListEvent(`call-${i}`))).toBe( - false, - ); - expect( - heuristicService.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult('frozen board'), - ), - ).toBe(false); - } - - expect( - heuristicService.checkAlwaysOnSafeties({ - type: GeminiEventType.Retry, - } as ServerGeminiStreamEvent), - ).toBe(false); - - // The replayed attempt is judged on its own results: one more frozen - // pair is pair #1 after the Retry clear, not #threshold. - expect( - heuristicService.addAndCheck( - createToolCallRequestEvent('tool_b', { step: 'replay' }), - ), - ).toBe(false); - expect(heuristicService.addAndCheck(taskListEvent('replay-0'))).toBe( - false, - ); - expect( - heuristicService.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult('frozen board'), - ), - ).toBe(false); - }); - - it('clears result-aware pair counts across prompts on reset()', () => { - const heuristicService = new LoopDetectionService( - makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), - ); - heuristicService.reset('prompt-1'); - - for (let i = 0; i < GLOBAL_DUPLICATE_THRESHOLD - 1; i++) { - expect( - heuristicService.addAndCheck( - createToolCallRequestEvent('tool_b', { step: i }), - ), - ).toBe(false); - expect(heuristicService.addAndCheck(taskListEvent(`call-${i}`))).toBe( - false, - ); - expect( - heuristicService.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult('frozen board'), - ), - ).toBe(false); - } - - heuristicService.reset('prompt-2'); - - // A poller that saw the same frozen board five times in prompt 1 must - // not trip the global-duplicate gate on its first poll of prompt 2. - expect( - heuristicService.addAndCheck( - createToolCallRequestEvent('tool_b', { step: 'p2' }), - ), - ).toBe(false); - expect(heuristicService.addAndCheck(taskListEvent('p2-0'))).toBe(false); - expect( - heuristicService.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult('frozen board'), - ), - ).toBe(false); - }); - - it('halts an interleaved frozen poller just past the adaptive soft cap', () => { - // CLI default: skipLoopDetection=true. The request-time cap tracker - // skips stateful tools and the result-time global-duplicate halt is - // gated off, so the cap's stuck signal fed by recordToolResult pair - // counts is the only live halt path for an interleaved frozen poller. - const capService = new LoopDetectionService(makeConfig(20)); - capService.reset('cap-frozen'); - - let fired = false; - let totalCalls = 0; - for (let round = 0; round < 40 && !fired; round++) { - fired = capService.checkAlwaysOnSafeties(taskListEvent(`tl-${round}`)); - totalCalls++; - if (fired) break; - fired = capService.checkAlwaysOnSafeties( - createToolCallRequestEvent('tool_b', { step: round }), - ); - totalCalls++; - if (fired) break; - capService.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult('frozen board'), - ); - } - expect(fired).toBe(true); - expect(capService.getLastLoopType()).toBe(LoopType.TURN_TOOL_CALL_CAP); - // Halts just past the soft cap (20) once the stuck signal is armed, - // far below the hard backstop (20 * 10). - expect(totalCalls).toBeLessThanOrEqual(22); - }); - - it('re-judges a resumed streak on fresh result evidence after a streak break', () => { - const unchanged = '#1 [in_progress] @peer-a — task'; - // A 4-call identical streak with unchanged results. - for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD - 1; i++) { - expect(service.checkAlwaysOnSafeties(taskListEvent(`call-${i}`))).toBe( - false, - ); - service.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult(unchanged), - ); - } - - // A different tool breaks the consecutive streak; the result evidence - // accumulated within it must be discarded for both keys. - expect( - service.checkAlwaysOnSafeties( - createToolCallRequestEvent('tool_b', { step: 1 }), - ), - ).toBe(false); - - // Resume identical polling, recording only ONE changed result. - expect(service.checkAlwaysOnSafeties(taskListEvent('resume-0'))).toBe( - false, - ); - service.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult('board changed'), - ); - for (let i = 1; i <= 3; i++) { - expect( - service.checkAlwaysOnSafeties(taskListEvent(`resume-${i}`)), - ).toBe(false); - } - - // The 5th request of the resumed streak expects 4 recorded results but - // only 1 was observed: missing evidence fails safe and halts, keeping - // the #5019 protection. Stale evidence from the broken streak must not - // satisfy the check and restart instead. - expect(service.checkAlwaysOnSafeties(taskListEvent('resume-4'))).toBe( - true, - ); - expect(service.getLastLoopType()).toBe( - LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, - ); - }); - - it('disarms the adaptive cap when a frozen board thaws (no latched peak)', () => { - // The cap's stateful stuck signal must NOT be a high-water ratchet: a - // frozen phase builds it, but once results change it must fall back to - // the current streak so a thawed board keeps polling past the soft cap. - const capService = new LoopDetectionService(makeConfig(20)); - capService.reset('cap-thaw'); - - let fired = false; - let totalCalls = 0; - const poll = (board: string, round: number) => { - fired ||= capService.checkAlwaysOnSafeties( - taskListEvent(`tl-${round}`), - ); - totalCalls++; - if (fired) return; - fired ||= capService.checkAlwaysOnSafeties( - createToolCallRequestEvent('tool_b', { step: round }), - ); - totalCalls++; - if (fired) return; - fired = capService.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult(board), - ); - }; - - // 6 frozen results (interleaved so the consecutive guard never fires): - // the stateful stuck signal reaches GLOBAL_DUPLICATE_THRESHOLD. - for ( - let round = 0; - round < GLOBAL_DUPLICATE_THRESHOLD && !fired; - round++ - ) { - poll('frozen board', round); - } - expect(fired).toBe(false); - - // Board thaws: every subsequent result differs. The stuck signal must - // disarm, so polling sails past the soft cap of 20 without halting. - for ( - let round = GLOBAL_DUPLICATE_THRESHOLD; - round < 40 && !fired; - round++ - ) { - poll(`thawed board v${round}`, round); - } - expect(fired).toBe(false); - expect(totalCalls).toBeGreaterThanOrEqual(40); - }); - - it('still arms the adaptive cap on a permanently frozen board', () => { - // Regression guard for the disarm fix: a board that NEVER changes keeps - // the stateful stuck signal at the threshold, so the adaptive cap still - // halts the stuck poller just past the soft cap. - const capService = new LoopDetectionService(makeConfig(20)); - capService.reset('cap-frozen'); - - let fired = false; - for (let round = 0; round < 40 && !fired; round++) { - fired = capService.checkAlwaysOnSafeties(taskListEvent(`tl-${round}`)); - if (fired) break; - fired = capService.checkAlwaysOnSafeties( - createToolCallRequestEvent('tool_b', { step: round }), - ); - if (fired) break; - fired = capService.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult('frozen board'), - ); - } - expect(fired).toBe(true); - expect(capService.getLastLoopType()).toBe(LoopType.TURN_TOOL_CALL_CAP); - }); - - it('still halts a continuously frozen poller across Finished round-trips', () => { - // The Finished-boundary decay must only release ABANDONED keys: a board - // that stays frozen while the model keeps polling every round-trip keeps - // its stuck signal, so the adaptive cap still halts it just past the - // soft cap (fail-safe twin of the abandon regression below). - const capService = new LoopDetectionService(makeConfig(20)); - capService.reset('cap-frozen-rounds'); - const finishedEvent = { - type: GeminiEventType.Finished, - value: { reason: 'STOP' }, - } as unknown as ServerGeminiStreamEvent; - - let fired = false; - let totalCalls = 0; - for (let round = 0; round < 40 && !fired; round++) { - fired = capService.checkAlwaysOnSafeties(taskListEvent(`tl-${round}`)); - totalCalls++; - if (fired) break; - fired = capService.checkAlwaysOnSafeties( - createToolCallRequestEvent('tool_b', { step: round }), - ); - totalCalls++; - if (fired) break; - fired = capService.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult('frozen board'), - ); - if (fired) break; - capService.checkAlwaysOnSafeties(finishedEvent); - } - expect(fired).toBe(true); - expect(capService.getLastLoopType()).toBe(LoopType.TURN_TOOL_CALL_CAP); - expect(totalCalls).toBeLessThanOrEqual(22); - }); - - it('releases the adaptive cap when a frozen poller is abandoned for productive work', () => { - // The cap's stateful stuck signal must not latch a stale peak from an - // abandoned key: interleaved frozen polls peak the signal, then the - // model stops polling and does diverse productive work. Pre-fix the - // add-only key map kept the peak for the whole prompt, so the turn was - // halted as TURN_TOOL_CALL_CAP just past the soft cap (issue #9450). - // CLI default skipLoopDetection=true: the cap is the only live path. - const capService = new LoopDetectionService(makeConfig(20)); - capService.reset('cap-abandon'); - const finishedEvent = { - type: GeminiEventType.Finished, - value: { reason: 'STOP' }, - } as unknown as ServerGeminiStreamEvent; - - let fired = false; - // 8 interleaved frozen task_list polls, each its own round-trip: the - // stateful stuck signal peaks at 8 without halting (still under cap). - for (let round = 0; round < 8 && !fired; round++) { - fired ||= capService.checkAlwaysOnSafeties( - taskListEvent(`tl-${round}`), - ); - fired ||= capService.checkAlwaysOnSafeties( - createToolCallRequestEvent('tool_b', { step: round }), - ); - if (fired) break; - fired = capService.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult('frozen board'), - ); - if (fired) break; - capService.checkAlwaysOnSafeties(finishedEvent); - } - expect(fired).toBe(false); - - // The model abandons polling and does diverse productive work well past - // the soft cap of 20. The abandoned key's peak must decay at the - // Finished boundaries, so no TURN_TOOL_CALL_CAP halt fires. - for (let i = 0; i < 30 && !fired; i++) { - fired = capService.checkAlwaysOnSafeties( - createToolCallRequestEvent('tool_c', { i }), - ); - if (fired) break; - if (i % 3 === 2) { - capService.checkAlwaysOnSafeties(finishedEvent); - } - } - expect(fired).toBe(false); - expect(capService.getLastLoopType()).toBeNull(); - }); - - it('still halts a frozen poller interleaved with non-stateful replay-only rounds (requirement #6 parity)', () => { - // Requirement-#6 parity with the daemon (issue #9450): poll a frozen - // task_list board → suppressed replay of an already-handled - // NON-stateful call id (the round executes nothing) → gap round, - // repeated. The daemon's batch recorder receives the all-replay batch - // as zero executable calls and skips its boundary decay entirely, so - // the last executed round's result marks survive and the daemon halts - // just past the soft cap. Pre-fix core consumed the poll's mark at the - // replay round's Finished boundary (noteSuppressedToolCallByCallId - // marks nothing for a non-stateful replay) and wiped the frozen streak - // at the next one, so the stuck signal never armed and the turn ran to - // the 10x hard backstop. The replaySuppression carry must keep the - // streak alive across the replay round's boundary. - const capService = new LoopDetectionService(makeConfig(20)); - capService.reset('replay-parity-non-stateful'); - const finishedEvent = { - type: GeminiEventType.Finished, - value: { reason: 'STOP' }, - } as unknown as ServerGeminiStreamEvent; - - let fired = false; - let totalCalls = 0; - for (let round = 0; round < 30 && !fired; round++) { - // Poll round: production ordering — the request streams, its - // Finished boundary runs, then the result is recorded with the next - // round's submission. - fired = capService.checkAlwaysOnSafeties(taskListEvent(`tl-${round}`)); - totalCalls++; - if (fired) break; - capService.checkAlwaysOnSafeties(finishedEvent); - fired = capService.recordToolResultByCallId( - `tl-${round}`, - taskListResult('frozen board', `tl-${round}`), - ); - if (fired) break; - // Replay-only round: a NON-stateful already-handled call id streams - // in and is suppressed without executing; the suppression is noted - // with the following round's submission (after the Finished - // boundary), exactly when client.ts's feed unwinds it. - // Varying args: the replay's own repeat key must not build the - // cap's stuck signal — the mechanism under test is the stateful - // streak carry, not the replay's request-time counting. - fired = capService.checkAlwaysOnSafeties( - createToolCallRequestEvent('read_file', { file_path: `/a${round}` }), - ); - totalCalls++; - if (fired) break; - capService.checkAlwaysOnSafeties(finishedEvent); - capService.noteSuppressedToolCallByCallId('test-id', { - replaySuppression: true, - }); - // Gap round (other productive work). - fired = capService.checkAlwaysOnSafeties( - createToolCallRequestEvent('tool_b', { step: round }), - ); - totalCalls++; - if (fired) break; - capService.checkAlwaysOnSafeties(finishedEvent); - } - expect(fired).toBe(true); - expect(capService.getLastLoopType()).toBe(LoopType.TURN_TOOL_CALL_CAP); - // Halts just past the soft cap of 20 — pre-fix the streak restarted - // every cycle and nothing fired within 90 calls (hard backstop 200). - expect(totalCalls).toBeLessThanOrEqual(30); - }); - - it('does not halt a resumed task_list poller whose evidence decayed mid-streak (issue #9450)', () => { - // Two consecutive tool-call-free round-trips mid-streak (reachable - // via checkNextSpeaker "Please continue." hook turns or agent-core - // external-input wait rounds) decay the key's result evidence at the - // second Finished boundary. Pre-fix the always-on consecutive streak - // (lastToolCallKey / toolCallRepetitionCount) survived the decay: - // resultsObserved could then only ever reach count - 2, the - // exoneration gate stayed permanently unsatisfiable, and a - // changing-board poller halted at the 5th identical request after - // resuming — the #9450 false positive re-entering via the decay - // layer. The decay's "abandoned" semantics must drop the streak too - // so resumed polling starts fresh and is judged on its own results. - const finishedEvent = { - type: GeminiEventType.Finished, - value: { reason: 'STOP' }, - } as unknown as ServerGeminiStreamEvent; - const gapService = new LoopDetectionService(makeConfig()); - gapService.reset('decay-resume-productive'); - - // Bring the streak to 3 with changing results, one poll per round - // (production ordering: request → Finished → result). - for (let i = 0; i < 3; i++) { - expect( - gapService.checkAlwaysOnSafeties(taskListEvent(`poll-${i}`)), - ).toBe(false); - gapService.checkAlwaysOnSafeties(finishedEvent); - expect( - gapService.recordToolResultByCallId( - `poll-${i}`, - taskListResult(`board state v${i}`, `poll-${i}`), - ), - ).toBe(false); - } - // Two consecutive tool-call-free round-trips: the first boundary - // consumes the last result's mark, the second decays the evidence. - gapService.checkAlwaysOnSafeties(finishedEvent); - gapService.checkAlwaysOnSafeties(finishedEvent); - - // Polling resumes with the board still changing: no halt. Pre-fix - // this fired CONSECUTIVE_IDENTICAL_TOOL_CALLS at the 5th identical - // request of the streak. - let fired = false; - for (let i = 3; i < 11 && !fired; i++) { - fired = gapService.checkAlwaysOnSafeties(taskListEvent(`poll-${i}`)); - if (fired) break; - gapService.checkAlwaysOnSafeties(finishedEvent); - fired = gapService.recordToolResultByCallId( - `poll-${i}`, - taskListResult(`board state v${i}`, `poll-${i}`), - ); - } - expect(fired).toBe(false); - expect(gapService.getLastLoopType()).toBeNull(); - }); - - it('still halts a resumed frozen poller whose evidence decayed mid-streak (fail-safe)', () => { - // Fail-safe twin of the decay-resume regression: after the abandoned - // evidence decays and polling resumes, a frozen board corroborates - // the loop again through the fresh streak's own results, so the - // guard still halts once the fresh streak is complete. - const finishedEvent = { - type: GeminiEventType.Finished, - value: { reason: 'STOP' }, - } as unknown as ServerGeminiStreamEvent; - const gapService = new LoopDetectionService(makeConfig()); - gapService.reset('decay-resume-frozen'); - - for (let i = 0; i < 3; i++) { - expect( - gapService.checkAlwaysOnSafeties(taskListEvent(`poll-${i}`)), - ).toBe(false); - gapService.checkAlwaysOnSafeties(finishedEvent); - expect( - gapService.recordToolResultByCallId( - `poll-${i}`, - taskListResult('frozen board', `poll-${i}`), - ), - ).toBe(false); - } - gapService.checkAlwaysOnSafeties(finishedEvent); - gapService.checkAlwaysOnSafeties(finishedEvent); - - let fired = false; - for (let i = 3; i < 11 && !fired; i++) { - fired = gapService.checkAlwaysOnSafeties(taskListEvent(`poll-${i}`)); - if (fired) break; - gapService.checkAlwaysOnSafeties(finishedEvent); - fired = gapService.recordToolResultByCallId( - `poll-${i}`, - taskListResult('frozen board', `poll-${i}`), - ); - } - expect(fired).toBe(true); - expect(gapService.getLastLoopType()).toBe( - LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, - ); - }); - - it('does not halt a changing-board poller when a suppressed replay lands mid-streak', () => { - // The provider re-emitted an already-handled call id mid-streak: the - // replay streamed through the guards (incrementing the request-side - // counts) and was suppressed without executing. Pre-fix the - // suppressed occurrence kept its increment, so at the 5th identical - // request expectedResults = 4 while resultsObserved = 3 forever — - // the exoneration branch unreachable, and the turn halted - // CONSECUTIVE_IDENTICAL_TOOL_CALLS despite every executed result - // having changed (the #9450 false positive re-entering via provider - // re-emission). - let fired = false; - for (let i = 0; i < 8 && !fired; i++) { - const callId = `call-${i}`; - fired = service.checkAlwaysOnSafeties(taskListEvent(callId)); - if (fired) break; - if (i === 2) { - // Mid-streak replay of an already-handled call id: it streams in - // (the guards count it), then the runtime suppresses it — no - // result will ever land for it. - fired = service.checkAlwaysOnSafeties(taskListEvent('call-1')); - if (fired) break; - service.noteSuppressedToolCallByCallId('call-1'); - } - fired = service.recordToolResultByCallId( - callId, - taskListResult(`board state v${i}`, callId), - ); - } - expect(fired).toBe(false); - expect(service.getLastLoopType()).toBeNull(); - }); - - it('does not halt an ABAB poller when a suppressed replay pads the window', () => { - // A replay of the stateful participant mid-window pads the window to - // a clean ABABAB shape while producing no result. Pre-fix the - // carve-out saw 3 window occurrences against only 2 recorded - // results, skipped the exoneration, and halted on arguments alone. - const heuristicService = new LoopDetectionService( - makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), - ); - heuristicService.reset('alternating-replay'); - - const toolB = () => - createToolCallRequestEvent('tool_b', { step: 'work' }); - const recordBoard = (callId: string, board: string) => - heuristicService.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult(board, callId), - ); - - expect(heuristicService.addAndCheck(taskListEvent('a-0'))).toBe(false); - expect(recordBoard('a-0', 'board state v0')).toBe(false); - expect(heuristicService.addAndCheck(toolB())).toBe(false); - expect(heuristicService.addAndCheck(taskListEvent('a-1'))).toBe(false); - expect(recordBoard('a-1', 'board state v1')).toBe(false); - expect(heuristicService.addAndCheck(toolB())).toBe(false); - // The replay streams in and is suppressed without executing. - expect(heuristicService.addAndCheck(taskListEvent('a-0'))).toBe(false); - heuristicService.noteSuppressedToolCallByCallId('a-0'); - // Pre-fix this 6th window entry halted - // ALTERNATING_TOOL_CALL_PATTERN on args alone. - expect(heuristicService.addAndCheck(toolB())).toBe(false); - expect(heuristicService.getLastLoopType()).toBeNull(); - }); - - it('halts a pure stream of rejected identical task_list calls on the 5th request (issue #9450)', () => { - // A subagent whose model persistently re-emits an unavailable - // task_list (undeclared for the subagent, or allowlisted out): every - // identical request streams through the guard and is rejected without - // executing. Pre-fix noteSuppressedToolCallByCallId unwound the - // consecutive-identical increment right back (the count oscillated - // 0↔1 — the threshold 5 unreachable), a rejected call never records - // a result (the missing-evidence fail-safe unreachable), and the - // cap's stuck signal never arms (trackCapKeyRepeat skips stateful - // tools while statefulCapKeyRepeat feeds only on recorded results) — - // the stream looped to the hard backstop instead of halting on the - // 5th identical request like the pre-PR wiring. - let fired = false; - let firedAt = -1; - for (let i = 0; i < 12 && !fired; i++) { - fired = service.checkAlwaysOnSafeties(taskListEvent(`rej-${i}`)); - if (fired) { - firedAt = i + 1; - break; - } - // The rejection lands before the next request streams (agent-core - // rejects during batch filtering, ahead of execution). - service.noteSuppressedToolCallByCallId(`rej-${i}`); - } - expect(fired).toBe(true); - expect(firedAt).toBe(TOOL_CALL_LOOP_THRESHOLD); - expect(service.getLastLoopType()).toBe( - LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, - ); - }); - - it('does not halt a changing-board stream mixing rejected and executed identical calls (issue #9450)', () => { - // Rejected calls keep their request-side increments (the pure stream - // above must still halt), so the exoneration gate must subtract the - // streak's suppressedRequests from the expected result count — or a - // MIXED stream of rejected + executed calls whose executed results - // keep changing would be permanently one result short and false-halt - // on arguments alone (the #9450 false positive re-entering via the - // rejection branch). - let fired = false; - for (let i = 0; i < 8 && !fired; i++) { - fired = service.checkAlwaysOnSafeties(taskListEvent(`rej-${i}`)); - if (fired) break; - service.noteSuppressedToolCallByCallId(`rej-${i}`); - fired = service.checkAlwaysOnSafeties(taskListEvent(`exec-${i}`)); - if (fired) break; - fired = service.recordToolResultByCallId( - `exec-${i}`, - taskListResult(`board state v${i}`, `exec-${i}`), - ); - } - expect(fired).toBe(false); - expect(service.getLastLoopType()).toBeNull(); - }); - - it('halts an interleaved frozen poller whose gap rounds previously decayed the streak', () => { - // Production ordering: requests → Finished → results. A frozen board - // polled every OTHER round between varied work: pre-fix the poll - // round's Finished boundary found the key absent from the result set - // (the gap round's boundary had consumed the previous result's mark), - // decayed the streak back to zero, and the cap's stuck signal never - // armed — the turn ran to the hard backstop instead of halting just - // past the soft cap. The requested-keys skip keeps the streak alive - // across gap rounds. - const capService = new LoopDetectionService(makeConfig(20)); - capService.reset('cap-interleaved-frozen'); - const finishedEvent = { - type: GeminiEventType.Finished, - value: { reason: 'STOP' }, - } as unknown as ServerGeminiStreamEvent; - - let fired = false; - let totalCalls = 0; - for (let round = 0; round < 40 && !fired; round++) { - fired = capService.checkAlwaysOnSafeties(taskListEvent(`tl-${round}`)); - totalCalls++; - if (fired) break; - capService.checkAlwaysOnSafeties(finishedEvent); - fired = capService.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult('frozen board'), - ); - if (fired) break; - // Gap round: other work, no task_list request or result. - fired = capService.checkAlwaysOnSafeties( - createToolCallRequestEvent('tool_b', { step: round }), - ); - totalCalls++; - if (fired) break; - capService.checkAlwaysOnSafeties(finishedEvent); - } - expect(fired).toBe(true); - expect(capService.getLastLoopType()).toBe(LoopType.TURN_TOOL_CALL_CAP); - // Halts just past the soft cap (20) once the stuck signal arms, far - // below the hard backstop (20 * 10). - expect(totalCalls).toBeLessThanOrEqual(24); - }); - - it('does not halt a changing-board poller when a gap round lands mid-streak', () => { - // A text-only gap round mid-streak: pre-fix the next poll round's - // Finished boundary decayed resultsObserved/unchangedStreak to zero - // while toolCallRepetitionCount stood, making the carve-out gate - // resultsObserved >= count - 1 permanently unsatisfiable — the 5th - // identical request halted on arguments alone despite every executed - // result having changed (fail-closed arm of the decay gap). - const finishedEvent = { - type: GeminiEventType.Finished, - value: { reason: 'STOP' }, - } as unknown as ServerGeminiStreamEvent; - - expect(service.checkAlwaysOnSafeties(taskListEvent('call-0'))).toBe( - false, - ); - service.checkAlwaysOnSafeties(finishedEvent); - expect( - service.recordToolResultByCallId( - 'call-0', - taskListResult('board v0', 'call-0'), - ), - ).toBe(false); - - expect(service.checkAlwaysOnSafeties(taskListEvent('call-1'))).toBe( - false, - ); - service.checkAlwaysOnSafeties(finishedEvent); - expect( - service.recordToolResultByCallId( - 'call-1', - taskListResult('board v1', 'call-1'), - ), - ).toBe(false); - - // Text-only gap round: no requests, no results. - service.checkAlwaysOnSafeties(finishedEvent); - - expect(service.checkAlwaysOnSafeties(taskListEvent('call-2'))).toBe( - false, - ); - // Pre-fix this boundary wiped resultsObserved/unchangedStreak. - service.checkAlwaysOnSafeties(finishedEvent); - expect( - service.recordToolResultByCallId( - 'call-2', - taskListResult('board v2', 'call-2'), - ), - ).toBe(false); - - expect(service.checkAlwaysOnSafeties(taskListEvent('call-3'))).toBe( - false, - ); - service.checkAlwaysOnSafeties(finishedEvent); - expect( - service.recordToolResultByCallId( - 'call-3', - taskListResult('board v3', 'call-3'), - ), - ).toBe(false); - - // 5th identical request: all four prior results changed, so the - // exoneration branch must restart the streak instead of halting. - expect(service.checkAlwaysOnSafeties(taskListEvent('call-4'))).toBe( - false, - ); - expect(service.getLastLoopType()).toBeNull(); - }); - - it('does not collapse the fingerprint when board content merely quotes the digest label', () => { - // task_list embeds peer-authored text verbatim, and agents quote stub - // text (including the `Full output sha256: ` line this PR adds to - // every oversized output) into board state. A board whose quoted label - // + digest window stays constant while the REST of the board changes - // must fingerprint by its full content — collapsing to the quoted - // 64-char window would halt this productive poller at the 5th request. - const quotedDigest = 'deadbeef'.repeat(8); // constant 64-hex window - let fired = false; - for (let i = 0; i < 4 * TOOL_CALL_LOOP_THRESHOLD; i++) { - fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); - if (fired) break; - const board = - `board row changing ${i}\n` + - `Full output sha256: ${quotedDigest}\n` + - `more changing content ${i}`; - service.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult(board, `poll_${i}`), - ); - } - expect(fired).toBe(false); - expect(loggers.logLoopDetected).not.toHaveBeenCalled(); - }); - - it('still halts when board content quoting the digest label is frozen', () => { - // Inverse of the injection guard: quoting the label does not grant - // immunity. A fully frozen board (quoted window AND the rest constant) - // must still corroborate the consecutive-identical halt. - const quotedDigest = 'deadbeef'.repeat(8); - const board = - 'frozen board row\n' + - `Full output sha256: ${quotedDigest}\n` + - 'frozen tail'; - let fired = false; - for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD; i++) { - fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); - if (fired) break; - service.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult(board, `poll_${i}`), - ); - } - expect(fired).toBe(true); - expect(service.getLastLoopType()).toBe( - LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, - ); - }); - - it('does not halt a fallback ABAB poller whose predecessor died mid-batch (issue #9450)', () => { - // A parallel poll batch streams two identical task_list requests from - // the primary model, then the attempt fails before any result lands: - // Turn.run clears pendingToolCalls on ModelFallback without a - // suppression note, so the in-flight reservations made when those - // requests streamed in can never unwind on their own. Pre-fix the - // ModelFallback branches never cleared the stateful trackers, so the - // stale reservations survived into the fallback attempt and the - // alternating-pattern carve-out computed expectedResults = - // occurrences - staleInFlight <= 0, skipping the exoneration check — - // the fallback model's changing-board poller halted - // ALTERNATING_TOOL_CALL_PATTERN on arguments alone. - const heuristicService = new LoopDetectionService( - makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), - ); - heuristicService.reset('fallback-alternating-productive'); - - // The primary model's failed partial round: two identical task_list - // requests, no results. - expect(heuristicService.addAndCheck(taskListEvent('primary-0'))).toBe( - false, - ); - expect(heuristicService.addAndCheck(taskListEvent('primary-1'))).toBe( - false, - ); - const fallbackEvent: ServerGeminiModelFallbackEvent = { - type: GeminiEventType.ModelFallback, - fromModel: 'primary-model', - toModel: 'fallback-model', - fallbackIndex: 1, - }; - expect(heuristicService.addAndCheck(fallbackEvent)).toBe(false); - - // The fallback model restarts the poll from scratch and the board - // keeps changing: productive ABAB (task_list ↔ tool_b) well past the - // window fill. - let fired = false; - for ( - let round = 0; - round < ALTERNATING_PATTERN_CYCLES + 2 && !fired; - round++ - ) { - fired = heuristicService.addAndCheck(taskListEvent(`fb-${round}`)); - if (fired) break; - fired = heuristicService.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult(`board state v${round}`, `fb-${round}`), - ); - if (fired) break; - fired = heuristicService.addAndCheck( - createToolCallRequestEvent('tool_b', { step: 'work' }), - ); - } - expect(fired).toBe(false); - expect(heuristicService.getLastLoopType()).toBeNull(); - }); - - it('does not halt a fallback poller resuming after the primary stream died (issue #9450)', () => { - // Always-on tier (CLI default skipLoopDetection=true). The primary - // model streams three identical task_list requests, then the attempt - // dies before results land. Pre-fix checkAlwaysOnSafeties had no - // ModelFallback branch: the consecutive streak (3) and its - // never-answerable in-flight reservations carried into the fallback - // attempt, and the fallback model's second EXECUTED poll — the 5th - // consecutive request — halted CONSECUTIVE_IDENTICAL_TOOL_CALLS - // despite every executed result having changed. - const fallbackService = new LoopDetectionService(makeConfig()); - fallbackService.reset('fallback-consecutive-productive'); - for (let i = 0; i < 3; i++) { - expect( - fallbackService.checkAlwaysOnSafeties(taskListEvent(`primary-${i}`)), - ).toBe(false); - } - const fallbackEvent: ServerGeminiModelFallbackEvent = { - type: GeminiEventType.ModelFallback, - fromModel: 'primary-model', - toModel: 'fallback-model', - fallbackIndex: 1, - }; - expect(fallbackService.checkAlwaysOnSafeties(fallbackEvent)).toBe(false); - - const finishedEvent = { - type: GeminiEventType.Finished, - value: { reason: 'STOP' }, - } as unknown as ServerGeminiStreamEvent; - let fired = false; - for (let i = 0; i < 12 && !fired; i++) { - fired = fallbackService.checkAlwaysOnSafeties(taskListEvent(`fb-${i}`)); - if (fired) break; - fallbackService.checkAlwaysOnSafeties(finishedEvent); - fired = fallbackService.recordToolResultByCallId( - `fb-${i}`, - taskListResult(`board state v${i}`, `fb-${i}`), - ); - } - expect(fired).toBe(false); - expect(fallbackService.getLastLoopType()).toBeNull(); - }); - - it('does not halt a resumed poller whose suppressed-only evidence decayed (issue #9450)', () => { - // Two identical task_list requests stream and are suppressed without - // executing (authorization rejection / scheduler not_started): the - // streak stands at repCount 2 with suppressedRequests 2 and no result - // evidence. Two tool-call-free round-trips then decay the entry at the - // second Finished boundary. Pre-fix the decay zeroed - // suppressedRequests while KEEPING the streak: when executed polling - // resumed, expectedResults = repCount - inFlight - 0 stayed - // permanently one above resultsObserved (every new request adds one - // to repCount and, a round later, one to resultsObserved), so the - // exoneration gate was never satisfiable again and the poller halted - // CONSECUTIVE_IDENTICAL_TOOL_CALLS at the 5th consecutive request - // despite every executed result having changed. The decay must keep - // suppressedRequests alongside the kept streak so the gate keeps - // subtracting the never-answerable requests. - const finishedEvent = { - type: GeminiEventType.Finished, - value: { reason: 'STOP' }, - } as unknown as ServerGeminiStreamEvent; - const gapService = new LoopDetectionService(makeConfig()); - gapService.reset('decay-suppressed-resume-productive'); - - // Suppressed-only entry: two identical requests, never executed. - expect(gapService.checkAlwaysOnSafeties(taskListEvent('sup-0'))).toBe( - false, - ); - expect(gapService.checkAlwaysOnSafeties(taskListEvent('sup-1'))).toBe( - false, - ); - gapService.noteSuppressedToolCallByCallId('sup-0'); - gapService.noteSuppressedToolCallByCallId('sup-1'); - // Two tool-call-free round-trips: the first boundary consumes the - // suppression marks, the second decays the entry (suppressed-only, so - // the streak is kept). - gapService.checkAlwaysOnSafeties(finishedEvent); - gapService.checkAlwaysOnSafeties(finishedEvent); - - // Executed polling resumes with the board still changing: no halt. - let fired = false; - for (let i = 0; i < 9 && !fired; i++) { - fired = gapService.checkAlwaysOnSafeties(taskListEvent(`poll-${i}`)); - if (fired) break; - gapService.checkAlwaysOnSafeties(finishedEvent); - fired = gapService.recordToolResultByCallId( - `poll-${i}`, - taskListResult(`board state v${i}`, `poll-${i}`), - ); - } - expect(fired).toBe(false); - expect(gapService.getLastLoopType()).toBeNull(); - }); - - it('still halts a pure suppressed stream crossing round-trip boundaries (fail-safe)', () => { - // Fail-safe twin of the suppressed-only decay fix: the decay keeps the - // suppressed-only streak armed, so a persistent stream of identical - // suppressed calls — no result ever lands for it — still halts at the - // threshold even when it crosses a decay boundary. Kept - // suppressedRequests keep balancing the gate exactly like the - // uninterrupted stream does. - const finishedEvent = { - type: GeminiEventType.Finished, - value: { reason: 'STOP' }, - } as unknown as ServerGeminiStreamEvent; - const gapService = new LoopDetectionService(makeConfig()); - gapService.reset('decay-suppressed-halt'); - - expect(gapService.checkAlwaysOnSafeties(taskListEvent('sup-0'))).toBe( - false, - ); - expect(gapService.checkAlwaysOnSafeties(taskListEvent('sup-1'))).toBe( - false, - ); - gapService.noteSuppressedToolCallByCallId('sup-0'); - gapService.noteSuppressedToolCallByCallId('sup-1'); - gapService.checkAlwaysOnSafeties(finishedEvent); - gapService.checkAlwaysOnSafeties(finishedEvent); - - let fired = false; - let requests = 2; - for (let i = 2; i < 10 && !fired; i++) { - fired = gapService.checkAlwaysOnSafeties(taskListEvent(`sup-${i}`)); - requests = i + 1; - if (fired) break; - gapService.noteSuppressedToolCallByCallId(`sup-${i}`); - } - expect(fired).toBe(true); - expect(requests).toBe(TOOL_CALL_LOOP_THRESHOLD); - expect(gapService.getLastLoopType()).toBe( - LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, - ); - }); - - describe('persisted oversized results (issue #9450 follow-up)', () => { - // Results over the response-finalizer budget are rewritten into - // persistence stubs (utils/truncation.ts buildStub) whose envelope - // embeds a per-call unique file path (`.txt`). The guards - // fingerprint the model-visible finalized parts, so hashing the - // envelope would make every fingerprint unique and silently disable - // every result-aware guard for exactly the largest results. These - // tests build their stubs with the real builder so a format change - // in truncation.ts fails here loudly instead of leaving the guards - // parsing stale hand-mirrored shapes. - const FROZEN_BOARD = 'task row for a frozen board\n'.repeat(1500); // ~41KB - - const persistedStub = (callId: string, board: string): string => - buildStub( - board, - Buffer.byteLength(board, 'utf-8'), - `/tmp/qwen/tool-results/${callId}.txt`, - ); - - const stubResult = (callId: string, board: string): Part[] => - taskListResult(persistedStub(callId, board), callId); - - it('halts on a frozen oversized board despite per-call unique stub paths', () => { - let fired = false; - for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD; i++) { - fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); - if (fired) break; - service.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - stubResult(`poll_${i}`, FROZEN_BOARD), - ); - } - expect(fired).toBe(true); - expect(service.getLastLoopType()).toBe( - LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, - ); - }); - - it('keeps oversized polling alive while the board keeps changing', () => { - // Guards against over-collapsing: the envelope must be stripped, not - // the preview — changed boards inside unique-path stubs are still - // observable progress. - let fired = false; - for (let i = 0; i < 4 * TOOL_CALL_LOOP_THRESHOLD; i++) { - fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); - if (fired) break; - service.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - stubResult(`poll_${i}`, `board state v${i}`), - ); - } - expect(fired).toBe(false); - expect(loggers.logLoopDetected).not.toHaveBeenCalled(); - }); - - it('keeps oversized polling alive when the board changes beyond the preview window', () => { - // buildStub previews only the first PREVIEW_SIZE_CHARS chars, so a - // board whose mutations land beyond that window hashes to an - // identical preview on every poll. The full-output digest embedded - // in the stub must keep the fingerprints distinct; without it the - // always-on guard halts this productive poller at the 5th identical - // request. - const headerLine = 'task row header line\n'; - const header = headerLine.repeat( - Math.ceil(PREVIEW_SIZE_CHARS / headerLine.length) + 10, - ); - let fired = false; - for (let i = 0; i < 4 * TOOL_CALL_LOOP_THRESHOLD; i++) { - fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); - if (fired) break; - service.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - stubResult(`poll_${i}`, `${header}tail state v${i}`), - ); - } - expect(fired).toBe(false); - expect(loggers.logLoopDetected).not.toHaveBeenCalled(); - }); - - it('counts global duplicates on frozen oversized results when heuristics run', () => { - const heuristicService = new LoopDetectionService( - makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), - ); - heuristicService.reset('global-dup-persisted'); - - const interleaved = ['task_list', 'tool_b', 'tool_c']; - let detected = false; - for ( - let round = 0; - round < GLOBAL_DUPLICATE_THRESHOLD && !detected; - round++ - ) { - for (const name of interleaved) { - const args = - name === 'task_list' ? TASK_LIST_ARGS : { step: round }; - if ( - heuristicService.addAndCheck( - createToolCallRequestEvent(name, args), - ) - ) { - detected = true; - break; - } - if (name === 'task_list') { - detected = heuristicService.recordToolResult( - { name, args }, - stubResult(`poll_${round}`, FROZEN_BOARD), - ); - if (detected) break; - } - } - } - expect(detected).toBe(true); - expect(heuristicService.getLastLoopType()).toBe( - LoopType.GLOBAL_TOOL_CALL_DUPLICATE, - ); - }); - - it('halts an interleaved frozen oversized poller just past the adaptive soft cap', () => { - // CLI default: skipLoopDetection=true. The cap's stuck signal fed by - // recordToolResult pair counts is then the only live halt path for - // an interleaved frozen poller; unique stub paths must not keep it - // judging the turn productive until the hard backstop. - const capService = new LoopDetectionService(makeConfig(20)); - capService.reset('cap-frozen-persisted'); - - let fired = false; - let totalCalls = 0; - for (let round = 0; round < 40 && !fired; round++) { - fired = capService.checkAlwaysOnSafeties( - taskListEvent(`tl-${round}`), - ); - totalCalls++; - if (fired) break; - fired = capService.checkAlwaysOnSafeties( - createToolCallRequestEvent('tool_b', { step: round }), - ); - totalCalls++; - if (fired) break; - capService.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - stubResult(`tl-${round}`, FROZEN_BOARD), - ); - } - expect(fired).toBe(true); - expect(capService.getLastLoopType()).toBe(LoopType.TURN_TOOL_CALL_CAP); - // Halts just past the soft cap (20) once the stuck signal is armed, - // far below the hard backstop (20 * 10). - expect(totalCalls).toBeLessThanOrEqual(22); - }); - - it('halts on a frozen unwrapped oversized stub (disk unavailable)', () => { - // buildStub's unwrapped shape (no `` tag) is - // emitted when disk persistence is unavailable. Its note depends on - // the failure mode, so alternate the two notes across polls: only a - // guard that recognizes the shape and reduces it to the payload can - // see through the varying envelope to the frozen board. - const notes = [ - '(file too large to persist)', - '(session disk budget exhausted)', - ]; - let fired = false; - for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD; i++) { - fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); - if (fired) break; - const stub = buildStub( - FROZEN_BOARD, - Buffer.byteLength(FROZEN_BOARD, 'utf-8'), - notes[i % notes.length], - ); - service.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult(stub, `poll_${i}`), - ); - } - expect(fired).toBe(true); - expect(service.getLastLoopType()).toBe( - LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, - ); - }); - - it('halts on a frozen truncated-output stub despite per-call unique file paths', async () => { - // The truncateAndSaveToFile shape (TOOL_OUTPUT_TRUNCATED_PREFIX) - // embeds a per-call file path in its envelope; a frozen board must - // still halt. The real builder spills its file, so give it a - // throwaway directory. - const spillDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'loop-detection-stub-'), - ); - try { - let fired = false; - for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD; i++) { - fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); - if (fired) break; - const { content } = await truncateAndSaveToFile( - FROZEN_BOARD, - `task_list_poll_${i}`, - spillDir, - 1024, - 20, - ); - service.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult(content, `poll_${i}`), - ); - } - expect(fired).toBe(true); - expect(service.getLastLoopType()).toBe( - LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, - ); - } finally { - await fs.rm(spillDir, { recursive: true, force: true }); - } - }); - - it('keeps truncated-output polling alive when the board changes in the truncated middle band', async () => { - // truncateAndSaveToFile retains a head and a tail and drops the - // middle band, so a board mutating inside that band hashes to an - // identical head+tail payload on every poll. The full-output digest - // embedded in the envelope must keep the fingerprints distinct; - // without it the always-on guard halts this productive poller at - // the 5th identical request. - const spillDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'loop-detection-stub-'), - ); - const head = 'task row head line\n'.repeat(30); - const tail = 'task row tail line\n'.repeat(30); - try { - let fired = false; - for (let i = 0; i < 4 * TOOL_CALL_LOOP_THRESHOLD; i++) { - fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); - if (fired) break; - const middle = `middle band state v${i}\n`.repeat(400); - const { content } = await truncateAndSaveToFile( - `${head}${middle}${tail}`, - `task_list_poll_${i}`, - spillDir, - 1024, - Number.POSITIVE_INFINITY, - 'both', - 400, - ); - service.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult(content, `poll_${i}`), - ); - } - expect(fired).toBe(false); - expect(loggers.logLoopDetected).not.toHaveBeenCalled(); - } finally { - await fs.rm(spillDir, { recursive: true, force: true }); - } - }); - - // The batch-budget finalizer (fitText) rewrites oversized results into - // a header embedding a per-call artifact path plus a head/tail fit. - // Built through the real budget enforcer so the guard is tested - // against the producer's actual shape. - const batchBudgetResult = (callId: string, board: string): Part[] => { - const fitted = enforceFunctionResponseBudget( - [ - { - callId, - toolName: 'task_list', - responseParts: [ - { - functionResponse: { - id: callId, - name: 'task_list', - response: { output: board }, - }, - }, - ], - persistedOutputFiles: [`/tmp/qwen/tool-results/${callId}.txt`], - }, - ], - 1500, - ); - return fitted[0].responseParts; - }; - - it('halts on a frozen batch-budget result despite per-call unique artifact paths', () => { - // Without the full-output digest in the fitText header, the unique - // artifact path fingerprints every poll uniquely and the guard - // never sees the frozen board. - let fired = false; - for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD; i++) { - fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); - if (fired) break; - service.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - batchBudgetResult(`poll_${i}`, FROZEN_BOARD), - ); - } - expect(fired).toBe(true); - expect(service.getLastLoopType()).toBe( - LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, - ); - }); - - it('keeps batch-budget polling alive when the board changes beyond the fitted window', () => { - // fitText retains a head and a tail and drops the middle band, so a - // board mutating there fits to an identical head+tail on every - // poll. The digest must cover the FULL pre-fit text (not the - // fitted payload), or the guard halts this productive poller. - const head = 'task row head line\n'.repeat(30); - const tail = 'task row tail line\n'.repeat(80); - let fired = false; - for (let i = 0; i < 4 * TOOL_CALL_LOOP_THRESHOLD; i++) { - fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); - if (fired) break; - const middle = `middle band state v${i}\n`.repeat(800); - service.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - batchBudgetResult(`poll_${i}`, `${head}${middle}${tail}`), - ); - } - expect(fired).toBe(false); - expect(loggers.logLoopDetected).not.toHaveBeenCalled(); - }); - - it('collides the raw and batch-budget-fitted fingerprints of identical content', () => { - // A batch oscillating around the budget boundary alternates between - // the raw output (under-budget) and the digest-reduced fit header - // (over-budget). The two representations of identical content must - // fingerprint identically or every poll counts as "changed" and - // the result-aware guards never fire (issue #9450). - expect(fingerprintToolResult(taskListResult(FROZEN_BOARD, 'raw'))).toBe( - fingerprintToolResult(batchBudgetResult('fitted', FROZEN_BOARD)), - ); - // A changed board stays distinct in both representations. - expect( - fingerprintToolResult(taskListResult(FROZEN_BOARD, 'raw')), - ).not.toBe( - fingerprintToolResult( - batchBudgetResult('fitted', `${FROZEN_BOARD}new row`), - ), - ); - }); - - it('halts a frozen board whose representation alternates raw/fitted across the budget boundary', () => { - // Witness along the finding's shape: identical board content, but - // the batch fits under budget on solo polls (raw) and over budget - // on co-batched polls (fitted). Pre-fix the alternating - // fingerprints judged every poll "changed" — unchangedStreak and - // consecutiveIdenticalResults reset every round and no guard - // fired. With the representations colliding, the always-on - // consecutive guard halts at the 5th identical request with all - // prior results observed unchanged. - let fired = false; - for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD + 1 && !fired; i++) { - fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); - if (fired) break; - service.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - i % 2 === 0 - ? taskListResult(FROZEN_BOARD, `poll_${i}`) - : batchBudgetResult(`poll_${i}`, FROZEN_BOARD), - ); - } - expect(fired).toBe(true); - expect(service.getLastLoopType()).toBe( - LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, - ); - }); - - // fitText's degenerate band: when the per-slot allocation holds the - // 84-char digest line but not the 107-char minimal header (budgets - // 84..106 for a single slot), the fit is EXACTLY the line - // `Full output sha256: <64-hex>` — no producer prefix recognizes - // that shape, so the guard must reduce it structurally or it never - // collides with the raw under-budget representation of the same - // board and a frozen board oscillating across the budget boundary - // counts every poll as "changed" (issue #9450). - const degenerateFitResult = (callId: string, board: string): Part[] => { - const fitted = enforceFunctionResponseBudget( - [ - { - callId, - toolName: 'task_list', - responseParts: [ - { - functionResponse: { - id: callId, - name: 'task_list', - response: { output: board }, - }, - }, - ], - persistedOutputFiles: [`/tmp/qwen/tool-results/${callId}.txt`], - }, - ], - 100, - ); - return fitted[0].responseParts; - }; - - it('collides the raw and degenerate digest-line-only fingerprints of identical content', () => { - const fittedOutput = degenerateFitResult('fitted', FROZEN_BOARD)[0] - .functionResponse?.response?.['output']; - // Shape witness: the budget 100 fit is exactly the digest line. - expect(fittedOutput).toBe( - `${FULL_OUTPUT_DIGEST_LABEL}${createHash('sha256') - .update(FROZEN_BOARD) - .digest('hex')}`, - ); - expect(fingerprintToolResult(taskListResult(FROZEN_BOARD, 'raw'))).toBe( - fingerprintToolResult(degenerateFitResult('fitted', FROZEN_BOARD)), - ); - // A changed board stays distinct in both representations. - expect( - fingerprintToolResult(taskListResult(FROZEN_BOARD, 'raw')), - ).not.toBe( - fingerprintToolResult( - degenerateFitResult('fitted', `${FROZEN_BOARD}new row`), - ), - ); - }); - - it('halts a frozen board whose representation alternates raw/degenerate-fit across the budget boundary', () => { - let fired = false; - for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD + 1 && !fired; i++) { - fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); - if (fired) break; - service.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - i % 2 === 0 - ? taskListResult(FROZEN_BOARD, `poll_${i}`) - : degenerateFitResult(`poll_${i}`, FROZEN_BOARD), - ); - } - expect(fired).toBe(true); - expect(service.getLastLoopType()).toBe( - LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, - ); - }); - - it('collides the save-failure and successfully-spilled fingerprints of identical content', async () => - // truncateAndSaveToFile's save-failure fallback starts with the - // digest label itself (no producer prefix) and carries the - // head/tail payload plus the save-failure note. It must reduce to - // its embedded full-output digest exactly like the successfully - // spilled shape, or a board whose spill oscillates between success - // and failure counts every poll as "changed". - { - const spillDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'loop-detection-stub-'), - ); - try { - const { content: spilled } = await truncateAndSaveToFile( - FROZEN_BOARD, - 'task_list_ok', - spillDir, - 1024, - 20, - ); - // Force the save-failure path: mkdir(recursive) throws ENOTDIR - // when an ancestor path component is a regular file. - const blocker = path.join(spillDir, 'blocker'); - await fs.writeFile(blocker, 'x'); - const { content: unsaved } = await truncateAndSaveToFile( - FROZEN_BOARD, - 'task_list_fail', - path.join(blocker, 'sub'), - 1024, - 20, - ); - expect(unsaved.endsWith(TRUNCATION_SAVE_FAILURE_NOTE)).toBe(true); - expect(fingerprintToolResult(taskListResult(spilled, 'ok'))).toBe( - fingerprintToolResult(taskListResult(unsaved, 'fail')), - ); - } finally { - await fs.rm(spillDir, { recursive: true, force: true }); - } - }); - - it('halts a frozen board whose spill success alternates across polls', async () => { - const spillDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'loop-detection-stub-'), - ); - try { - const blocker = path.join(spillDir, 'blocker'); - await fs.writeFile(blocker, 'x'); - const failDir = path.join(blocker, 'sub'); - let fired = false; - for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD && !fired; i++) { - fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); - if (fired) break; - const { content } = await truncateAndSaveToFile( - FROZEN_BOARD, - `task_list_poll_${i}`, - i % 2 === 0 ? spillDir : failDir, - 1024, - 20, - ); - service.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult(content, `poll_${i}`), - ); - } - expect(fired).toBe(true); - expect(service.getLastLoopType()).toBe( - LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, - ); - } finally { - await fs.rm(spillDir, { recursive: true, force: true }); - } - }); - - it('does not collapse content that merely starts with the digest label', () => { - // Shape-exact recognition only: a board whose first line quotes - // the label without a full producer digest line (no 64-hex payload - // of the right length, no save-failure note) carries no producer - // digest and must keep fingerprinting as ordinary content, so its - // mutations stay visible to the result-aware guards. - let fired = false; - for (let i = 0; i < 4 * TOOL_CALL_LOOP_THRESHOLD; i++) { - fired = service.checkAlwaysOnSafeties(taskListEvent(`poll_${i}`)); - if (fired) break; - const board = `${FULL_OUTPUT_DIGEST_LABEL}pending\nrow v${i}`; - service.recordToolResult( - { name: 'task_list', args: TASK_LIST_ARGS }, - taskListResult(board, `poll_${i}`), - ); - } - expect(fired).toBe(false); - expect(loggers.logLoopDetected).not.toHaveBeenCalled(); - }); - }); - - describe('stub grammar parity across the batch-budget boundary (issue #9450)', () => { - // fitText (producer) and stripPersistenceEnvelope (guard) must - // recognize stub shapes with ONE grammar: the shared recognizers in - // tools/truncation.ts. These tests pin the guard side at the fixed - // positions the producers write digests to — a quoted stub header in - // payload content must never hijack the fingerprint, and content with - // no producer digest must canonicalize identically on both sides of - // the budget boundary. - const quotedHex = '0123456789abcdef'.repeat(4); - - const reducedText = (value: string): string => - extractToolResultText(taskListResult(value)) ?? ''; - - it('reduces a fit header digest at the fixed position only, never from the payload', () => { - // A fit-prefix-leading value whose PAYLOAD quotes a stub header - // carries no producer digest: pre-fix the guard scanned the whole - // value for a line-anchored digest and reduced to the quoted hex — - // changes below the quoted line stayed invisible and the reduction - // diverged from fitText, which hashes the full text when the fixed - // header position carries no digest. - const value = - `${BATCH_BUDGET_FIT_PREFIX}\n` + - `board quoting a stub header\n` + - `${FULL_OUTPUT_DIGEST_LABEL}${quotedHex}\n` + - 'more payload'; - const reduced = reducedText(value); - const expected = `sha256:${createHash('sha256') - .update(value) - .digest('hex')}`; - expect(reduced).toContain(expected); - expect(reduced).not.toContain(quotedHex); - }); - - it('collides a no-digest fit-prefix-leading value with its over-budget fit', () => { - // Content starting with the fit prefix but carrying no digest line - // fingerprinted VERBATIM pre-fix under budget while its over-budget - // fit wrapped to sha256(raw): two representations of identical - // content that never collide, so a frozen board oscillating around - // the budget boundary never accumulated unchanged-result evidence. - // Both sides must reduce to sha256(content). - const value = `${BATCH_BUDGET_FIT_PREFIX}\nplain content without any digest line`; - const digest = createHash('sha256').update(value).digest('hex'); - const reduced = reducedText(value); - expect(reduced).toContain(`sha256:${digest}`); - // The over-budget fit of the same content (fitText writes the - // sha256 at the fixed header position) reduces to the SAME marker. - const fit = - `${BATCH_BUDGET_FIT_PREFIX}\n` + - `${FULL_OUTPUT_DIGEST_LABEL}${digest}\n` + - 'Persisted tool-output artifact: /tmp/tool-results/call-a.txt'; - expect(reducedText(fit)).toBe(reduced); - }); - - it('keeps the shape-exact label arm reducing producer shapes to their digest', () => { - // The degenerate digest-line-only fit and the save-failure fallback - // both start with the label itself and carry their digest at the - // fixed leading position: they must still reduce to that digest - // under the shared shape-exact recognizer. - const exactLine = `${FULL_OUTPUT_DIGEST_LABEL}${quotedHex}`; - expect(reducedText(exactLine)).toContain( - `sha256:${quotedHex}`, - ); - // A quoted stub header (label + quoted hex + further payload) is - // content, not a stub: it canonicalizes to its own sha256 and its - // mutations stay visible. - const quotedA = `${FULL_OUTPUT_DIGEST_LABEL}${quotedHex}\npayload A`; - const quotedB = `${FULL_OUTPUT_DIGEST_LABEL}${quotedHex}\npayload B`; - const reducedA = reducedText(quotedA); - expect(reducedA).toContain( - `sha256:${createHash('sha256') - .update(quotedA) - .digest('hex')}`, - ); - expect(reducedA).not.toBe(reducedText(quotedB)); - expect(reducedA).not.toContain(quotedHex); - }); - }); }); }); diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 69d592ecf34..6f123ac672a 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -20,18 +20,6 @@ import { } from '../telemetry/types.js'; import type { Config } from '../config/config.js'; import { getToolCallRepeatKey } from '../tools/tool-call-repeat-key.js'; -import { BATCH_BUDGET_FIT_PREFIX } from '../tools/tool-response-finalizer.js'; -import { - extractAnchoredStubDigest, - extractDigestCarryingShapeDigest, - extractStubDigestAt, - FULL_OUTPUT_DIGEST_LABEL, - OUTPUT_TOO_LARGE_PREFIX, - PERSISTED_OUTPUT_OPEN_TAG, - PERSISTED_PREVIEW_MARKER, - TOOL_OUTPUT_TRUNCATED_PREFIX, - TRUNCATED_PART_MARKER, -} from '../tools/truncation.js'; // Re-exported for existing importers (daemon turn-loop guard); the // implementation lives in a leaf module so replay detection in @@ -107,16 +95,6 @@ const MIN_PERIODIC_REGION_LENGTH = 1000; // `task_update`) have different mutation/delivery semantics and stay out. const STATEFUL_READ_TOOLS: ReadonlySet = new Set(['task_list']); -/** - * Whether a tool is a stateful read tool (see STATEFUL_READ_TOOLS). - * Exported so the daemon's turn-loop guard (ACP Session) applies the same - * result-aware treatment as this service — the two runtimes must not drift - * (issue #9450 requirement #6). - */ -export function isStatefulReadTool(toolName: string): boolean { - return STATEFUL_READ_TOOLS.has(toolName); -} - // Bound for the callId → request map used to pair tool results with their // requests (recordToolResultByCallId). Parallel tool batches are far smaller; // the cap only protects against unpaired entries accumulating. @@ -208,221 +186,6 @@ export function shouldHaltOnTurnToolCallCap( return isExplicitCap || totalCalls > hardCap || stuck; } -// Producer shapes of the oversized-result stubs (see utils/truncation.ts -// and the batch-budget finalizer). Recognition is anchored on these -// prefixes: task results such as task_list embed peer-authored text -// verbatim, and that text can quote stub markers (this PR puts a -// `Full output sha256: ` line into every oversized output, and agents -// quote stubs into board state). Honoring a marker found MID-string would -// let quoted content collapse or vary the whole-board fingerprint — -// re-shipping the #9450 false halt via content. Every shape here embeds a -// per-call unique artifact path in its envelope, which is exactly why it -// must be reduced to its digest; content that merely contains (or even -// starts with) the digest label carries no per-call path and is -// fingerprinted as ordinary content instead. -const STUB_PRODUCER_PREFIXES: readonly string[] = [ - PERSISTED_OUTPUT_OPEN_TAG, - OUTPUT_TOO_LARGE_PREFIX, - TOOL_OUTPUT_TRUNCATED_PREFIX, - // The batch-budget finalizer's fitText header. - BATCH_BUDGET_FIT_PREFIX, -]; - -/** - * Canonicalizes a value no producer shape recognizes (see - * stripPersistenceEnvelope): its own sha256 under the `` - * sentinel, never the verbatim text — the batch-budget fit of the same - * content carries exactly this sha256 as its header digest (fitText), so - * both sides of the budget boundary collide. - */ -function canonicalizeContent(value: string): string { - return `sha256:${createHash('sha256') - .update(value) - .digest('hex')}`; -} - -/** - * Reduces an oversized-result stub to its semantic payload for - * fingerprinting. Oversized tool results are rewritten into truncation - * stubs: a `` envelope embedding the unique - * `/.txt` path, an unwrapped `Output too large - * (...)` envelope whose session-dependent note can also vary between - * calls, the `truncateAndSaveToFile` shape embedding a random temp-file - * name, or a batch-budget fit whose header embeds a per-call artifact - * path. Hashing the envelope would make every fingerprint unique per call - * — silently disabling every result-aware guard for exactly the largest - * results. The producers embed a sha256 of the full pre-truncation output - * (FULL_OUTPUT_DIGEST_LABEL); prefer it over any visible content, because - * previews and head+tail payloads only cover the first/last chars — a - * board mutating in the dropped band must still fingerprint differently - * each poll (and a frozen board identically) no matter which stub shape - * carries it. The digest-first rule also covers stubs nested inside a - * further batch-budget fit, where the outer digest fingerprints the inner - * stub as a whole. Stubs without a digest line fall back to the shape's - * visible payload after its stable marker. The markers are the shared - * constants from utils/truncation.ts and the batch-budget finalizer so the - * parser cannot drift from the producer. The `` sentinel - * keeps a stub fingerprint from ever colliding with a small literal output - * that matches the payload. - * - * Stub recognition is gated on the producer prefixes (see - * STUB_PRODUCER_PREFIXES), and the guard parses stubs with the SAME grammar - * the producers write: the shared recognizers from tools/truncation.ts - * (extractAnchoredStubDigest / extractDigestCarryingShapeDigest / - * extractStubDigestAt) instead of a hand-mirrored copy. Digests are read - * only at the positions the producers write them — a batch-budget fit's - * header line right after its prefix, a label-leading shape's leading - * position — so a quoted stub header inside payload content can never - * hijack the fingerprint, and arbitrary result text that merely contains - * the label is canonicalized like ordinary content instead of being - * collapsed to a quoted window (issue #9450). - * - * Non-stub text is canonicalized to its own sha256 digest marker instead of - * being carried verbatim: the batch-budget fit rewrites an over-budget - * batch's results into fit headers embedding the sha256 of the full pre-fit - * text (fitText), while an under-budget batch keeps the raw text. Those two - * representations of identical content must collide or a frozen board whose - * batch oscillates around the budget boundary would count every poll as - * "changed" and fail open past every result-aware guard (issue #9450). - * Hashing the full text preserves every distinction a changed board makes - * (including inside the band a fit would drop), and the `` - * sentinel keeps a canonicalized result from ever colliding with a literal - * small output that happens to match the raw text of another shape. The - * same collision rule applies to values that themselves start with a stub - * prefix but carry no producer digest (fit-prefix-leading board content): - * returning them verbatim would fingerprint them differently from their - * over-budget fit (whose digest is the sha256 of the full pre-fit text), - * so the cap's stuck signal could never arm for a frozen board oscillating - * around the boundary. - */ -function stripPersistenceEnvelope(value: string): string { - const isProducerStub = STUB_PRODUCER_PREFIXES.some((prefix) => - value.startsWith(prefix), - ); - // Digest-carrying shapes that start with the digest label itself, so no - // producer prefix recognizes them: the batch-budget finalizer's - // degenerate band that returns exactly the `Full output sha256: <64-hex>` - // line when the per-slot allocation holds the digest line but not the - // full fit header, and truncateAndSaveToFile's save-failure fallback - // (label + head/tail payload + save-failure note). Both carry the full - // pre-truncation digest and must reduce to it exactly like the prefixed - // stubs, or an over-budget representation of a board never collides with - // its under-budget (or successfully-spilled) representation: a frozen - // board oscillating across the budget boundary would fingerprint - // differently per poll despite byte-identical content, the - // consecutive-identical streak would never accumulate, and the cap's - // stuck signal would never arm (issue #9450). Recognition is shape-exact - // and reads the digest at the fixed leading position — the producer-side - // recognizer itself (extractDigestCarryingShapeDigest), so a quoted stub - // header (label + quoted hex + further payload) keeps fingerprinting as - // ordinary content on BOTH sides of the budget boundary instead of the - // fit carrying the quoted hex while the guard hashes the content. - if (!isProducerStub) { - const shapeDigest = extractDigestCarryingShapeDigest(value); - if (shapeDigest !== null) { - return `sha256:${shapeDigest}`; - } - return canonicalizeContent(value); - } - - // A batch-budget fit carries its digest at the fixed header position — - // the line right after the prefix. Read only there (fitText carries a - // nested digest through exactly that position): scanning the whole - // payload would adopt a quoted stub header from the fit's content, so the - // fingerprint would follow the quoted hex while content changes below it - // stay invisible. Without a header digest the value is canonicalized: - // fitText computes the same sha256 of the same text when it has no - // digest to carry, so the under-budget value and its over-budget fit - // collide even when the value itself starts with the fit prefix - // (issue #9450). - if (value.startsWith(BATCH_BUDGET_FIT_PREFIX)) { - const headerLabelStart = BATCH_BUDGET_FIT_PREFIX.length + 1; - const fitDigest = - value[BATCH_BUDGET_FIT_PREFIX.length] === '\n' && - value.startsWith(FULL_OUTPUT_DIGEST_LABEL, headerLabelStart) - ? extractStubDigestAt( - value, - headerLabelStart + FULL_OUTPUT_DIGEST_LABEL.length, - ) - : null; - if (fitDigest !== null) { - return `sha256:${fitDigest}`; - } - return canonicalizeContent(value); - } - - const digest = extractAnchoredStubDigest(value); - if (digest !== null) { - return `sha256:${digest}`; - } - - const isPreviewStub = - value.startsWith(PERSISTED_OUTPUT_OPEN_TAG) || - value.startsWith(OUTPUT_TOO_LARGE_PREFIX); - if (isPreviewStub) { - const marker = `${PERSISTED_PREVIEW_MARKER}\n`; - const index = value.indexOf(marker); - if (index >= 0) { - return `${value.slice(index + marker.length)}`; - } - return value; - } - if (value.startsWith(TOOL_OUTPUT_TRUNCATED_PREFIX)) { - const marker = `\n${TRUNCATED_PART_MARKER}`; - const index = value.indexOf(marker); - if (index >= 0) { - return `${value.slice(index + marker.length)}`; - } - // Truncation prefix with neither a digest line nor the truncated-part - // marker: no producer payload to fall back to — canonicalize for the - // same boundary-collision reason as the fit-prefix arm above. - return canonicalizeContent(value); - } - return value; -} - -/** - * Reconstructs the model-visible result text from tool response parts. - * Only the fingerprint of this text is retained by the guards, never the - * text itself. Returns null when the parts carry no functionResponse - * content. Shared by this service and the daemon's turn-loop guard (ACP - * Session) so both runtimes fingerprint results identically and cannot - * drift (issue #9450 requirement #6). - */ -export function extractToolResultText( - responseParts: readonly Part[], -): string | null { - const chunks: string[] = []; - for (const part of responseParts) { - const functionResponse = part.functionResponse; - if (!functionResponse) continue; - // Oversized results arrive as persistence stubs whose envelope embeds - // a per-call unique file path; fingerprint the semantic payload only - // (see stripPersistenceEnvelope) so identical underlying results stay - // identical no matter where they were persisted. - chunks.push( - JSON.stringify(functionResponse.response ?? {}, (_key, value) => - typeof value === 'string' ? stripPersistenceEnvelope(value) : value, - ), - ); - } - return chunks.length > 0 ? chunks.join('\n') : null; -} - -/** - * sha256 fingerprint of a tool result's model-visible text (see - * extractToolResultText), or null when the parts carry no functionResponse - * content. Shared with the daemon's turn-loop guard for the same - * cannot-drift reason as extractToolResultText. - */ -export function fingerprintToolResult( - responseParts: readonly Part[], -): string | null { - const resultText = extractToolResultText(responseParts); - if (resultText === null) return null; - return createHash('sha256').update(resultText).digest('hex'); -} - /** * Service for detecting and preventing infinite loops in AI responses. * Monitors tool call repetitions and content sentence repetitions. @@ -501,112 +264,33 @@ export class LoopDetectionService { private capKeyCounts = new Map(); private capMaxKeyRepeat = 0; - // Stateful-read contribution to the cap's stuck signal: the running max of - // the CURRENT consecutive-identical-result streaks (see - // statefulRepeatState). Unlike capMaxKeyRepeat this disarms when a result - // changes — a frozen-then-thawed board must release the cap exactly as it - // releases the result-time global-duplicate count, so the adaptive cap - // cannot latch a stale peak from a frozen phase and halt productive - // polling just past the soft cap. Kept separate from capMaxKeyRepeat - // (which stays a high-water mark for deterministic tools, where a 6x - // repeat is never productive even if the model later varies its calls). - private statefulCapKeyRepeat = 0; - // Result-aware tracking for stateful read tools (see STATEFUL_READ_TOOLS). // Keyed by the (tool, args) repeat key. `resultsObserved` / // `unchangedStreak` count results within the CURRENT consecutive-identical // streak (restarted when the streak breaks); `lastFingerprint` survives // streak breaks so a state change is still visible across interleaved // calls (used by the action-stagnation reset). - // `suppressedRequests` counts the suppressed calls (replays, rejections — - // see noteSuppressedToolCallByCallId) within the CURRENT streak. A - // suppressed call can never produce an exonerating result, so the - // consecutive-identical gate's expected-result computation subtracts it: - // without that, the request-side count of a mixed stream (suppressed + - // executed calls) would permanently outrun the recorded results and the - // exoneration gate would be unsatisfiable. The suppressed call's - // consecutive-count increment is KEPT (not unwound), so a pure stream of - // identical suppressed calls still reaches the threshold — its entry - // carries zero result evidence, the expected-result count reduces to - // zero, and the gate halts (the fail-safe shape). That is what catches a - // subagent persistently re-emitting an unavailable task_list. - // `consecutiveIdenticalResults` is the stuck-repetition evidence for the - // global-duplicate detector and the adaptive cap (replacing the - // request-time global-duplicate counting and the cap's stuck-repetition - // counting for these tools): it counts results that repeat the key's - // IMMEDIATELY PRECEDING result (interleaved calls still accumulate) and - // restarts at 1 whenever a result differs from its predecessor — the same - // call returning changed state is productive and must not accumulate - // toward either halt. Counting turn-wide (key, fingerprint) totals - // instead would halt a board oscillating between two byte-identical - // states even though every result there differs from its predecessor. private statefulRepeatState = new Map< string, { resultsObserved: number; unchangedStreak: number; - consecutiveIdenticalResults: number; - suppressedRequests: number; lastFingerprint: string | undefined; } >(); - // Stateful keys that recorded a result since the last Finished round-trip - // boundary. At each Finished, keys NOT in this set AND not in the - // requested set below produced no result AND no request for a whole - // round-trip: the model moved on to other work, so their streak evidence - // is abandoned and must stop feeding the cap's stuck signal — otherwise a - // key abandoned after a frozen phase keeps its peak for the whole prompt - // and the adaptive cap halts a productive turn just past the soft cap - // (issue #9450). Keys that keep polling appear in every round's results - // (or requests) and are never decayed. - private statefulResultKeysSinceLastFinished = new Set(); - - // Stateful keys that streamed a request since the last Finished boundary. - // Decay must skip these too: production records results AFTER the Finished - // of the stream that emitted their calls, so at the boundary of a poll - // round the poll's own result has not landed yet, and a gap round (a - // text-only turn, an interleaved other tool) consumes the previous - // result's mark at ITS boundary — keying decay on result marks alone - // wipes a still-polled key's streak at the next poll's boundary. That - // disarmed the cap's stuck signal for a frozen board polled every other - // round (fail open) and reset resultsObserved mid-streak while - // toolCallRepetitionCount stood, making the consecutive guard's - // exoneration gate permanently unsatisfiable (fail closed) (issue #9450). - // Maintained in the always-on path (checkAlwaysOnSafeties) so it works - // under skipLoopDetection too. - private statefulRequestedKeysSinceLastFinished = new Set(); + // Turn-wide counts of (repeat key, result fingerprint) pairs for stateful + // read tools, recorded post-execution. Replaces the request-time + // global-duplicate counting and the cap's stuck-repetition counting for + // these tools: the same call returning changed state is productive and + // must not accumulate toward either halt. + private statefulPairCounts = new Map(); // callId → request pairing so results can be matched to their calls when // the runtime only has the response (populated on ToolCallRequest events, // consumed by recordToolResultByCallId). private requestByCallId = new Map(); - // Repeat keys known to belong to a stateful read tool, so the - // alternating-pattern carve-out can tell which window participants are - // stateful (repeat keys are hashes and do not carry the tool name). - private statefulRepeatKeys = new Set(); - - // Rolling per-key result fingerprints for the alternating-pattern - // carve-out (see checkAlternatingPattern), capped at one window's worth - // of occurrences per key so a full ABAB window is judged on the results - // its own requests produced. - private statefulAlternationHistory = new Map(); - - // Per-key count of stateful requests streamed through the guards whose - // results have NOT landed yet (incremented in checkAlwaysOnSafeties, - // decremented in recordToolResult and noteSuppressedToolCallByCallId). - // With parallel tool batches ALL requests of a round reach the guards - // before that round's results land, so a guard judged on args alone would - // skip the exonerating result check for occurrences still in flight and - // false-halt a productive poller; both the always-on consecutive-identical - // gate and the alternating-pattern carve-out subtract these from their - // expected results (issue #9450). Maintained in the always-on path so the - // accounting works under the skipLoopDetection default too. Reduces to - // the sequential arithmetic when results land before the next request is - // fed. - private statefulInFlight = new Map(); - // Loop type of the most recent firing. Bubbled up through the // LoopDetected event so callers (non-interactive CLI, telemetry) can tell // the user which detector actually fired. @@ -667,33 +351,11 @@ export class LoopDetectionService { if (this.disabledForSession) return false; if (!this.isStatefulReadTool(toolCall.name)) return false; - const fingerprint = fingerprintToolResult(responseParts); - if (fingerprint === null) return false; + const resultText = LoopDetectionService.extractResultText(responseParts); + if (resultText === null) return false; + const fingerprint = createHash('sha256').update(resultText).digest('hex'); const key = this.getToolCallKey(toolCall); - // Round-trip boundary bookkeeping: this key produced a result in the current - // round, so the Finished-boundary decay must not treat it as abandoned - // (see statefulResultKeysSinceLastFinished). - this.statefulResultKeysSinceLastFinished.add(key); - - // One in-flight request for this key has landed: unreserve it for the - // in-flight accounting (see statefulInFlight). Floored at zero because - // results can be recorded for calls that never streamed through the - // guards (direct recordToolResult callers). - const inFlight = this.statefulInFlight.get(key) ?? 0; - if (inFlight > 0) { - this.statefulInFlight.set(key, inFlight - 1); - } - - // Rolling result history for the alternating-pattern carve-out (see - // checkAlternatingPattern), capped at one window's occurrences per key. - const history = this.statefulAlternationHistory.get(key) ?? []; - history.push(fingerprint); - if (history.length > ALTERNATING_PATTERN_CYCLES) { - history.shift(); - } - this.statefulAlternationHistory.set(key, history); - // Consecutive-streak evidence for the always-on guard. The state entry // can predate the streak (lastFingerprint survives streak breaks), so // create it lazily but only count results while a streak exists. @@ -702,8 +364,6 @@ export class LoopDetectionService { state = { resultsObserved: 0, unchangedStreak: 0, - consecutiveIdenticalResults: 0, - suppressedRequests: 0, lastFingerprint: undefined, }; this.statefulRepeatState.set(key, state); @@ -731,29 +391,21 @@ export class LoopDetectionService { this.sameNameStreak = 1; } - // Consecutive identical-result counting (see statefulRepeatState): a - // result that differs from the key's predecessor restarts the count, so - // an oscillating board never accumulates toward either halt. - const consecutiveIdentical = fingerprintChanged - ? 1 - : state.consecutiveIdenticalResults + 1; - state.consecutiveIdenticalResults = consecutiveIdentical; - - // Cap stuck signal from result evidence (see statefulCapKeyRepeat). A - // raised peak must NOT latch: when a result changes, recompute the peak - // from the keys' CURRENT streaks so a thawed board disarms the adaptive - // cap exactly as it disarms the result-time global-duplicate count. - if (consecutiveIdentical > this.statefulCapKeyRepeat) { - this.statefulCapKeyRepeat = consecutiveIdentical; - } else if (fingerprintChanged) { - this.recomputeStatefulCapPeak(); + // Turn-wide (repeat key, fingerprint) counting: replaces the + // request-time global-duplicate and cap stuck-repetition counting for + // stateful tools. + const pairKey = `${key}|${fingerprint}`; + const pairCount = (this.statefulPairCounts.get(pairKey) ?? 0) + 1; + this.statefulPairCounts.set(pairKey, pairCount); + if (pairCount > this.capMaxKeyRepeat) { + this.capMaxKeyRepeat = pairCount; } // The global-duplicate detector is gated (skipLoopDetection) exactly as // its request-time counterpart in addAndCheckHeuristicLoops. if ( !this.config.getSkipLoopDetection() && - consecutiveIdentical >= GLOBAL_DUPLICATE_THRESHOLD + pairCount >= GLOBAL_DUPLICATE_THRESHOLD ) { this.lastLoopType = LoopType.GLOBAL_TOOL_CALL_DUPLICATE; logLoopDetected( @@ -788,152 +440,25 @@ export class LoopDetectionService { ); } - /** - * Notes that a call which streamed through the guards was suppressed - * WITHOUT executing (a cross-round replay of an already-handled provider - * call id, or an authorization rejection), so its synthetic response carries - * no result evidence and must not be recorded via recordToolResult / - * recordToolResultByCallId. The request-time reservations the guards made - * when the call streamed in must unwind: the callId pairing is dropped (no - * real result will land for it), the per-key in-flight reservation is - * released (otherwise the consecutive-identical gate and the - * alternating-pattern carve-out over-subtract in-flight counts and judge - * on too little evidence), and the - * key is marked as having produced activity since the last Finished - * boundary — the model DID re-issue the poll; suppression is the runtime's - * machinery, not abandonment, so the decay must not wipe a live - * frozen-board streak on the next boundary (the daemon twin skips decay - * for batches that execute nothing instead — recordDaemonToolCalls in the - * ACP Session). Without this, a replay-suppressed round is - * indistinguishable from abandonment and disarms the result-aware halts. - * The request-side repetition increment the suppressed call made when it - * streamed in is KEPT (not unwound): a persistent stream of identical - * suppressed calls — a subagent re-emitting an unavailable task_list, a - * provider re-emitting the same handled id — is exactly the stuck - * pattern the always-on consecutive-identical guard exists to stop, and - * no result will ever land for it. Unwinding the increment let such a - * stream oscillate the count 0↔1 forever: the threshold unreachable, the - * missing-evidence fail-safe unreachable (no result ever records, so no - * state entry exists), and the cap's stuck signal unreachable - * (trackCapKeyRepeat skips stateful tools) — the loop ran to the hard - * backstop instead of halting on the 5th identical request (issue #9450). - * The exoneration gate stays satisfiable for MIXED streams (suppressed + - * executed calls with changing results) because checkToolCallLoop - * subtracts the streak's suppressedRequests from the expected result - * count. Unknown callIds (never streamed through the guards) are - * ignored. - * - * `replaySuppression` marks the CROSS-ROUND REPLAY class (a suppressed - * re-emission of an already-handled provider call id — the duplicate - * synthetics), as opposed to the never-executed class (authorization - * rejections, scheduler not_started synthetics). Replay-only rounds must - * carry the live stateful streak marks across the next Finished boundary: - * the daemon twin's batch recorder receives an all-replay batch as zero - * executable calls and skips its boundary decay entirely - * (recordDaemonToolCalls), so the last executed round's result marks - * survive to the NEXT non-empty batch's decay. Without the carry, core - * consumes those marks at the replay round's own Finished boundary and - * wipes a live frozen-board streak at the next one when the replayed - * tool is NOT stateful (a non-stateful replay marks nothing here — and - * its callId never resolves in the pairing, which tracks only stateful - * requests), so a frozen task_list board polled around non-stateful - * replay rounds never accumulates its stuck signal in core while the - * daemon halts it just past the soft cap (requirement #6 parity). The - * carry re-adds the keys with live streak evidence for exactly one more - * boundary; the never-executed class must NOT carry: the daemon treats - * those calls as ordinary (non-empty) batches whose boundary decay runs. - * Suppression awareness, not activity awareness: post-abandonment rounds - * have no suppressions at all, so decay still releases abandoned peaks - * (the literal "skip decay on stateful-inactive rounds" formulation - * would latch the peak forever and break the abandonment release). - */ - noteSuppressedToolCallByCallId( - callId: string, - options?: { replaySuppression?: boolean }, - ): void { - // Runs BEFORE the callId pairing lookup: the pairing only tracks - // STATEFUL requests (see checkAlwaysOnSafeties), but the carry is - // needed most when the suppressed replay is a NON-stateful tool — its - // callId never resolves here, yet its replay-only round must still - // protect the live frozen-board streaks (see the doc above). - if (options?.replaySuppression) { - this.carryStatefulStreakMarksAcrossSuppression(); - } - const request = this.requestByCallId.get(callId); - if (!request) return; - this.requestByCallId.delete(callId); - if (!this.isStatefulReadTool(request.name)) return; - const key = this.getToolCallKey(request); - // Floored at zero because the heuristic tier (the only writer besides - // this unwind) may be off under skipLoopDetection; a stale decrement is - // inert then — nothing reads the count until a Retry/reset clears it. - const inFlight = this.statefulInFlight.get(key) ?? 0; - if (inFlight > 0) { - this.statefulInFlight.set(key, inFlight - 1); - } - // The consecutive-identical increment the suppressed call made when it - // streamed in is KEPT (see the doc above): count it into the streak's - // suppressedRequests instead so the exoneration gate can subtract it - // while the threshold still sees the request-side evidence. Only while - // the streak still belongs to the suppressed key: a later different - // call restarts the count for its own key. The entry is created - // lazily here (no recorded result yet) so suppressions landing BEFORE - // the streak's first result still balance the gate — for a PURE - // suppressed stream the entry then carries zero result evidence, the - // expected-result count reduces to zero, and the gate halts exactly - // like the missing-evidence fail-safe it replaces. - if (this.lastToolCallKey === key) { - let state = this.statefulRepeatState.get(key); - if (!state) { - state = { - resultsObserved: 0, - unchangedStreak: 0, - consecutiveIdenticalResults: 0, - suppressedRequests: 0, - lastFingerprint: undefined, - }; - this.statefulRepeatState.set(key, state); - } - state.suppressedRequests++; - } - // Drop the window occurrence the alternating-pattern tier pushed when - // the call streamed in; it carries no result, and leaving it in would - // overstate the key's occurrences so the carve-out expects one more - // recorded result than can ever exist (judging the window on too - // little evidence). Remove the most recent occurrence: the suppressed - // call is the latest push of this key unless an identical call streamed - // after it, in which case removing either occurrence is equivalent. - const windowIndex = this.recentToolCallKeys.lastIndexOf(key); - if (windowIndex >= 0) { - this.recentToolCallKeys.splice(windowIndex, 1); - } - this.statefulResultKeysSinceLastFinished.add(key); + private isStatefulReadTool(toolName: string): boolean { + return STATEFUL_READ_TOOLS.has(toolName); } /** - * One extra Finished boundary of decay coverage for the live stateful - * streak marks, applied when a replay suppression lands (see - * noteSuppressedToolCallByCallId): re-adds every key that still carries - * streak evidence to statefulResultKeysSinceLastFinished so the next - * boundary's decay skips it, mirroring the daemon's empty-batch decay - * skip. Keys already marked are re-added idempotently; keys whose - * evidence already decayed stay gone (the carry never resurrects an - * abandoned streak — only postpones an imminent decay by one boundary). + * Reconstructs the model-visible result text from tool response parts. + * Only the fingerprint of this text is retained, never the text itself. + * Returns null when the parts carry no functionResponse content. */ - private carryStatefulStreakMarksAcrossSuppression(): void { - for (const [key, state] of this.statefulRepeatState) { - if ( - state.consecutiveIdenticalResults > 0 || - state.resultsObserved > 0 || - state.unchangedStreak > 0 - ) { - this.statefulResultKeysSinceLastFinished.add(key); - } - } - } - - private isStatefulReadTool(toolName: string): boolean { - return isStatefulReadTool(toolName); + private static extractResultText( + responseParts: readonly Part[], + ): string | null { + const chunks: string[] = []; + for (const part of responseParts) { + const functionResponse = part.functionResponse; + if (!functionResponse) continue; + chunks.push(JSON.stringify(functionResponse.response ?? {})); + } + return chunks.length > 0 ? chunks.join('\n') : null; } private getToolCallKey(toolCall: { name: string; args: object }): string { @@ -980,15 +505,7 @@ export class LoopDetectionService { // Stateful read tools are counted post-execution in // recordToolResult, keyed on (call, result fingerprint) instead of // args alone (issue #9450). - const stateful = this.isStatefulReadTool(event.value.name); - if (stateful) { - this.statefulRepeatKeys.add(toolCallKey); - // The per-key in-flight reservation for this request is made in - // the always-on path (checkAlwaysOnSafeties), which production - // and addAndCheck always run first — incrementing here too would - // double-count (see statefulInFlight). - } - const globalDup = stateful + const globalDup = this.isStatefulReadTool(event.value.name) ? false : this.checkGlobalDuplicate(toolCallKey); const alternating = this.checkAlternatingPattern(toolCallKey); @@ -1009,9 +526,6 @@ export class LoopDetectionService { // streak reset). this.globalToolCallCounts.clear(); this.recentToolCallKeys = []; - this.statefulAlternationHistory.clear(); - this.statefulRepeatKeys.clear(); - this.statefulInFlight.clear(); // A replay (non-continuation) retry also re-streams the failed // attempt's content and reasoning through the chunk detectors: the // transport-replay gate admits thought-only cuts (#7832), and with @@ -1038,17 +552,6 @@ export class LoopDetectionService { // replay-retry resets. this.globalToolCallCounts.clear(); this.recentToolCallKeys = []; - // The failed model's streamed requests never execute and never - // receive results (Turn discards them without a suppression note), - // so the stateful reservations they made can never unwind on their - // own: release them here like the replay-retry branch above, or the - // stale in-flight counts collapse the alternating-pattern carve-out - // (expectedResults drops to zero, the exoneration check is skipped) - // and the guard halts the fallback model's productive poller on - // arguments alone (issue #9450). - this.statefulAlternationHistory.clear(); - this.statefulRepeatKeys.clear(); - this.statefulInFlight.clear(); this.resetContentTracking(); this.thoughtHistory = []; break; @@ -1105,12 +608,6 @@ export class LoopDetectionService { // round-trip rather than resetting to zero. if (event.type === GeminiEventType.Finished) { this.turnToolCallTotalCommitted = this.turnToolCallTotal; - // Results are recorded between round-trips (after the Finished event - // of the stream that emitted their calls), so at this boundary the - // results recorded since the previous Finished are exactly the prior - // round's executed results — the safe point to decay stateful keys - // absent from them (see decayAbandonedStatefulStreaks). - this.decayAbandonedStatefulStreaks(); return false; } @@ -1127,61 +624,15 @@ export class LoopDetectionService { this.resetToolCallCount(); this.capKeyCounts.clear(); this.capMaxKeyRepeat = 0; - this.statefulCapKeyRepeat = 0; // A retry replays the failed attempt's tool calls; drop the stateful // result evidence too so the replayed attempt is judged on its own - // results (the consecutive counts re-accumulate as results land, - // consistent with the capKeyCounts/globalToolCallCounts clears). + // results (pair counts re-accumulate as results land, consistent with + // the capKeyCounts/globalToolCallCounts clears). + this.statefulPairCounts.clear(); for (const state of this.statefulRepeatState.values()) { state.resultsObserved = 0; state.unchangedStreak = 0; - state.consecutiveIdenticalResults = 0; - state.suppressedRequests = 0; } - this.statefulResultKeysSinceLastFinished.clear(); - this.statefulRequestedKeysSinceLastFinished.clear(); - this.statefulAlternationHistory.clear(); - this.statefulRepeatKeys.clear(); - this.statefulInFlight.clear(); - return false; - } - - // A model fallback restarts the attempt from scratch exactly like a - // replay retry (Turn clears pendingToolCalls on the fallback event), - // except the failed model's streamed tool calls are DISCARDED, not - // re-streamed: they never execute, no results land for them, and no - // suppression note unwinds their request-side state. Mirror the Retry - // resets so the failed attempt's evidence cannot poison the fallback - // attempt: roll the per-turn cap back to the last committed round-trip - // (the failed attempt's calls counted there but never produce results), - // drop the consecutive-identical streak (its in-flight requests can - // never be exonerated, so keeping it would false-halt the fallback - // model's resumed polling at the threshold), clear the cap's repeat - // trackers and the stateful result evidence (the fresh attempt is - // judged on its own results), release the stateful reservations the - // discarded requests made (they can never unwind on their own), and - // drop the still-unanswered callId pairings — results recorded between - // round-trips already consumed the prior rounds' entries, so whatever - // remains belongs to the discarded attempt and would otherwise - // accumulate toward the FIFO eviction cap (issue #9450). - if (event.type === GeminiEventType.ModelFallback) { - this.turnToolCallTotal = this.turnToolCallTotalCommitted; - this.resetToolCallCount(); - this.capKeyCounts.clear(); - this.capMaxKeyRepeat = 0; - this.statefulCapKeyRepeat = 0; - for (const state of this.statefulRepeatState.values()) { - state.resultsObserved = 0; - state.unchangedStreak = 0; - state.consecutiveIdenticalResults = 0; - state.suppressedRequests = 0; - } - this.statefulResultKeysSinceLastFinished.clear(); - this.statefulRequestedKeysSinceLastFinished.clear(); - this.statefulAlternationHistory.clear(); - this.statefulRepeatKeys.clear(); - this.statefulInFlight.clear(); - this.requestByCallId.clear(); return false; } @@ -1201,21 +652,6 @@ export class LoopDetectionService { // can be large (e.g. write_file content), so avoid recomputing per guard. const key = this.getToolCallKey(event.value); const stateful = this.isStatefulReadTool(event.value.name); - if (stateful) { - this.statefulRepeatKeys.add(key); - // The Finished-boundary decay must not treat this key as abandoned - // at this stream's own boundary: its result is recorded AFTER the - // Finished event (see statefulRequestedKeysSinceLastFinished). - this.statefulRequestedKeysSinceLastFinished.add(key); - // This request is now in flight: its result has not landed yet, so - // the consecutive-identical gate's exoneration check and the - // alternating-pattern carve-out must not expect it (see - // statefulInFlight). recordToolResult / noteSuppressedToolCallByCallId - // decrement when it lands or is suppressed. Kept here (always-on) so - // the accounting also works under the skipLoopDetection default, - // where the heuristic tier never runs (issue #9450). - this.statefulInFlight.set(key, (this.statefulInFlight.get(key) ?? 0) + 1); - } // Pair requests with their later results (recordToolResultByCallId). // Only stateful read tools participate: recordToolResult rejects every @@ -1283,7 +719,6 @@ export class LoopDetectionService { if (state) { state.resultsObserved = 0; state.unchangedStreak = 0; - state.suppressedRequests = 0; } } this.lastToolCallKey = key; @@ -1293,45 +728,21 @@ export class LoopDetectionService { if (this.isStatefulReadTool(toolCall.name)) { // Result-aware guard (issue #9450): identical arguments to a stateful // read do not imply an identical result, so only halt when the - // executed results corroborate the loop. With sequential rounds the - // prior N-1 results of the Nth identical request have been recorded; - // with parallel batches ALL of a round's identical requests stream - // through this guard before ANY of that round's results lands - // (dedupeToolCallsById collapses only same-callId duplicates, so - // distinct-callId twins both execute), and the still-in-flight - // requests cannot have recorded results yet. Subtract them from the - // expected count (floored at the recorded evidence) so the gate is - // judged on the results that CAN have landed; a changed recorded - // result still restarts the streak. Suppressed calls in the streak - // (replays, rejections) are subtracted too: they can never produce - // an exonerating result, and leaving their request-side increments - // in the expected count would keep the gate permanently one result - // short for mixed suppressed + executed streams. Missing result - // evidence (results never recorded for this streak) fails safe and - // keeps the pre-#9450 behavior, so the DashScope protection (#5019) - // is never loosened by a wiring gap — that fail-safe is also what - // halts a pure stream of rejected/suppressed identical calls (no - // state entry ever exists for it), e.g. a subagent persistently - // re-emitting an unavailable task_list. + // executed results corroborate the loop. By the Nth identical request + // the prior N-1 results have been recorded; if they were ALL observed + // and unchanged, the repetition is genuinely unproductive. If some + // result changed, the model's re-poll was productive — restart the + // streak instead of halting. Missing result evidence (results never + // recorded for this streak) fails safe and keeps the pre-#9450 + // behavior, so the DashScope protection (#5019) is never loosened by + // a wiring gap. const state = this.statefulRepeatState.get(key); - const inFlight = Math.min( - this.statefulInFlight.get(key) ?? 0, - this.toolCallRepetitionCount, - ); - const suppressedInStreak = Math.min( - state?.suppressedRequests ?? 0, - this.toolCallRepetitionCount, - ); - const expectedResults = Math.max( - this.toolCallRepetitionCount - inFlight - suppressedInStreak, - state?.resultsObserved ?? 0, - ); + const expectedResults = this.toolCallRepetitionCount - 1; if (state && state.resultsObserved >= expectedResults) { if (state.unchangedStreak < expectedResults - 1) { this.toolCallRepetitionCount = 1; state.resultsObserved = 0; state.unchangedStreak = 0; - state.suppressedRequests = 0; return false; } } @@ -2025,109 +1436,6 @@ export class LoopDetectionService { return false; } - /** - * Recomputes the cap's stateful stuck signal (statefulCapKeyRepeat) from - * the keys' CURRENT consecutive-identical-result streaks, dropping any - * latched peak that no longer reflects live evidence. - */ - private recomputeStatefulCapPeak(): void { - let peak = 0; - for (const state of this.statefulRepeatState.values()) { - if (state.consecutiveIdenticalResults > peak) { - peak = state.consecutiveIdenticalResults; - } - } - this.statefulCapKeyRepeat = peak; - } - - /** - * Round-trip boundary decay for the cap's stateful stuck signal. A key - * that produced no result for a whole round-trip was abandoned: the model - * moved on to other work, so its frozen-phase streak must stop feeding the - * stuck signal. Without this the key map is add-only and the peak latches - * for the whole prompt — the adaptive cap would then halt a productive - * turn just past the soft cap on the abandoned key's stale peak (issue - * #9450). Keys polled in every round-trip appear in the result set and - * keep their streaks, so a continuously frozen board still arms the cap. - * Keys that merely streamed a request since the last boundary are skipped - * too (statefulRequestedKeysSinceLastFinished): production records results - * AFTER the Finished of the stream that emitted their calls, and any gap - * round (a text-only turn, an interleaved other tool) consumes the - * previous result's mark at its own boundary — decaying a key that is - * still being polled would wipe its streak at the next poll's boundary, - * disarming the stuck signal for an every-other-round frozen poller and - * resetting resultsObserved mid-streak while toolCallRepetitionCount - * stands (issue #9450). When a key's evidence is zeroed, the always-on - * consecutive streak is dropped too if it still belongs to that key: - * the exoneration gate counts resultsObserved against - * toolCallRepetitionCount, and zeroing the evidence while the count - * stands leaves the gate permanently unsatisfiable — resumed polling - * would halt CONSECUTIVE_IDENTICAL_TOOL_CALLS regardless of its results - * (the #9450 false positive re-entering via the decay layer). A resumed - * streak starts fresh and is judged on its own results; decay never runs - * for a key with requests still in flight (the requested-set skip above), - * so this cannot drop a streak the in-flight accounting is still - * deferring. Exception — a suppressedRequests-ONLY entry (no result - * evidence) keeps its standing streak AND its suppressedRequests: the - * gate subtracts the never-answerable requests, so a pure suppressed - * stream crossing round-trip boundaries still halts at the threshold, - * and resumed EXECUTED polling on the same key stays exonerable — - * zeroing suppressedRequests while the request-side count stands would - * leave expectedResults permanently one above resultsObserved and - * false-halt a changing-board poller (issue #9450). lastFingerprint - * survives the decay: when polling resumes, the first fresh result is - * still judged against the last observed one (changed → productive, - * unchanged → the count re-accumulates toward the halt). - */ - private decayAbandonedStatefulStreaks(): void { - let decayed = false; - for (const [key, state] of this.statefulRepeatState) { - if (this.statefulResultKeysSinceLastFinished.has(key)) continue; - if (this.statefulRequestedKeysSinceLastFinished.has(key)) continue; - const hadResultEvidence = - state.consecutiveIdenticalResults > 0 || - state.resultsObserved > 0 || - state.unchangedStreak > 0; - if (hadResultEvidence || state.suppressedRequests > 0) { - state.consecutiveIdenticalResults = 0; - state.resultsObserved = 0; - state.unchangedStreak = 0; - if (hadResultEvidence && this.lastToolCallKey === key) { - // Dropping the exoneration gate's result evidence while the - // consecutive count stands would leave the gate permanently - // unsatisfiable, so drop the streak with it — and the suppression - // count with the streak: it belongs to the dropped streak, and a - // later streak must not subtract requests it never made. - state.suppressedRequests = 0; - this.lastToolCallKey = null; - this.toolCallRepetitionCount = 0; - } else if (this.lastToolCallKey !== key) { - // The streak no longer belongs to this key (or was reset): the - // suppression count is read only while its streak stands, so it - // decays with the rest of the evidence. - state.suppressedRequests = 0; - } - // Else: suppressed-only evidence (no result evidence) with the - // streak still standing — a stream of identical suppressed calls - // crossing round-trip boundaries. Keep the streak AND its - // suppressedRequests: the exoneration gate subtracts the - // never-answerable requests, so the threshold still halts a pure - // suppressed stream, and if EXECUTED polling resumes on the same - // key the gate stays satisfiable (zeroing suppressedRequests while - // the request-side count stands would leave expectedResults - // permanently one above resultsObserved and false-halt a - // changing-board poller — the #9450 false positive re-entering via - // the decay layer). - decayed = true; - } - } - this.statefulResultKeysSinceLastFinished.clear(); - this.statefulRequestedKeysSinceLastFinished.clear(); - if (decayed) { - this.recomputeStatefulCapPeak(); - } - } - /** * Records a (tool,args) occurrence for the adaptive cap and updates the * running max repeat count. Always-on (called from checkAlwaysOnSafeties @@ -2159,10 +1467,7 @@ export class LoopDetectionService { if ( !shouldHaltOnTurnToolCallCap( this.turnToolCallTotal, - // Request-time evidence (deterministic tools) and result-time - // evidence (stateful reads) feed the same stuck signal; the - // stateful half disarms when results change (statefulCapKeyRepeat). - Math.max(this.capMaxKeyRepeat, this.statefulCapKeyRepeat), + this.capMaxKeyRepeat, this.config.getMaxToolCallsPerTurn(), this.config.isMaxToolCallsPerTurnExplicit(), ) @@ -2205,8 +1510,7 @@ export class LoopDetectionService { * Alternating-pattern detection: catches ABABAB… patterns where the model * flips between two distinct tool calls. Tracked via a sliding window of * tool-call keys; when the window fills with alternating A/B values the - * turn is halted — except for stateful read participants whose observed - * results keep changing (issue #9450), see the carve-out below. + * turn is halted. */ private checkAlternatingPattern(toolCallKey: string): boolean { const maxLen = 2 * ALTERNATING_PATTERN_CYCLES; @@ -2231,42 +1535,6 @@ export class LoopDetectionService { } } - // Result-aware carve-out for stateful read tools (issue #9450): - // identical arguments do not imply an identical result, so an ABAB - // poller is only stuck when its observed results corroborate it. For - // every stateful participant require the results produced by the - // window's own prior requests, minus the requests still in flight (fed - // to this tier but not yet answered — with parallel tool batches BOTH - // requests of a round reach the guard before that round's results land, - // so more than just the window-tail request can be in flight); if ANY - // recorded result changed, the alternation is making observable - // progress and the window restarts. Missing result evidence (results - // never recorded) fails safe and keeps the argument-only halt, so a - // wiring gap never loosens the guard. The per-key in-flight count - // reduces to the sequential arithmetic (tail request in flight) when - // each result lands before the next request is fed. - for (const altKey of [a, b]) { - if (!this.statefulRepeatKeys.has(altKey)) continue; - const occurrences = this.recentToolCallKeys.filter( - (windowKey) => windowKey === altKey, - ).length; - const inFlight = Math.min( - this.statefulInFlight.get(altKey) ?? 0, - occurrences, - ); - const expectedResults = occurrences - inFlight; - if (expectedResults <= 0) continue; - const history = this.statefulAlternationHistory.get(altKey); - if (!history || history.length < expectedResults) { - continue; - } - const recent = history.slice(-expectedResults); - if (recent.some((fp) => fp !== recent[0])) { - this.recentToolCallKeys = []; - return false; - } - } - this.lastLoopType = LoopType.ALTERNATING_TOOL_CALL_PATTERN; logLoopDetected( this.config, @@ -2301,13 +1569,8 @@ export class LoopDetectionService { this.turnToolCallTotalCommitted = 0; this.capKeyCounts.clear(); this.capMaxKeyRepeat = 0; - this.statefulCapKeyRepeat = 0; this.statefulRepeatState.clear(); - this.statefulResultKeysSinceLastFinished.clear(); - this.statefulRequestedKeysSinceLastFinished.clear(); - this.statefulRepeatKeys.clear(); - this.statefulAlternationHistory.clear(); - this.statefulInFlight.clear(); + this.statefulPairCounts.clear(); this.requestByCallId.clear(); } diff --git a/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts index c2047589027..d494f12cf62 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts @@ -27,7 +27,6 @@ import { SkillLaunchEvent, ProtocolTagSanitizedEvent, RipgrepRuntimeRecoveryEvent, - SubagentExecutionEvent, type ToolCallEvent, } from '../types.js'; import type { RumEvent, RumPayload } from './event-types.js'; @@ -1071,62 +1070,4 @@ describe('QwenLogger', () => { expect(rumEvent.properties).not.toHaveProperty('mcp_server_name'); }); }); - - describe('logSubagentExecutionEvent', () => { - it('carries loop_type into the subagent_execution journal record', () => { - // Pins the final hop of the #9450 attribution chain: if the - // conditional loop_type spread is dropped or the key is misnamed, - // journal records again log loop stops as unattributable. - const logger = QwenLogger.getInstance(mockConfig)!; - const enqueueSpy = vi.spyOn(logger, 'enqueueLogEvent'); - - const event = new SubagentExecutionEvent('general-purpose', 'failed', { - terminate_reason: 'LOOP_DETECTED', - loop_type: 'consecutive_identical_tool_calls', - }); - - logger.logSubagentExecutionEvent(event); - - expect(enqueueSpy).toHaveBeenCalledWith( - expect.objectContaining({ - event_type: 'action', - type: 'tool', - name: 'subagent_execution', - properties: { - subagent_name: 'general-purpose', - status: 'failed', - terminate_reason: 'LOOP_DETECTED', - loop_type: 'consecutive_identical_tool_calls', - }, - }), - ); - }); - - it('omits loop_type when the run ended without a loop attribution', () => { - const logger = QwenLogger.getInstance(mockConfig)!; - const enqueueSpy = vi.spyOn(logger, 'enqueueLogEvent'); - - const event = new SubagentExecutionEvent('general-purpose', 'completed', { - terminate_reason: 'COMPLETED', - }); - - logger.logSubagentExecutionEvent(event); - - expect(enqueueSpy).toHaveBeenCalledWith( - expect.objectContaining({ - event_type: 'action', - type: 'tool', - name: 'subagent_execution', - properties: { - subagent_name: 'general-purpose', - status: 'completed', - terminate_reason: 'COMPLETED', - }, - }), - ); - expect(enqueueSpy.mock.calls[0][0].properties).not.toHaveProperty( - 'loop_type', - ); - }); - }); }); diff --git a/packages/core/src/tools/agent/agent.test.ts b/packages/core/src/tools/agent/agent.test.ts index 0dc16cde6d9..4d504a4ffda 100644 --- a/packages/core/src/tools/agent/agent.test.ts +++ b/packages/core/src/tools/agent/agent.test.ts @@ -2185,7 +2185,6 @@ describe('AgentTool', () => { failedToolCalls: 0, }), getTerminateMode: vi.fn().mockReturnValue(AgentTerminateMode.GOAL), - getLoopType: vi.fn().mockReturnValue(null), } as unknown as AgentHeadless; mockContextState = { @@ -2241,37 +2240,6 @@ describe('AgentTool', () => { expect(display.subagentName).toBe('file-search'); }); - it('keeps the loop attribution on the final task card when the subagent stops on a loop (issue #9450)', async () => { - // The FINISH handler augments terminateReason with the loop type, - // but the post-await display update merge-overwrites currentDisplay - // and execute() returns that display as the committed card — so the - // update must re-apply the augmentation or the card collapses back - // to bare LOOP_DETECTED and the failed task is unattributable. - vi.mocked(mockAgent.getTerminateMode).mockReturnValue( - AgentTerminateMode.LOOP_DETECTED, - ); - vi.mocked(mockAgent.getLoopType).mockReturnValue('turn_tool_call_cap'); - - const params: AgentParams = { - description: 'Search files', - prompt: 'Find all TypeScript files', - subagent_type: 'file-search', - run_in_background: false, - }; - - const invocation = ( - agentTool as AgentToolWithProtectedMethods - ).createInvocation(params); - const result = await invocation.execute(); - - const display = result.returnDisplay as AgentResultDisplay; - expect(display.type).toBe('task_execution'); - expect(display.status).toBe('failed'); - expect(display.terminateReason).toBe( - `${AgentTerminateMode.LOOP_DETECTED} (turn_tool_call_cap)`, - ); - }); - it('rejects working_dir when the resolved subagent config runs in the background', async () => { // The explicit run_in_background param is caught in validateToolParams; // this covers the other route into the background — a subagent config @@ -3754,7 +3722,6 @@ describe('AgentTool', () => { failedToolCalls: 0, }), getTerminateMode: vi.fn().mockReturnValue(AgentTerminateMode.GOAL), - getLoopType: vi.fn().mockReturnValue(null), } as unknown as AgentHeadless; mockContextState = { @@ -5019,7 +4986,6 @@ describe('AgentTool', () => { failedToolCalls: 0, }), getTerminateMode: vi.fn().mockReturnValue(AgentTerminateMode.GOAL), - getLoopType: vi.fn().mockReturnValue(null), } as unknown as AgentHeadless; mockContextState = { @@ -5216,7 +5182,6 @@ describe('AgentTool', () => { failedToolCalls: 0, }), getTerminateMode: vi.fn().mockReturnValue(AgentTerminateMode.GOAL), - getLoopType: vi.fn().mockReturnValue(null), } as unknown as AgentHeadless; mockContextState = { @@ -5568,7 +5533,6 @@ describe('AgentTool', () => { failedToolCalls: 0, }), getTerminateMode: vi.fn().mockReturnValue(AgentTerminateMode.GOAL), - getLoopType: vi.fn().mockReturnValue(null), } as unknown as AgentHeadless; vi.mocked(mockAgent.execute).mockImplementation(async () => { @@ -5952,7 +5916,6 @@ describe('AgentTool', () => { executeExternalInputs: vi.fn().mockResolvedValue(undefined), getFinalText: vi.fn().mockReturnValue('Monitor done'), getTerminateMode: vi.fn().mockReturnValue(AgentTerminateMode.GOAL), - getLoopType: vi.fn().mockReturnValue(null), getExecutionSummary: vi.fn().mockReturnValue({}), // Background spawn subscribes to the core's event emitter to // populate the entry's recentActivities buffer. Return a stub diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index 2dc99be6b7a..1fa436f2c3f 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -1576,12 +1576,7 @@ class AgentToolInvocation extends BaseToolInvocation { this.updateDisplay( { status: event.terminateReason === 'GOAL' ? 'completed' : 'failed', - // Surface which loop detector stopped the subagent (issue #9450) - // so the failed task card is attributable instead of collapsing - // every loop stop into the generic LOOP_DETECTED label. - terminateReason: event.loopType - ? `${event.terminateReason} (${event.loopType})` - : event.terminateReason, + terminateReason: event.terminateReason, }, updateOutput, ); @@ -2197,12 +2192,6 @@ class AgentToolInvocation extends BaseToolInvocation { // Get the results const subagentRawText = subagent.getFinalText(); const terminateMode = subagent.getTerminateMode(); - // Which loop detector fired when the subagent stopped on a loop - // (issue #9450). The FINISH event handler augmented terminateReason - // with it, but the display update below merge-overwrites that — - // re-apply the same augmentation here or the committed task card - // (execute() returns this.currentDisplay) shows bare LOOP_DETECTED. - const loopType = subagent.getLoopType(); const finalText = appendStopHookBlockingCapWarning( toModelVisibleSubagentResult(subagentRawText, terminateMode), stopHookWarning, @@ -2242,9 +2231,7 @@ class AgentToolInvocation extends BaseToolInvocation { this.updateDisplay( { status: success ? 'completed' : 'failed', - terminateReason: loopType - ? `${terminateMode} (${loopType})` - : terminateMode, + terminateReason: terminateMode, result: finalText, executionSummary, }, diff --git a/packages/core/src/tools/tool-response-finalizer.test.ts b/packages/core/src/tools/tool-response-finalizer.test.ts index 90a2fdf90bf..edaec147854 100644 --- a/packages/core/src/tools/tool-response-finalizer.test.ts +++ b/packages/core/src/tools/tool-response-finalizer.test.ts @@ -5,25 +5,17 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createHash } from 'node:crypto'; import type { Part } from '@google/genai'; import type { Config } from '../config/config.js'; import { getPlanModeSystemReminder } from '../core/prompts.js'; import { ToolNames } from './tool-names.js'; import { - BATCH_BUDGET_FIT_PREFIX, enforceFunctionResponseBudget, finalizeToolResponses, toolResponseTextLength, type ToolResponseBudgetEntry, } from './tool-response-finalizer.js'; -import { - buildStub, - FULL_OUTPUT_DIGEST_LABEL, - persistAndTruncateToolResult, - TRUNCATION_SAVE_FAILURE_NOTE, -} from './truncation.js'; -import { fingerprintToolResult } from '../services/loopDetectionService.js'; +import { persistAndTruncateToolResult } from './truncation.js'; const debugLogger = vi.hoisted(() => ({ debug: vi.fn(), @@ -807,542 +799,4 @@ describe('tool response finalization', () => { expect(output.startsWith(reminder)).toBe(true); expect(output.length).toBeLessThanOrEqual(reminder.length + 2 + 100); }); - - describe('batch-budget fits over already-persisted stubs (issue #9450)', () => { - // The scheduler persists oversized results BEFORE the batch budget runs, - // so the text a fit wraps can itself be a `` stub whose - // envelope embeds the per-call unique `/.txt` - // path. Hashing that envelope would fingerprint every poll of an - // unchanged board uniquely and silently disable the result-aware loop - // guards; the fit must carry the stub's inner digest instead. - const boardDigest = (board: string): string => - createHash('sha256').update(board).digest('hex'); - - const fittedDigest = (text: string | undefined): string => { - const match = /Full output sha256: ([0-9a-f]{64})/.exec(text ?? ''); - return match?.[1] ?? ''; - }; - - const stubEntry = (callId: string, board: string) => { - const stub = buildStub( - board, - Buffer.byteLength(board), - `/tmp/tool-results/${callId}.txt`, - ); - return entry( - callId, - [ - { - functionResponse: { - id: callId, - name: 'task_list', - response: { output: stub }, - }, - }, - ], - [`/tmp/tool-results/${callId}.txt`], - ); - }; - - it('carries the inner stub digest through the fit instead of hashing the unique envelope', async () => { - const board = `#1 [in_progress] @peer-a — ship it\n${'board line\n'.repeat(300)}`; - const entries = [stubEntry('call-a', board), stubEntry('call-b', board)]; - - // Budget 600 over two ~2.3K stubs → each allocation (300) is below the - // stub length, so both are fitted. - const finalized = await finalizeToolResponses( - config(600), - entries, - new Map(), - ); - const outputs = finalized.map( - (finalizedEntry) => - finalizedEntry.responseParts[0].functionResponse?.response?.[ - 'output' - ], - ); - for (const output of outputs) { - expect(typeof output).toBe('string'); - expect(output as string).toContain(BATCH_BUDGET_FIT_PREFIX); - } - // Both polls of the frozen board must carry the board's digest — the - // per-envelope hashes differ (unique callId paths), so pre-fix each - // header carried a unique digest and the two assertions below failed. - expect(fittedDigest(outputs[0] as string)).toBe(boardDigest(board)); - expect(fittedDigest(outputs[1] as string)).toBe(boardDigest(board)); - }); - - it('keeps the carried digest stable when a fit is fitted again', async () => { - // geminiChat's send guard runs the same allocator on the fitted output - // of a later batch; the reduction must stay idempotent across that - // second nesting (the first fit's header is per-call unique via its - // artifact note, so it too must be reduced to the carried digest). - const board = `#2 [in_progress] @peer-b — verify\n${'board line\n'.repeat(300)}`; - - const once = await finalizeToolResponses( - config(400), - [stubEntry('call-a', board)], - new Map(), - ); - const firstFit = once[0].responseParts[0].functionResponse?.response?.[ - 'output' - ] as string; - expect(firstFit).toContain(BATCH_BUDGET_FIT_PREFIX); - - const twice = await finalizeToolResponses( - config(250), - [ - entry( - 'call-a', - [ - { - functionResponse: { - id: 'call-a', - name: 'task_list', - response: { output: firstFit }, - }, - }, - ], - ['/tmp/tool-results/call-a.txt'], - ), - ], - new Map(), - ); - const secondFit = twice[0].responseParts[0].functionResponse?.response?.[ - 'output' - ] as string; - expect(secondFit).toContain(BATCH_BUDGET_FIT_PREFIX); - expect(fittedDigest(secondFit)).toBe(boardDigest(board)); - }); - - it('carries the inner digest when fitting the save-failure fallback shape', async () => { - // truncateAndSaveToFile's save-failure shape starts with the digest - // label itself (no producer prefix) and embeds the full-output - // digest; a fit wrapping it must carry that digest instead of - // hashing the whole shape, or the fit's digest-reduction never - // collides with the unfitted shape's own digest reduction and a - // board oscillating across the budget boundary counts every poll as - // "changed" (issue #9450). - const board = `#4 [in_progress] @peer-d — save failed\n${'board line\n'.repeat(300)}`; - const digest = boardDigest(board); - const saveFailureShape = - `${FULL_OUTPUT_DIGEST_LABEL}${digest}\n` + - 'head line\n... [CONTENT TRUNCATED] ...\ntail line\n' + - TRUNCATION_SAVE_FAILURE_NOTE; - expect(saveFailureShape.length).toBeGreaterThan(150); - - const finalized = await finalizeToolResponses( - config(150), - [ - entry('call-a', [ - { - functionResponse: { - id: 'call-a', - name: 'task_list', - response: { output: saveFailureShape }, - }, - }, - ]), - ], - new Map(), - ); - const output = finalized[0].responseParts[0].functionResponse?.response?.[ - 'output' - ] as string; - expect(output).toContain(BATCH_BUDGET_FIT_PREFIX); - expect(fittedDigest(output)).toBe(digest); - }); - - it('does not adopt a quoted stub digest from label-leading content', async () => { - // A tool result or peer-authored board that STARTS with a quoted - // `Full output sha256: ` line is content, not a stub: the guard - // side recognizes label-leading shapes shape-exactly, so the producer - // must hash the full text instead of carrying the quoted hex — - // pre-fix the fit carried the quoted hex and changes below the quoted - // line were invisible to the result-aware guards (issue #9450). - const quotedHex = 'ab'.repeat(32); - const quotedA = `${FULL_OUTPUT_DIGEST_LABEL}${quotedHex}\n${'payload A line\n'.repeat(40)}`; - const quotedB = `${FULL_OUTPUT_DIGEST_LABEL}${quotedHex}\n${'payload B line\n'.repeat(40)}`; - - const fitDigestOf = async (text: string): Promise => { - const finalized = await finalizeToolResponses( - config(150), - [ - entry('call-a', [ - { - functionResponse: { - id: 'call-a', - name: 'task_list', - response: { output: text }, - }, - }, - ]), - ], - new Map(), - ); - return fittedDigest( - finalized[0].responseParts[0].functionResponse?.response?.[ - 'output' - ] as string, - ); - }; - - expect(await fitDigestOf(quotedA)).toBe(boardDigest(quotedA)); - expect(await fitDigestOf(quotedB)).toBe(boardDigest(quotedB)); - expect(await fitDigestOf(quotedA)).not.toBe(quotedHex); - }); - - it('does not adopt a quoted digest buried in a fit-prefix-leading payload', async () => { - // Content starting with the fit prefix whose payload QUOTES a stub - // header carries no producer digest at the fixed header position: the - // carry-through must read that position only (where fitText writes - // it), never scan the payload — pre-fix the scan adopted the quoted - // hex, fingerprinting the fit to the quoted hex while the content - // below it changed (issue #9450). - const quotedHex = 'cd'.repeat(32); - const base = - `${BATCH_BUDGET_FIT_PREFIX}\n` + - `board quoting a stub header\n` + - `${FULL_OUTPUT_DIGEST_LABEL}${quotedHex}\n`; - const textA = `${base}payload A ${'x'.repeat(400)}`; - const textB = `${base}payload B ${'y'.repeat(400)}`; - - const fitDigestOf = async (text: string): Promise => { - const finalized = await finalizeToolResponses( - config(200), - [ - entry('call-a', [ - { - functionResponse: { - id: 'call-a', - name: 'task_list', - response: { output: text }, - }, - }, - ]), - ], - new Map(), - ); - return fittedDigest( - finalized[0].responseParts[0].functionResponse?.response?.[ - 'output' - ] as string, - ); - }; - - expect(await fitDigestOf(textA)).toBe(boardDigest(textA)); - expect(await fitDigestOf(textB)).toBe(boardDigest(textB)); - expect(await fitDigestOf(textA)).not.toBe(quotedHex); - }); - - it('collides quoted-stub-leading content across the budget boundary and stays change-sensitive', async () => { - // Both directions of the label-leading divergence: identical content - // must fingerprint identically raw (under budget) and fitted (over - // budget), and content changing BELOW the quoted line must - // fingerprint differently (issue #9450). - const quotedHex = 'ef'.repeat(32); - const make = (tail: string) => - `${FULL_OUTPUT_DIGEST_LABEL}${quotedHex}\nquoted stub header above\n${tail}`; - const textA = make(`payload v1 ${'a'.repeat(400)}`); - const textB = make(`payload v2 ${'b'.repeat(400)}`); - - const partsOf = (text: string): Part[] => [ - { - functionResponse: { - id: 'call-a', - name: 'task_list', - response: { output: text }, - }, - }, - ]; - const fitParts = async (text: string): Promise => { - const finalized = await finalizeToolResponses( - config(150), - [entry('call-a', partsOf(text))], - new Map(), - ); - return finalized[0].responseParts; - }; - - // Raw vs fitted representations of identical content collide. - expect(fingerprintToolResult(await fitParts(textA))).toBe( - fingerprintToolResult(partsOf(textA)), - ); - // Changes below the quoted line stay visible across the boundary. - expect(fingerprintToolResult(await fitParts(textA))).not.toBe( - fingerprintToolResult(await fitParts(textB)), - ); - }); - - it('collides a no-digest fit-prefix-leading value with its fit across the boundary', async () => { - // Entrance 2: content starting with the fit prefix but carrying no - // anchored digest line fingerprinted VERBATIM under budget (guard) - // while its over-budget fit wrapped to sha256(raw) — two - // representations of identical content that never collide, so a - // frozen board oscillating around the budget boundary never armed - // the cap's stuck signal. Both representations must reduce to the - // same sha256 (issue #9450). - const content = - `${BATCH_BUDGET_FIT_PREFIX}\n` + - `plain board content without any digest line\n` + - 'board line\n'.repeat(40); - - const partsOf = (text: string): Part[] => [ - { - functionResponse: { - id: 'call-a', - name: 'task_list', - response: { output: text }, - }, - }, - ]; - const finalized = await finalizeToolResponses( - config(150), - [entry('call-a', partsOf(content))], - new Map(), - ); - expect(fingerprintToolResult(finalized[0].responseParts)).toBe( - fingerprintToolResult(partsOf(content)), - ); - }); - }); - - describe('degenerate batch-budget fits stay content-dependent (issue #9450)', () => { - // The digest starts at offset BATCH_BUDGET_FIT_PREFIX.length + 1 + - // FULL_OUTPUT_DIGEST_LABEL.length (= 43), so pre-fix any per-slot - // allocation <= 43 sliced only constant header text: every oversized - // result fingerprinted identically regardless of content, and repeated - // polls of a CHANGING board false-halted on - // consecutive_identical_tool_calls under a small configured - // toolOutputBatchBudget. - const oversizedEntry = (callId: string, board: string) => - entry(callId, [ - { - functionResponse: { - id: callId, - name: 'task_list', - response: { output: `${board}\n${'board line\n'.repeat(300)}` }, - }, - }, - ]); - - const fittedOutput = (entries: ToolResponseBudgetEntry[]) => - entries[0].responseParts[0].functionResponse?.response?.['output']; - - it('fingerprints distinct boards distinctly at allocations below the digest offset', () => { - // Single oversized slot: the whole budget is the slot's allocation. - const boardA = '#1 [in_progress] @peer-a — ship it'; - const boardB = '#2 [completed] @peer-b — totally different board'; - - for (const budget of [21, 40, 43]) { - const fitA = fittedOutput( - enforceFunctionResponseBudget([oversizedEntry('a', boardA)], budget), - ) as string; - const fitB = fittedOutput( - enforceFunctionResponseBudget([oversizedEntry('b', boardB)], budget), - ) as string; - // Degenerate fits return the FULL digest line even when it - // overshoots the allocation: only the exact full line reduces to - // the digest in the loop guards (bounded overshoot). - expect(fitA.length).toBe(FULL_OUTPUT_DIGEST_LABEL.length + 64); - expect(fitA.startsWith(FULL_OUTPUT_DIGEST_LABEL)).toBe(true); - expect(fitA).not.toBe(fitB); - } - }); - - it('keeps the mid band (full digest, sliced header) content-dependent', () => { - // With an artifact note the header outruns the minimal prefix + digest - // line (107 chars), so allocations in [107, header.length) slice the - // header with the FULL digest present — that band must stay - // content-dependent too. - const midBandEntry = (callId: string, board: string) => - entry( - callId, - [ - { - functionResponse: { - id: callId, - name: 'task_list', - response: { output: `${board}\n${'board line\n'.repeat(300)}` }, - }, - }, - ], - [`/tmp/tool-results/${callId}.txt`], - ); - // Same callId for both so the per-call artifact path is identical and - // only the board content (via the digest) can distinguish the fits. - const fitA = fittedOutput( - enforceFunctionResponseBudget( - [midBandEntry('a', '#1 [in_progress] @peer-a')], - 130, - ), - ) as string; - const fitB = fittedOutput( - enforceFunctionResponseBudget( - [midBandEntry('a', '#2 [completed] @peer-b')], - 130, - ), - ) as string; - expect(fitA.startsWith(BATCH_BUDGET_FIT_PREFIX)).toBe(true); - expect(fitA).toContain(FULL_OUTPUT_DIGEST_LABEL); - expect(fitA).not.toBe(fitB); - }); - - it('keeps identical content fingerprint-stable under a degenerate fit', () => { - const board = '#3 [in_progress] @peer-c — frozen board'; - const fitOne = fittedOutput( - enforceFunctionResponseBudget([oversizedEntry('a', board)], 40), - ) as string; - const fitTwo = fittedOutput( - enforceFunctionResponseBudget([oversizedEntry('b', board)], 40), - ) as string; - expect(fitOne).toBe(fitTwo); - }); - - it('does not collapse many distinct boards into one constant text', () => { - // Reviewer witness shape: budget 500 over 12 oversized slots gives - // per-slot allocations of ~41 chars — below the digest offset — which - // pre-fix collapsed every board to the same constant slice. - const entries = Array.from({ length: 12 }, (_, index) => - oversizedEntry(`call-${index}`, `board variant ${index} — distinct`), - ); - const fitted = enforceFunctionResponseBudget(entries, 500).map( - (fittedEntry) => - fittedEntry.responseParts[0].functionResponse?.response?.[ - 'output' - ] as string, - ); - expect(new Set(fitted).size).toBe(12); - }); - - it('fingerprints distinct boards distinctly at sub-label allocations', () => { - // Allocations <= FULL_OUTPUT_DIGEST_LABEL.length (20 chars) sliced - // only constant label text pre-fix (the digest starts AFTER the - // label), so every oversized result fingerprinted identically no - // matter its content — the degenerate band one notch below the band - // the digest-line slice covers (21..107). The band now returns the - // full digest line too, so every non-zero allocation stays - // content-dependent AND reduces to the digest in the loop guards. - const boardA = '#1 [in_progress] @peer-a — ship it'; - const boardB = '#2 [completed] @peer-b — totally different board'; - - for (const budget of [1, 5, 12, 20]) { - const fitA = fittedOutput( - enforceFunctionResponseBudget([oversizedEntry('a', boardA)], budget), - ) as string; - const fitB = fittedOutput( - enforceFunctionResponseBudget([oversizedEntry('b', boardB)], budget), - ) as string; - expect(fitA.length).toBe(FULL_OUTPUT_DIGEST_LABEL.length + 64); - expect(fitA.startsWith(FULL_OUTPUT_DIGEST_LABEL)).toBe(true); - expect(fitA).not.toBe(fitB); - } - }); - - it('does not collapse many distinct boards at the label-length allocation', () => { - // Reviewer witness shape: budget 240 over 12 oversized slots gives - // EXACTLY FULL_OUTPUT_DIGEST_LABEL.length (20) chars per slot — - // pre-fix every fit was the identical constant label text, so - // repeated oversized polls of a CHANGING board fingerprinted - // identically and false-halted on consecutive_identical_tool_calls. - const entries = Array.from({ length: 12 }, (_, index) => - oversizedEntry(`call-${index}`, `board variant ${index} — distinct`), - ); - const fitted = enforceFunctionResponseBudget(entries, 240).map( - (fittedEntry) => - fittedEntry.responseParts[0].functionResponse?.response?.[ - 'output' - ] as string, - ); - expect(new Set(fitted).size).toBe(12); - }); - - it('keeps identical content fingerprint-stable at sub-label allocations', () => { - const board = '#3 [in_progress] @peer-c — frozen board'; - const fitOne = fittedOutput( - enforceFunctionResponseBudget([oversizedEntry('a', board)], 12), - ) as string; - const fitTwo = fittedOutput( - enforceFunctionResponseBudget([oversizedEntry('b', board)], 12), - ) as string; - expect(fitOne).toBe(fitTwo); - }); - - it('keeps every slot content-dependent when the budget is smaller than the slot count', () => { - // Budget 5 over 12 oversized slots: pre-fix the allocator gave 1 - // char to five slots and 0 to the rest, and fitText returns '' for - // a zero allocation — a content-independent constant, so every - // zero-allocation result fingerprinted identically no matter its - // content and a CHANGING board false-halted on the result-aware - // guards. Every active slot must keep >= 1 char even when that - // overshoots a sub-slot-count budget. - const boards = Array.from( - { length: 12 }, - (_, index) => `board variant ${index} — distinct`, - ); - const entries = boards.map((board, index) => - oversizedEntry(`call-${index}`, board), - ); - const fitted = enforceFunctionResponseBudget(entries, 5).map( - (fittedEntry) => - fittedEntry.responseParts[0].functionResponse?.response?.[ - 'output' - ] as string, - ); - expect(fitted.every((text) => text.length >= 1)).toBe(true); - // Content-dependent and guard-reducible from the smallest allocation: - // every degenerate fit is the FULL digest line of its own board's - // full-output digest (bounded overshoot), never the shared '' - // constant and never a digest fragment the loop guards cannot reduce. - fitted.forEach((text, index) => { - expect(text).toBe( - FULL_OUTPUT_DIGEST_LABEL + - createHash('sha256') - .update(`${boards[index]}\n${'board line\n'.repeat(300)}`) - .digest('hex'), - ); - }); - }); - - it('collides with every other representation of identical content across allocations', () => { - // The digest carry-through exists so the same board fingerprints - // identically no matter which representation carries it. Pre-fix the - // sub-84-char allocations emitted a digest FRAGMENT the guards' - // stripPersistenceEnvelope cannot reduce, so a frozen board whose - // per-slot allocation varied with its siblings' lengths (75 vs 76 - // chars, or crossing the full-line boundary as batch composition - // changes) fingerprinted differently every poll despite byte-identical - // content. Every degenerate allocation must now reduce to the same - // guard fingerprint as the raw (under-budget) board text. - const board = '#4 [in_progress] @peer-d — frozen oversized board'; - const text = `${board}\n${'board line\n'.repeat(300)}`; - const guardFingerprint = (output: string) => - fingerprintToolResult([ - { - functionResponse: { - id: 'a', - name: 'task_list', - response: { output }, - }, - }, - ]); - const rawFingerprint = guardFingerprint(text); - expect(rawFingerprint).not.toBeNull(); - for (const budget of [1, 5, 12, 20, 21, 40, 75, 76, 83, 84, 90, 106]) { - const fit = fittedOutput( - enforceFunctionResponseBudget([oversizedEntry('a', board)], budget), - ) as string; - expect(guardFingerprint(fit)).toBe(rawFingerprint); - } - // Same content, two different allocations in the degenerate band: - // the fingerprints must collide (the pre-fix 75-vs-76 divergence). - const fit75 = fittedOutput( - enforceFunctionResponseBudget([oversizedEntry('a', board)], 75), - ) as string; - const fit76 = fittedOutput( - enforceFunctionResponseBudget([oversizedEntry('b', board)], 76), - ) as string; - expect(guardFingerprint(fit75)).toBe(guardFingerprint(fit76)); - }); - }); }); diff --git a/packages/core/src/tools/tool-response-finalizer.ts b/packages/core/src/tools/tool-response-finalizer.ts index 7eef798daad..9909ca6658f 100644 --- a/packages/core/src/tools/tool-response-finalizer.ts +++ b/packages/core/src/tools/tool-response-finalizer.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { createHash } from 'node:crypto'; import type { Part } from '@google/genai'; import type { Config } from '../config/config.js'; import type { ToolArtifact } from './tools.js'; @@ -17,9 +16,6 @@ import { type ToolResultBoundaryStage, } from './tool-result-boundary-diagnostics.js'; import { - extractPersistedStubDigest, - extractStubDigestAt, - FULL_OUTPUT_DIGEST_LABEL, normalizeToolResultCallId, persistAndTruncateToolResult, } from './truncation.js'; @@ -167,19 +163,6 @@ function allocateTextBudget(lengths: number[], budget: number): number[] { const share = Math.floor(remaining / active.length); const fixed = active.filter((index) => lengths[index] <= share); if (fixed.length === 0) { - if (share === 0) { - // Budget smaller than the active-slot count: a zero-char slot fits - // to '' regardless of content, and hashing that constant would - // fingerprint every over-budget result identically — a CHANGING - // board would false-halt on the result-aware guards (issue #9450). - // Keep every active slot at >= 1 char (digest chars, content- - // dependent from the first char) even when that overshoots a - // sub-slot-count budget by less than one char per slot. - for (const index of active) { - allocations[index] = 1; - } - break; - } for (const index of active) { allocations[index] = share; } @@ -224,14 +207,6 @@ function sliceEndWithoutBrokenSurrogate(text: string, length: number): string { return text.slice(start); } -/** - * First line of the header fitText prepends to every batch-budget fit. - * Exported so consumers that parse stubs (the loop guards in - * services/loopDetectionService.ts) recognize the shape with the - * producer's constant instead of a hand-mirrored literal that can drift. - */ -export const BATCH_BUDGET_FIT_PREFIX = 'Tool output truncated.'; - function fitText( text: string, maxChars: number, @@ -240,78 +215,16 @@ function fitText( if (text.length <= maxChars) return text; if (maxChars <= 0) return ''; - // sha256 of the full pre-fit text (FULL_OUTPUT_DIGEST_LABEL). The header - // embeds a per-call artifact path, so hashing the fitted output would - // fingerprint every call uniquely and silently disable the result-aware - // loop guards for exactly these oversized batch-budget results (issue - // #9450). The digest sits right after the constant prefix; when even the - // header does not fit the allocation, the degenerate slice below takes - // the digest line itself so the fitted text stays content-dependent. - // - // Idempotence across nesting: the scheduler persists oversized results - // BEFORE the batch budget runs, so the text fitted here can itself be an - // already-persisted stub whose envelope embeds the per-call unique - // `/.txt` path. Hashing THAT envelope would - // fingerprint every poll of an unchanged board uniquely and disable the - // result-aware guards again (the guards' digest-first reduction would take - // this header's outer digest), so carry the inner stub's own digest - // instead — and likewise the digest of a prior batch-budget fit, whose - // header is per-call unique via its artifact note. The prior-fit digest is - // read at its FIXED header position (the line right after the prefix), - // never by scanning the payload: a quoted stub header inside the fit's - // content would otherwise be adopted as the digest, fingerprinting the fit - // to the quoted hex while content changes below it stay invisible — and - // diverging from the guard, which only recognizes producer-written - // positions (issue #9450). - const headerLabelStart = BATCH_BUDGET_FIT_PREFIX.length + 1; - const digest = - extractPersistedStubDigest(text) ?? - (text.startsWith(BATCH_BUDGET_FIT_PREFIX) && - text[BATCH_BUDGET_FIT_PREFIX.length] === '\n' && - text.startsWith(FULL_OUTPUT_DIGEST_LABEL, headerLabelStart) - ? extractStubDigestAt( - text, - headerLabelStart + FULL_OUTPUT_DIGEST_LABEL.length, - ) - : null) ?? - createHash('sha256').update(text).digest('hex'); - const digestLine = `${FULL_OUTPUT_DIGEST_LABEL}${digest}`; - const artifactNote = + const header = persistedOutputFiles && persistedOutputFiles.length > 0 ? persistedOutputFiles.length === 1 - ? `Persisted tool-output artifact: ${persistedOutputFiles[0]}` - : `Persisted tool-output artifacts:\n${persistedOutputFiles + ? `Tool output truncated. Persisted tool-output artifact: ${persistedOutputFiles[0]}` + : `Tool output truncated. Persisted tool-output artifacts:\n${persistedOutputFiles .map((file) => `- ${file}`) .join('\n')}` - : undefined; - const minimalHeader = `${BATCH_BUDGET_FIT_PREFIX}\n${digestLine}`; - const header = artifactNote - ? `${minimalHeader}\n${artifactNote}` - : minimalHeader; + : 'Tool output truncated.'; if (header.length >= maxChars) { - // Degenerate allocation: the header does not fit whole. As long as the - // allocation holds prefix + digest line, slicing the header keeps the - // full digest (content-dependent). Below that, return the FULL digest - // line even though it overshoots the allocation (bounded by the line's - // own FULL_OUTPUT_DIGEST_LABEL.length + 64 chars — the same deliberate - // overshoot spirit allocateTextBudget applies with its >= 1-char - // allocations). A slice of the digest line instead would be a digest - // FRAGMENT, and the loop guards' stripPersistenceEnvelope reduces only - // the exact full digest line (or the save-failure note) to its digest: - // a fragment fingerprints as ordinary content, so byte-identical - // content would fingerprint differently for every sub-line allocation - // (75 vs 76 chars, or crossing the line length as batch composition - // changes) and never collide with the raw / full-fit / spilled - // representations of the same board — consecutiveIdentical evidence - // could never accumulate for a frozen oversized board under a small - // configured toolOutputBatchBudget, disarming the cap's result-aware - // stuck signal exactly in the small-budget regime (issue #9450). The - // full line makes every degenerate fit reduce to the same digest as - // every other representation of the same content. - if (maxChars >= minimalHeader.length) { - return sliceStartWithoutBrokenSurrogate(header, maxChars); - } - return digestLine; + return sliceStartWithoutBrokenSurrogate(header, maxChars); } const separator = '\n\n'; diff --git a/packages/core/src/tools/truncation.ts b/packages/core/src/tools/truncation.ts index 9c886dd16a6..c89a9127a70 100644 --- a/packages/core/src/tools/truncation.ts +++ b/packages/core/src/tools/truncation.ts @@ -17,7 +17,7 @@ import { ToolOutputTruncatedEvent } from '../telemetry/types.js'; const debugLogger = createDebugLogger('TRUNCATION'); -export const PREVIEW_SIZE_CHARS = 2000; +const PREVIEW_SIZE_CHARS = 2000; const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50MB export const MAX_SESSION_BYTES = 500 * 1024 * 1024; // 500MB @@ -30,146 +30,6 @@ export const MAX_SESSION_BYTES = 500 * 1024 * 1024; // 500MB export const TOOL_OUTPUT_TRUNCATED_PREFIX = 'Tool output was too large and has been truncated'; -/** - * Format markers of the oversized-result stubs this module emits. Exported - * so consumers that parse stubs (the loop guards in - * services/loopDetectionService.ts) share the producer's constants instead - * of hand-mirroring literals that can silently drift. - */ -export const PERSISTED_OUTPUT_OPEN_TAG = ''; -export const OUTPUT_TOO_LARGE_PREFIX = 'Output too large ('; -export const PERSISTED_PREVIEW_MARKER = `Preview (up to ${PREVIEW_SIZE_CHARS} chars):`; -export const TRUNCATED_PART_MARKER = 'Truncated part of the output:\n'; - -/** - * Label of the line `buildStub` embeds carrying a sha256 of the FULL - * pre-truncation output. The preview only covers the first - * PREVIEW_SIZE_CHARS chars, so consumers that fingerprint results (the loop - * guards) preserve this digest to stay sensitive to mutations that land - * beyond the preview window (a task board whose changes sit past char 2000 - * would otherwise fingerprint identically on every poll). - */ -export const FULL_OUTPUT_DIGEST_LABEL = 'Full output sha256: '; - -/** - * Trailing note `truncateAndSaveToFile` appends on its save-failure - * fallback shape (digest label + head/tail payload, no spilled file). - * Exported so consumers that recognize stub shapes (the loop guards, the - * batch-budget finalizer's nesting gate) parse the producer's constant - * instead of a hand-mirrored literal that can drift. - */ -export const TRUNCATION_SAVE_FAILURE_NOTE = - '[Note: Could not save full output to file]'; - -/** - * Extracts a full 64-hex stub digest occupying a FIXED position: the digest - * must span exactly `digestStart` .. `digestStart + 64` and end its line - * (terminator undefined, '\n' or '\r'). Returns null otherwise. Shared - * primitive for the positions the producers are known to write the digest - * at, so the producer-side carry-through and the loop guards' recognition - * parse one grammar and cannot drift (issue #9450). - */ -export function extractStubDigestAt( - value: string, - digestStart: number, -): string | null { - const digest = value.slice(digestStart, digestStart + 64); - const terminator = value[digestStart + 64]; - if ( - /^[0-9a-f]{64}$/.test(digest) && - (terminator === undefined || terminator === '\n' || terminator === '\r') - ) { - return digest; - } - return null; -} - -/** - * Extracts the sha256 digest a stub producer embedded for the FULL - * pre-truncation output, anchored to a producer line: the label must start - * its line and be followed by exactly 64 hex chars ending the line. A - * mid-string mention of the label (e.g. board content quoting a stub) never - * matches. Returns null when no anchored digest is present. Exported so the - * batch-budget finalizer can carry a nested stub's digest through a fit - * (see fitText there) instead of hashing the per-call unique envelope, and - * so the loop guards share this exact recognizer instead of hand-mirroring - * it — the recognition must not drift from the producer constants above - * (issue #9450). - */ -export function extractAnchoredStubDigest(value: string): string | null { - let searchFrom = 0; - for (;;) { - const index = value.indexOf(FULL_OUTPUT_DIGEST_LABEL, searchFrom); - if (index < 0) return null; - const lineAnchored = index === 0 || value[index - 1] === '\n'; - if (lineAnchored) { - const digest = extractStubDigestAt( - value, - index + FULL_OUTPUT_DIGEST_LABEL.length, - ); - if (digest !== null) return digest; - } - searchFrom = index + 1; - } -} - -/** - * Returns the digest of a digest-carrying shape that starts with the digest - * label itself: the batch-budget finalizer's degenerate digest-line-only - * fits (exactly `Full output sha256: <64-hex>`) and the save-failure - * fallback of `truncateAndSaveToFile` (label + head/tail payload + - * save-failure note). Recognition is SHAPE-EXACT and the digest is read at - * the fixed position right after the label — the same grammar the loop - * guards apply (stripPersistenceEnvelope): content merely STARTING with the - * label (a task board or tool result quoting a stub header) carries no - * producer digest and must keep fingerprinting as ordinary content, or the - * two sides of the batch-budget boundary fingerprint the same content - * differently (issue #9450). Returns null for any other text. - */ -export function extractDigestCarryingShapeDigest(text: string): string | null { - if (!text.startsWith(FULL_OUTPUT_DIGEST_LABEL)) return null; - const isDigestCarryingShape = - text.length === FULL_OUTPUT_DIGEST_LABEL.length + 64 || - text.endsWith(TRUNCATION_SAVE_FAILURE_NOTE); - if (!isDigestCarryingShape) return null; - return extractStubDigestAt(text, FULL_OUTPUT_DIGEST_LABEL.length); -} - -/** - * Returns the embedded full-output digest when `text` itself is an - * oversized-result stub produced by this module: a `` - * envelope, an unwrapped `Output too large (...)` stub, a - * `truncateAndSaveToFile` wrapper, or a digest-carrying shape that starts - * with the digest label itself — the save-failure fallback of - * `truncateAndSaveToFile` (label + head/tail payload + save-failure note) - * and the batch-budget finalizer's degenerate digest-line-only fits. - * Returns null for any other text: the label-leading shapes are recognized - * shape-exactly (see extractDigestCarryingShapeDigest), so content that - * merely STARTS with the label — a board quoting a stub header — is not - * treated as a stub and keeps fingerprinting as ordinary content - * (issue #9450). Used by the - * batch-budget finalizer's fitText to make stub reduction idempotent across - * nesting: the scheduler persists oversized results BEFORE the batch budget - * runs, so a fit wrapping an already-persisted stub must carry the stub's - * inner digest into its header instead of hashing the stub envelope, which - * embeds a per-call unique `/.txt` path (or, for - * the label-starting shapes, drops the digest that is the only part of the - * shape that survives a further fit) and would fingerprint every poll of an - * unchanged board uniquely (issue #9450). - */ -export function extractPersistedStubDigest(text: string): string | null { - const isProducerStub = - text.startsWith(PERSISTED_OUTPUT_OPEN_TAG) || - text.startsWith(OUTPUT_TOO_LARGE_PREFIX) || - text.startsWith(TOOL_OUTPUT_TRUNCATED_PREFIX); - if (isProducerStub) return extractAnchoredStubDigest(text); - // Label-leading shapes are recognized shape-exactly: a quoted stub header - // (label + quoted hex + further payload) is content, not a stub, and - // adopting its quoted digest would fingerprint the fit to the quoted hex - // instead of the content (issue #9450). - return extractDigestCarryingShapeDigest(text); -} - /** * Tolerance factor applied by the scheduler's combined (second) pass: * metadata appended after truncation is only re-bounded above 2x the @@ -322,18 +182,13 @@ export async function truncateAndSaveToFile( // Sanitize fileName to prevent path traversal. const safeFileName = `${path.basename(fileName)}.output`; const outputFile = path.join(projectTempDir, safeFileName); - // sha256 of the FULL pre-truncation output (see FULL_OUTPUT_DIGEST_LABEL): - // the head+tail below drops the middle band, so consumers that fingerprint - // results (the loop guards) need the digest to stay sensitive to mutations - // landing in that band (issue #9450). - const fullDigest = crypto.createHash('sha256').update(content).digest('hex'); const wrappedMessage = `${TOOL_OUTPUT_TRUNCATED_PREFIX}. The full output has been saved to: ${outputFile} To read the complete output, use the ${ReadFileTool.Name} tool with the absolute file path above. The truncated output below shows the beginning and end of the content. The marker '... [CONTENT TRUNCATED] ...' indicates where content was removed. -${FULL_OUTPUT_DIGEST_LABEL}${fullDigest} -${TRUNCATED_PART_MARKER}${truncatedContent}`; +Truncated part of the output: +${truncatedContent}`; // Token-aware fallback: if the wrapped (truncated + instructions) output is // not actually smaller than the original, truncating wastes effort and @@ -360,10 +215,9 @@ ${TRUNCATED_PART_MARKER}${truncatedContent}`; `Failed to save truncated output to ${outputFile}:`, error, ); - // Keep the digest even on the unsaved path: the fingerprinting - // consumers must not regress to the head+tail-only payload here. return { - content: `${FULL_OUTPUT_DIGEST_LABEL}${fullDigest}\n${truncatedContent}\n${TRUNCATION_SAVE_FAILURE_NOTE}`, + content: + truncatedContent + `\n[Note: Could not save full output to file]`, }; } } @@ -524,7 +378,7 @@ export async function truncateLlmContent( export function isAlreadyTruncated(content: string): boolean { return ( content.includes('... [CONTENT TRUNCATED] ...') || - content.startsWith(PERSISTED_OUTPUT_OPEN_TAG) + content.startsWith('') ); } @@ -651,41 +505,28 @@ export async function persistAndTruncateToolResult( } } -/** - * Builds the model-visible stub that replaces an oversized tool result. - * Embeds a sha256 of the full `content` (see FULL_OUTPUT_DIGEST_LABEL) so - * result fingerprinting stays faithful to mutations the head-only preview - * cuts off. `filePathOrNote` is either the absolute path of the persisted - * full output (wrapped `` envelope) or a short note - * explaining why it was not persisted (unwrapped stub). Exported so tests - * build stubs with the real producer instead of hand-mirroring its format. - */ -export function buildStub( +function buildStub( content: string, byteSize: number, filePathOrNote: string, ): string { const preview = generatePreview(content); const sizeKb = Math.round(byteSize / 1024); - const digest = crypto.createHash('sha256').update(content).digest('hex'); - const digestLine = `${FULL_OUTPUT_DIGEST_LABEL}${digest}`; const isFilePath = path.isAbsolute(filePathOrNote); if (isFilePath) { - return `${PERSISTED_OUTPUT_OPEN_TAG} -${OUTPUT_TOO_LARGE_PREFIX}${sizeKb} KB). Full output saved to: ${filePathOrNote} + return ` +Output too large (${sizeKb} KB). Full output saved to: ${filePathOrNote} Note: this file may be cleaned up after 24 hours. To read the complete output, use the ${ReadFileTool.Name} tool with the absolute file path above. -${digestLine} -${PERSISTED_PREVIEW_MARKER} +Preview (up to ${PREVIEW_SIZE_CHARS} chars): ${preview} `; } - return `${OUTPUT_TOO_LARGE_PREFIX}${sizeKb} KB). ${filePathOrNote} -${digestLine} + return `Output too large (${sizeKb} KB). ${filePathOrNote} -${PERSISTED_PREVIEW_MARKER} +Preview (up to ${PREVIEW_SIZE_CHARS} chars): ${preview}`; } From f34098e680fa5c2bbbf407ca3f25a404bb09734b Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Wed, 26 Aug 2026 23:39:40 +0800 Subject: [PATCH 50/51] fix(core): count consecutive results for stateful poll loop guards (#9450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The result-aware counting for stateful read tools accumulated turn-wide (call, result fingerprint) pair totals, contradicting the invariant its own comment states: the same call returning changed state is productive and must not accumulate toward either halt. A board oscillating between two byte-identical states repeats each (call, result) pair across the turn, so it halted with GLOBAL_TOOL_CALL_DUPLICATE on the 11th poll with heuristics on, and — under the CLI default (skipLoopDetection) — the pair totals fed capMaxKeyRepeat until the always-on adaptive cap halted with TURN_TOOL_CALL_CAP past the soft cap. Count consecutive identical results per repeat key instead: the count restarts at 1 whenever the result differs from its predecessor, so an oscillating board is changed-state progress on every poll in both modes while a frozen board still accumulates even when interleaved with other calls. Adds regression tests pinning oscillating-board survival in both modes and the interleaved frozen-board halt via the adaptive cap. Co-authored-by: Qwen-Coder --- .../src/services/loopDetectionService.test.ts | 77 +++++++++++++++++++ .../core/src/services/loopDetectionService.ts | 53 ++++++++----- 2 files changed, 112 insertions(+), 18 deletions(-) diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index 09b8eb56619..08f05c82185 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -2805,6 +2805,83 @@ describe('LoopDetectionService', () => { expect(fired).toBe(false); }); + it('does not accumulate an oscillating board toward the global-duplicate halt (heuristics on)', () => { + // A board flipping between two byte-identical states returns a result + // different from its predecessor on EVERY poll — changed-state + // progress — even though each (call, result) pair recurs across the + // turn. Turn-wide pair totals would reach the threshold here; the + // consecutive identical-result count must not. + const heuristicService = new LoopDetectionService( + makeConfig(DEFAULT_MAX_TOOL_CALLS_PER_TURN, false, false), + ); + heuristicService.reset('oscillating-global'); + let detected = false; + for (let i = 0; i < 4 * GLOBAL_DUPLICATE_THRESHOLD && !detected; i++) { + detected = heuristicService.addAndCheck(taskListEvent(`call-${i}`)); + if (detected) break; + detected = heuristicService.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult(i % 2 === 0 ? 'board A' : 'board B'), + ); + } + expect(detected).toBe(false); + expect(loggers.logLoopDetected).not.toHaveBeenCalled(); + }); + + it('keeps an oscillating board alive past the adaptive cap (skipLoopDetection default)', () => { + // CLI default (skipLoopDetection=true): the pair totals previously + // fed capMaxKeyRepeat, so an oscillating board past the 100-call soft + // cap was halted by the always-on adaptive cap. Every poll changing + // the result must keep the stuck signal at bay instead. + const defaultCapService = new LoopDetectionService(makeConfig()); + defaultCapService.reset('oscillating-cap'); + let fired = false; + for (let i = 0; i < DEFAULT_MAX_TOOL_CALLS_PER_TURN + 20; i++) { + fired = defaultCapService.checkAlwaysOnSafeties( + taskListEvent(`call-${i}`), + ); + if (fired) break; + fired = defaultCapService.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult(i % 2 === 0 ? 'board A' : 'board B'), + ); + if (fired) break; + } + expect(fired).toBe(false); + expect(loggers.logLoopDetected).not.toHaveBeenCalled(); + }); + + it('still halts an interleaved frozen board via the adaptive cap', () => { + // The other direction under the CLI default: a genuinely frozen board + // (same result on every poll) interleaved with other calls must still + // build the stuck signal and trip the adaptive cap past the soft cap. + const svc = new LoopDetectionService(makeConfig()); + svc.reset('frozen-cap'); + let fired = false; + for (let i = 0; i < GLOBAL_DUPLICATE_THRESHOLD && !fired; i++) { + fired = svc.checkAlwaysOnSafeties( + createToolCallRequestEvent('filler', { i }), + ); + if (fired) break; + fired = svc.checkAlwaysOnSafeties(taskListEvent(`call-${i}`)); + if (fired) break; + fired = svc.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult('frozen board'), + ); + } + expect(fired).toBe(false); + // Diverse filler calls push the turn past the soft cap; the frozen + // result streak (>= threshold) is the stuck signal that halts it. + for (let i = 0; i < DEFAULT_MAX_TOOL_CALLS_PER_TURN + 20 && !fired; i++) { + fired = svc.checkAlwaysOnSafeties( + createToolCallRequestEvent('filler', { j: i }), + ); + } + expect(fired).toBe(true); + expect(svc.getLastLoopType()).toBe(LoopType.TURN_TOOL_CALL_CAP); + }); + it('restarts the streak when a result changed, then halts on a fresh unchanged streak', () => { const args = TASK_LIST_ARGS; // R1..R4: the board changes once mid-streak (v2), so R5 must NOT halt. diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 6f123ac672a..fc8a8aa65ff 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -261,6 +261,8 @@ export class LoopDetectionService { // skipLoopDetection. capMaxKeyRepeat is the running max count of any single // (tool,args) key this turn — the stuck-repetition signal that decides // whether exceeding the soft cap halts (stuck) or is allowed (productive). + // Stateful read tools feed their consecutive identical-result count instead + // (recordToolResult), so changed-state polling never builds the signal. private capKeyCounts = new Map(); private capMaxKeyRepeat = 0; @@ -279,12 +281,20 @@ export class LoopDetectionService { } >(); - // Turn-wide counts of (repeat key, result fingerprint) pairs for stateful - // read tools, recorded post-execution. Replaces the request-time + // Consecutive identical-result counts per repeat key for stateful read + // tools, recorded post-execution. Replaces the request-time // global-duplicate counting and the cap's stuck-repetition counting for // these tools: the same call returning changed state is productive and - // must not accumulate toward either halt. - private statefulPairCounts = new Map(); + // must not accumulate toward either halt. The count restarts at 1 + // whenever the result differs from the key's predecessor, so a board + // oscillating between two byte-identical states is changed-state progress + // on every poll and never accumulates — even though the same + // (call, result) pair recurs across the turn — while a frozen board keeps + // accumulating even when other calls are interleaved. + private statefulConsecutiveResults = new Map< + string, + { fingerprint: string; count: number } + >(); // callId → request pairing so results can be matched to their calls when // the runtime only has the response (populated on ToolCallRequest events, @@ -391,21 +401,28 @@ export class LoopDetectionService { this.sameNameStreak = 1; } - // Turn-wide (repeat key, fingerprint) counting: replaces the - // request-time global-duplicate and cap stuck-repetition counting for - // stateful tools. - const pairKey = `${key}|${fingerprint}`; - const pairCount = (this.statefulPairCounts.get(pairKey) ?? 0) + 1; - this.statefulPairCounts.set(pairKey, pairCount); - if (pairCount > this.capMaxKeyRepeat) { - this.capMaxKeyRepeat = pairCount; + // Consecutive identical-result counting: replaces the request-time + // global-duplicate and cap stuck-repetition counting for stateful + // tools. The count restarts at 1 whenever the result differs from the + // key's predecessor, so an oscillating board (changed state on every + // poll) never accumulates toward either halt while a frozen board — + // same result on every poll, even interleaved with other calls — does. + const prior = this.statefulConsecutiveResults.get(key); + const consecutiveCount = + prior && prior.fingerprint === fingerprint ? prior.count + 1 : 1; + this.statefulConsecutiveResults.set(key, { + fingerprint, + count: consecutiveCount, + }); + if (consecutiveCount > this.capMaxKeyRepeat) { + this.capMaxKeyRepeat = consecutiveCount; } // The global-duplicate detector is gated (skipLoopDetection) exactly as // its request-time counterpart in addAndCheckHeuristicLoops. if ( !this.config.getSkipLoopDetection() && - pairCount >= GLOBAL_DUPLICATE_THRESHOLD + consecutiveCount >= GLOBAL_DUPLICATE_THRESHOLD ) { this.lastLoopType = LoopType.GLOBAL_TOOL_CALL_DUPLICATE; logLoopDetected( @@ -503,7 +520,7 @@ export class LoopDetectionService { this.trackToolCall(event.value); const toolCallKey = this.getToolCallKey(event.value); // Stateful read tools are counted post-execution in - // recordToolResult, keyed on (call, result fingerprint) instead of + // recordToolResult, on consecutive identical results instead of // args alone (issue #9450). const globalDup = this.isStatefulReadTool(event.value.name) ? false @@ -626,9 +643,9 @@ export class LoopDetectionService { this.capMaxKeyRepeat = 0; // A retry replays the failed attempt's tool calls; drop the stateful // result evidence too so the replayed attempt is judged on its own - // results (pair counts re-accumulate as results land, consistent with - // the capKeyCounts/globalToolCallCounts clears). - this.statefulPairCounts.clear(); + // results (consecutive counts re-accumulate as results land, consistent + // with the capKeyCounts/globalToolCallCounts clears). + this.statefulConsecutiveResults.clear(); for (const state of this.statefulRepeatState.values()) { state.resultsObserved = 0; state.unchangedStreak = 0; @@ -1570,7 +1587,7 @@ export class LoopDetectionService { this.capKeyCounts.clear(); this.capMaxKeyRepeat = 0; this.statefulRepeatState.clear(); - this.statefulPairCounts.clear(); + this.statefulConsecutiveResults.clear(); this.requestByCallId.clear(); } From 4bbf8eabd801010c1a2df4aad5a0bb313c2bd350 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Thu, 27 Aug 2026 00:52:44 +0800 Subject: [PATCH 51/51] fix(core): restore the trimmed verification findings for stateful poll guards (#9450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sandboxed verification of the trimmed PR reported four findings; this restores the three behavior fixes it proved live and pins the four coverage gaps its mutation matrix exposed: - Oversized (persisted) results escaped every result-aware guard: the stub envelope embeds a per-call unique path, so hashing it verbatim fingerprinted uniquely every poll and a frozen board read as "changed every time" (probe P2: no halt in 12 polls vs base halting at 5). buildStub now embeds a sha256 of the full pre-truncation output ("Full output sha256: "), and the guard's fingerprint reduces leading-producer stubs to that digest — with a path-free preview/ truncated-part payload fallback for digest-less stubs and verbatim treatment of non-stub text (a mid-content quoted marker never matches: recognition is prefix-gated and the digest label must start its line). Frozen oversized boards halt at the unchanged threshold regardless of the per-call path; mutations past the preview window stay visible. - Provider-duplicate call ids halted a productive poller fail-safe (probe P3): request counts fed pre-dedup while results land once per deduped executed call. Both reasoning-loop owners now feed the guards one ToolCallRequest per call id per attempt (a per-attempt Set mirroring dedupeToolCallsById; id-less calls never deduped), cleared on retry/fallback — agent-core's subagent stream loop and client.ts's main-session stream. - Coverage pins (finding F4): restored the client.ts recording-wiring tests (frozen halt, changed survival, duplicate-id population parity); added QwenLogger loop_type journal tests, the interactive stop-message attribution test, and the partial-evidence fail-safe fixture. Verification: packages/core tsc clean; loopDetectionService 145/145, agent-headless 68/68, client 370/370, agent-interactive 25/25, qwen-logger 43/43, truncation/shell/finalizer 368/368; prettier/eslint clean on touched files. --- .../core/src/agents/runtime/agent-core.ts | 19 ++ .../src/agents/runtime/agent-headless.test.ts | 98 +++++++++ .../agents/runtime/agent-interactive.test.ts | 49 ++++- packages/core/src/core/client.test.ts | 208 ++++++++++++++++++ packages/core/src/core/client.ts | 30 ++- .../src/services/loopDetectionService.test.ts | 153 +++++++++++++ .../core/src/services/loopDetectionService.ts | 91 +++++++- .../telemetry/qwen-logger/qwen-logger.test.ts | 45 ++++ packages/core/src/tools/truncation.ts | 21 +- 9 files changed, 709 insertions(+), 5 deletions(-) diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 70df0d24b35..0cd00138197 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -975,6 +975,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(); let roundText = ''; let roundThoughtText = ''; let lastUsage: GenerateContentResponseUsageMetadata | undefined = @@ -1012,6 +1019,7 @@ export class AgentCore { stickyMaxOutputTokens = streamEvent.maxOutputTokensEscalated; } functionCalls.length = 0; + loopGuardStreamedCallIds.clear(); roundText = ''; roundThoughtText = ''; lastUsage = undefined; @@ -1093,6 +1101,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, diff --git a/packages/core/src/agents/runtime/agent-headless.test.ts b/packages/core/src/agents/runtime/agent-headless.test.ts index 4f86d9f0991..5b804541d90 100644 --- a/packages/core/src/agents/runtime/agent-headless.test.ts +++ b/packages/core/src/agents/runtime/agent-headless.test.ts @@ -2432,6 +2432,104 @@ describe('subagent.ts', () => { ); }); + 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', diff --git a/packages/core/src/agents/runtime/agent-interactive.test.ts b/packages/core/src/agents/runtime/agent-interactive.test.ts index 2ff6d965bf6..920bf868a41 100644 --- a/packages/core/src/agents/runtime/agent-interactive.test.ts +++ b/packages/core/src/agents/runtime/agent-interactive.test.ts @@ -16,7 +16,8 @@ import type { } from './agent-events.js'; import { ContextState } from './agent-headless.js'; import type { AgentInteractiveConfig } from './agent-types.js'; -import { AgentStatus } from './agent-types.js'; +import { AgentStatus, AgentTerminateMode } from './agent-types.js'; +import { LoopType } from '../../telemetry/types.js'; import { getCurrentAgentDepth, getCurrentAgentId, @@ -33,7 +34,12 @@ function createMockCore( overrides: { chatValue?: unknown; nullChat?: boolean; - loopResult?: { text: string; terminateMode: null; turnsUsed: number }; + loopResult?: { + text: string; + terminateMode: AgentTerminateMode | null; + turnsUsed: number; + loopType?: LoopType; + }; } = {}, ) { const emitter = new AgentEventEmitter(); @@ -324,6 +330,45 @@ describe('AgentInteractive', () => { await agent.shutdown(); }); + it('surfaces the exact loop detector in the interactive stop message (issue #9450)', async () => { + // A loop stop must name its detector (issue #9450 requirement #7): the + // visible info message and lastRoundError both carry the LoopType, so a + // future regression collapsing stops back into the generic label fails + // here instead of shipping unattributable stops. + const { core } = createMockCore({ + loopResult: { + text: '', + terminateMode: AgentTerminateMode.LOOP_DETECTED, + turnsUsed: 3, + loopType: LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, + }, + }); + const agent = new AgentInteractive( + createConfig({ initialTask: 'go' }), + core, + ); + + await agent.start(context); + // A loop-detected round settles the agent as failed (the round error + // path); the stop message must already have been pushed by then. + await vi.waitFor(() => { + expect(['idle', 'failed']).toContain(agent.getStatus()); + }); + await vi.waitFor(() => { + expect(agent.getMessages().some((m) => m.role === 'info')).toBe(true); + }); + + const stopMessages = agent + .getMessages() + .filter((m) => m.role === 'info') + .map((m) => String(m.content)); + expect(stopMessages).toContain( + 'Agent stopped: duplicate tool-call loop detected (consecutive_identical_tool_calls).', + ); + + await agent.shutdown(); + }); + it('should set status to failed when chat creation fails', async () => { const { core } = createMockCore({ nullChat: true }); const config = createConfig(); diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 3f938fe661e..838dfbf20dc 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -8022,6 +8022,214 @@ hello expect(client['pendingMemoryPrefetch']).toBeUndefined(); }); + // Drives sendMessageStream with ToolResult messages whose + // functionResponse ids match previously streamed ToolCallRequest + // callIds, exercising the result-aware recording branch on the main + // interactive path (issue #9450). + async function runTaskListPollTurns( + board: (round: number) => string, + maxRounds = 9, + ) { + const promptId = 'prompt-task-list-poll'; + const taskListArgs = { status: 'in_progress', owner: 'peer-a' }; + const allEvents: Array<{ type: string; value?: unknown }> = []; + for (let round = 0; round <= maxRounds; round++) { + mockTurnRunFn.mockReturnValueOnce( + (async function* () { + yield { + type: GeminiEventType.ToolCallRequest, + value: { + callId: `tl-${round}`, + name: 'task_list', + args: taskListArgs, + isClientInitiated: false, + prompt_id: promptId, + }, + }; + yield { + type: GeminiEventType.ToolCallRequest, + value: { + callId: `other-${round}`, + name: 'tool_b', + args: { step: round }, + isClientInitiated: false, + prompt_id: promptId, + }, + }; + })(), + ); + const contents = + round === 0 + ? [{ text: 'poll the board' }] + : [ + { + functionResponse: { + id: `tl-${round - 1}`, + name: 'task_list', + response: { output: board(round - 1) }, + }, + }, + { + functionResponse: { + id: `other-${round - 1}`, + name: 'tool_b', + response: { output: `step ${round - 1}` }, + }, + }, + ]; + const events = await fromAsync( + client.sendMessageStream( + contents as never, + new AbortController().signal, + promptId, + { + type: + round === 0 + ? SendMessageType.UserQuery + : SendMessageType.ToolResult, + }, + ), + ); + allEvents.push(...(events as Array<{ type: string; value?: unknown }>)); + if ( + allEvents.some((e) => e.type === GeminiEventType.LoopDetected) || + !events.some((e) => e.type === GeminiEventType.ToolCallRequest) + ) { + return allEvents; + } + } + return allEvents; + } + + it('halts the interactive turn when paired ToolResults show a frozen stateful board (#9450)', async () => { + const events = await runTaskListPollTurns(() => 'frozen board'); + const loopEvent = events.find( + (e) => e.type === GeminiEventType.LoopDetected, + ); + expect(loopEvent).toBeDefined(); + expect( + (loopEvent?.value as { loopType?: string } | undefined)?.loopType, + ).toBe('global_tool_call_duplicate'); + }); + + it('keeps the interactive turn alive while paired ToolResults keep changing (#9450)', async () => { + const events = await runTaskListPollTurns((round) => `board v${round}`); + expect(events.some((e) => e.type === GeminiEventType.LoopDetected)).toBe( + false, + ); + }); + + // Variant of runTaskListPollTurns that polls ONLY task_list (no + // interleaved tool), so identical (name, args) build one unbroken + // consecutive streak across rounds. Round 0 streams the same call id + // twice — execution collapses it into one executed call and one + // functionResponse — so request counts and result evidence desync unless + // the loop-guard feed counts one event per call id per attempt + // (issue #9450). + async function runDuplicateIdTaskListPollTurns( + board: (round: number) => string, + maxRounds = 6, + ) { + const promptId = 'prompt-task-list-dup-poll'; + const taskListArgs = { status: 'in_progress', owner: 'peer-a' }; + const allEvents: Array<{ type: string; value?: unknown }> = []; + for (let round = 0; round <= maxRounds; round++) { + const request = (callId: string) => ({ + type: GeminiEventType.ToolCallRequest, + value: { + callId, + name: 'task_list', + args: taskListArgs, + isClientInitiated: false, + prompt_id: promptId, + }, + }); + mockTurnRunFn.mockReturnValueOnce( + (async function* () { + yield request(`tl-${round}`); + if (round === 0) { + // Provider-duplicate emission of the same call id: execution + // collapses it (one functionResponse comes back below), so the + // loop-guard feed must count it once. + yield request(`tl-${round}`); + } + })(), + ); + const contents = + round === 0 + ? [{ text: 'poll the board' }] + : [ + { + functionResponse: { + id: `tl-${round - 1}`, + name: 'task_list', + response: { output: board(round - 1) }, + }, + }, + ]; + const events = await fromAsync( + client.sendMessageStream( + contents as never, + new AbortController().signal, + promptId, + { + type: + round === 0 + ? SendMessageType.UserQuery + : SendMessageType.ToolResult, + }, + ), + ); + allEvents.push(...(events as Array<{ type: string; value?: unknown }>)); + if ( + allEvents.some((e) => e.type === GeminiEventType.LoopDetected) || + !events.some((e) => e.type === GeminiEventType.ToolCallRequest) + ) { + return allEvents; + } + } + return allEvents; + } + + it('counts a provider-duplicate call id once so changed-board polls never halt (#9450)', async () => { + const events = await runDuplicateIdTaskListPollTurns( + (round) => `board v${round}`, + ); + expect(events.some((e) => e.type === GeminiEventType.LoopDetected)).toBe( + false, + ); + // The turn kept polling through every round: 7 rounds, 7 unique call + // ids, 8 streamed events (round 0's id is emitted twice and both + // emissions still reach consumers — only the guard feed is deduped). + // Without the feed dedup the duplicate round-0 emission desyncs the + // request counter one ahead of the result evidence and the guard halts + // the streak mid-poll. + const taskListRequests = events.filter( + (e) => + e.type === GeminiEventType.ToolCallRequest && + (e.value as { name?: string }).name === 'task_list', + ); + expect(taskListRequests).toHaveLength(8); + expect( + new Set( + taskListRequests.map((e) => (e.value as { callId: string }).callId), + ).size, + ).toBe(7); + }); + + it('still halts a frozen board despite the duplicate-call-id feed dedup (#9450)', async () => { + const events = await runDuplicateIdTaskListPollTurns( + () => 'frozen board', + ); + const loopEvent = events.find( + (e) => e.type === GeminiEventType.LoopDetected, + ); + expect(loopEvent).toBeDefined(); + expect( + (loopEvent?.value as { loopType?: string } | undefined)?.loopType, + ).toBe('consecutive_identical_tool_calls'); + }); + it('should halt via the always-on turn cap before the skipLoopDetection gate', async () => { let abortHandlerInvoked = false; mockMemoryManager.recall.mockImplementation((_root, _query, opts) => { diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 2dcea487a62..b1c7e642e62 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -3687,6 +3687,15 @@ export class GeminiClient { const resultStream = turn.run(model, requestToSend, signal); let didUpdateIdeContextState = false; let steerInputSettled = false; + // callIds already fed to the loop guards this attempt. Mirrors the + // execution-side dedup (coreToolScheduler.dedupeRequestsByCallId / the + // interactive duplicate-call-id suppression), which collapses + // provider-duplicate emissions into one executed call and one result: + // feeding the guards once per call id keeps request counts and result + // evidence on the same population (main-session twin of the agent-core + // fix, issue #9450). Id-less requests are never deduped. Cleared on + // retry/fallback alongside the attempt's accumulated state. + const loopGuardFedCallIds = new Set(); try { for await (const event of resultStream) { if (!steerInputSettled) { @@ -3706,6 +3715,7 @@ export class GeminiClient { event.type === GeminiEventType.ModelFallback ) { hasToolCalls = false; + loopGuardFedCallIds.clear(); agentOutput.restartAttempt( event.type === GeminiEventType.Retry && event.isContinuation === true, @@ -3725,11 +3735,28 @@ export class GeminiClient { didUpdateIdeContextState = true; } + // A provider-duplicate emission of an already-fed call id executes + // once (the schedulers collapse it), so feed the loop guards once — + // counting both emissions would leave the request counters one ahead + // of the executed result evidence and fail-safe-halt a productive + // stateful poller (issue #9450). The event itself still flows to + // consumers below; only the guard feed is deduped. + let duplicateLoopGuardRequest = false; + if (event.type === GeminiEventType.ToolCallRequest) { + const fedCallId = event.value.callId; + if (fedCallId) { + duplicateLoopGuardRequest = loopGuardFedCallIds.has(fedCallId); + loopGuardFedCallIds.add(fedCallId); + } + } + // Always-on safety checks (consecutive-identical tool-call guard, // shell inspection stagnation, and per-turn tool-call cap). These fire // before the skipLoopDetection gate so they cannot be bypassed by // configuration. - const alwaysOnLoop = this.loopDetector.checkAlwaysOnSafeties(event); + const alwaysOnLoop = + !duplicateLoopGuardRequest && + this.loopDetector.checkAlwaysOnSafeties(event); if (alwaysOnLoop) { // Drop every tool call collected before the guard fired so the run // halts here instead of spawning a continuation that re-trips it. @@ -3767,6 +3794,7 @@ export class GeminiClient { // relaxes the heuristics (see nonInteractiveCli.ts). const skipLoopDetection = this.config.getSkipLoopDetection(); const heuristicLoop = + !duplicateLoopGuardRequest && !skipLoopDetection && this.loopDetector.addAndCheckHeuristicLoops(event); if (heuristicLoop) { diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index 08f05c82185..05f23479ed5 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -5,6 +5,7 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createHash } from 'node:crypto'; import type { Part } from '@google/genai'; import type { Config } from '../config/config.js'; import type { @@ -19,6 +20,7 @@ import { GeminiEventType } from '../core/turn.js'; import * as loggers from '../telemetry/loggers.js'; import { LoopType } from '../telemetry/types.js'; import type { DebugLogger } from '../utils/debugLogger.js'; +import { FULL_OUTPUT_DIGEST_LABEL } from '../tools/truncation.js'; import { DEFAULT_MAX_TOOL_CALLS_PER_TURN, LoopDetectionService, @@ -2748,6 +2750,28 @@ describe('LoopDetectionService', () => { ); }); + it('still halts when result evidence is only partial (fail-safe)', () => { + // Incomplete evidence (a dropped/failed execution records no result) + // must never grant the result-aware exemption: the guard needs one + // recorded result per preceding request before it trusts a streak. + let fired = false; + for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD; i++) { + fired = service.checkAlwaysOnSafeties(taskListEvent(`call-${i}`)); + if (fired) break; + if (i !== 2) { + // Skip one recording mid-streak: 4 results for 5 requests. + service.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult('frozen board'), + ); + } + } + expect(fired).toBe(true); + expect(service.getLastLoopType()).toBe( + LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, + ); + }); + it('still halts at the threshold when every result is unchanged', () => { const unchanged = '#1 [in_progress] @peer-a — task'; let fired = false; @@ -2882,6 +2906,135 @@ describe('LoopDetectionService', () => { expect(svc.getLastLoopType()).toBe(LoopType.TURN_TOOL_CALL_CAP); }); + describe('oversized (persisted) results fingerprint as stubs (issue #9450)', () => { + // Results over the persistence threshold are rewritten into stubs + // whose envelope embeds a per-call unique path. Hashing the envelope + // verbatim would fingerprint uniquely every poll, silently disabling + // every result-aware guard for exactly the largest results. + const digestOf = (content: string): string => + createHash('sha256').update(content).digest('hex'); + + const persistedStub = ( + boardState: string, + opts: { digest?: string; path?: string } = {}, + ): string => { + const digestLine = + opts.digest !== undefined + ? `\n${FULL_OUTPUT_DIGEST_LABEL}${opts.digest}` + : ''; + return ` +Output too large (42 KB). Full output saved to: ${opts.path ?? '/tool-results/call-x.txt'}${digestLine} +Note: this file may be cleaned up after 24 hours. + +Preview (up to 2000 chars): +${boardState} +`; + }; + + const driveUntilFireOrEnd = ( + results: () => Part[], + rounds: number, + ): boolean => { + let fired = false; + for (let i = 0; i < rounds && !fired; i++) { + fired = service.checkAlwaysOnSafeties(taskListEvent(`call-${i}`)); + if (fired) break; + service.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + results(), + ); + } + return fired; + }; + + it('halts a frozen oversized board despite per-call unique stub paths', () => { + // Same frozen content persisted to a DIFFERENT per-call path each + // poll: the envelope varies, the digest does not, so the guard must + // still see five unchanged results and halt at the same threshold. + let callCounter = 0; + const fired = driveUntilFireOrEnd(() => { + callCounter += 1; + return taskListResult( + persistedStub('frozen oversized board', { + digest: digestOf('frozen oversized board'), + path: `/tool-results/call-${callCounter}.txt`, + }), + `call-${callCounter}`, + ); + }, 8); + expect(fired).toBe(true); + expect(service.getLastLoopType()).toBe( + LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, + ); + }); + + it('keeps an oversized board alive when mutations land beyond the preview window', () => { + // The preview covers only the first chars; the full-output digest is + // what keeps the fingerprint sensitive to mutations past it. + let version = 0; + const fired = driveUntilFireOrEnd(() => { + version += 1; + const content = `board head\n${'x'.repeat(3000)}\ntail v${version}`; + return taskListResult( + persistedStub('board head', { digest: digestOf(content) }), + ); + }, 4 * TOOL_CALL_LOOP_THRESHOLD); + expect(fired).toBe(false); + }); + + it('falls back to the path-free preview for digest-less stubs', () => { + // Stubs produced before the digest line existed: identical previews + // in different envelopes must still collide (halt), changed previews + // must not. + let callCounter = 0; + const frozen = driveUntilFireOrEnd(() => { + callCounter += 1; + return taskListResult( + persistedStub('legacy frozen preview', { + path: `/tool-results/legacy-${callCounter}.txt`, + }), + `call-${callCounter}`, + ); + }, 8); + expect(frozen).toBe(true); + + const svc = new LoopDetectionService(makeConfig()); + svc.reset('legacy-changed'); + let fired = false; + for (let i = 0; i < 4 * TOOL_CALL_LOOP_THRESHOLD && !fired; i++) { + fired = svc.checkAlwaysOnSafeties(taskListEvent(`call-${i}`)); + if (fired) break; + fired = svc.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult( + persistedStub(`legacy preview v${i}`, { + path: `/tool-results/legacy-${i}.txt`, + }), + ), + ); + } + expect(fired).toBe(false); + }); + + it('fingerprints quoted stub markers mid-content as ordinary text', () => { + // Board content can QUOTE a stub (label + hex); only LEADING + // producer shapes are stubs, so two boards differing only in quoted + // content must still count as changed. + const quoted = (hex: string) => + `peer said:\n${FULL_OUTPUT_DIGEST_LABEL}${hex}\nend`; + let fired = false; + for (let i = 0; i < 4 * TOOL_CALL_LOOP_THRESHOLD && !fired; i++) { + fired = service.checkAlwaysOnSafeties(taskListEvent(`call-${i}`)); + if (fired) break; + fired = service.recordToolResult( + { name: 'task_list', args: TASK_LIST_ARGS }, + taskListResult(quoted(digestOf(`content ${i}`))), + ); + } + expect(fired).toBe(false); + }); + }); + it('restarts the streak when a result changed, then halts on a fresh unchanged streak', () => { const args = TASK_LIST_ARGS; // R1..R4: the board changes once mid-streak (v2), so R5 must NOT halt. diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index fc8a8aa65ff..c4662ac291a 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -20,6 +20,11 @@ import { } from '../telemetry/types.js'; import type { Config } from '../config/config.js'; import { getToolCallRepeatKey } from '../tools/tool-call-repeat-key.js'; +import { + FULL_OUTPUT_DIGEST_LABEL, + PREVIEW_SIZE_CHARS, + TOOL_OUTPUT_TRUNCATED_PREFIX, +} from '../tools/truncation.js'; // Re-exported for existing importers (daemon turn-loop guard); the // implementation lives in a leaf module so replay detection in @@ -186,6 +191,82 @@ export function shouldHaltOnTurnToolCallCap( return isExplicitCap || totalCalls > hardCap || stuck; } +// Producer shapes of the oversized-result stubs (see tools/truncation.ts). +// Recognition is anchored on these LEADING prefixes: results like task_list +// embed peer-authored text verbatim, and that text can quote stub markers — +// honoring a marker found mid-string would let quoted content collapse or +// vary the fingerprint, so only shapes that START with a producer prefix are +// treated as stubs (issue #9450). +const STUB_PRODUCER_PREFIXES: readonly string[] = [ + '', + 'Output too large (', + TOOL_OUTPUT_TRUNCATED_PREFIX, +]; + +const STUB_PREVIEW_MARKER = `Preview (up to ${PREVIEW_SIZE_CHARS} chars):`; +const STUB_TRUNCATED_PART_MARKER = 'Truncated part of the output:\n'; + +/** + * Reads the sha256 digest a stub producer embedded for the FULL + * pre-truncation output: the label must START its line and be followed by + * exactly 64 hex chars ending the line. A mid-line mention of the label + * (quoted content) never matches (issue #9450). + */ +function extractAnchoredStubDigest(value: string): string | null { + let searchFrom = 0; + for (;;) { + const index = value.indexOf(FULL_OUTPUT_DIGEST_LABEL, searchFrom); + if (index === -1) return null; + if (index === 0 || value[index - 1] === '\n') { + const digestStart = index + FULL_OUTPUT_DIGEST_LABEL.length; + const digest = value.slice(digestStart, digestStart + 64); + const terminator = value[digestStart + 64]; + if ( + /^[0-9a-f]{64}$/.test(digest) && + (terminator === undefined || terminator === '\n' || terminator === '\r') + ) { + return digest; + } + } + searchFrom = index + FULL_OUTPUT_DIGEST_LABEL.length; + } +} + +/** + * Reduces an oversized-result stub to a stable fingerprint payload. + * Oversized tool results are rewritten into truncation stubs whose envelope + * embeds a per-call unique artifact path (`/.txt`, + * a random temp file); hashing the envelope verbatim would fingerprint + * uniquely every poll — silently disabling every result-aware guard for + * exactly the largest results (a frozen board would read as "changed every + * time", issue #9450). + * + * Prefer the producers' sha256 of the full pre-truncation output + * (FULL_OUTPUT_DIGEST_LABEL): stable across calls for identical content and + * sensitive to mutations anywhere, including beyond the preview window. + * Stubs without a digest line fall back to their path-free visible payload + * (preview, or head+tail after the truncation marker). Non-stub text passes + * through unchanged. + */ +function stripPersistenceEnvelope(value: string): string { + if (!STUB_PRODUCER_PREFIXES.some((prefix) => value.startsWith(prefix))) { + return value; + } + const digest = extractAnchoredStubDigest(value); + if (digest !== null) { + return `sha256:${digest}`; + } + for (const marker of [STUB_PREVIEW_MARKER, STUB_TRUNCATED_PART_MARKER]) { + const payloadStart = value.indexOf(marker); + if (payloadStart !== -1) { + const payload = value.slice(payloadStart + marker.length); + const closeTag = payload.indexOf(''); + return `payload:${closeTag === -1 ? payload : payload.slice(0, closeTag)}`; + } + } + return `raw:${value}`; +} + /** * Service for detecting and preventing infinite loops in AI responses. * Monitors tool call repetitions and content sentence repetitions. @@ -464,6 +545,10 @@ export class LoopDetectionService { /** * Reconstructs the model-visible result text from tool response parts. * Only the fingerprint of this text is retained, never the text itself. + * Oversized results arrive as persistence stubs whose envelope embeds a + * per-call unique file path; each string value is reduced to its stable + * payload first (see stripPersistenceEnvelope) so identical underlying + * results fingerprint identically no matter where they were persisted. * Returns null when the parts carry no functionResponse content. */ private static extractResultText( @@ -473,7 +558,11 @@ export class LoopDetectionService { for (const part of responseParts) { const functionResponse = part.functionResponse; if (!functionResponse) continue; - chunks.push(JSON.stringify(functionResponse.response ?? {})); + chunks.push( + JSON.stringify(functionResponse.response ?? {}, (_key, value) => + typeof value === 'string' ? stripPersistenceEnvelope(value) : value, + ), + ); } return chunks.length > 0 ? chunks.join('\n') : null; } diff --git a/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts index d494f12cf62..bb33ef6d16d 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts @@ -27,6 +27,7 @@ import { SkillLaunchEvent, ProtocolTagSanitizedEvent, RipgrepRuntimeRecoveryEvent, + SubagentExecutionEvent, type ToolCallEvent, } from '../types.js'; import type { RumEvent, RumPayload } from './event-types.js'; @@ -394,6 +395,50 @@ describe('QwenLogger', () => { ); }); + it('journals the loop detector attribution on subagent loop stops', () => { + // A loop stop must stay attributable in the journal (issue #9450 + // requirement #7): dropping the loop_type spread would record the + // stop as an unattributable failure. + const logger = QwenLogger.getInstance(mockConfig)!; + const enqueueSpy = vi.spyOn(logger, 'enqueueLogEvent'); + const event = new SubagentExecutionEvent('worker-a', 'failed', { + terminate_reason: 'loop_detected', + loop_type: 'consecutive_identical_tool_calls', + }); + + logger.logSubagentExecutionEvent(event); + + expect(enqueueSpy).toHaveBeenCalledWith( + expect.objectContaining({ + event_type: 'action', + type: 'tool', + name: 'subagent_execution', + properties: expect.objectContaining({ + subagent_name: 'worker-a', + status: 'failed', + terminate_reason: 'loop_detected', + loop_type: 'consecutive_identical_tool_calls', + }), + }), + ); + }); + + it('omits loop_type from subagent journals when no loop fired', () => { + const logger = QwenLogger.getInstance(mockConfig)!; + const enqueueSpy = vi.spyOn(logger, 'enqueueLogEvent'); + const event = new SubagentExecutionEvent('worker-b', 'completed'); + + logger.logSubagentExecutionEvent(event); + + expect(enqueueSpy).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.not.objectContaining({ + loop_type: expect.anything(), + }), + }), + ); + }); + it('logs protocol tag sanitization without model content', () => { const logger = QwenLogger.getInstance(mockConfig)!; const enqueueSpy = vi.spyOn(logger, 'enqueueLogEvent'); diff --git a/packages/core/src/tools/truncation.ts b/packages/core/src/tools/truncation.ts index c89a9127a70..1057f4ab1ab 100644 --- a/packages/core/src/tools/truncation.ts +++ b/packages/core/src/tools/truncation.ts @@ -17,10 +17,22 @@ import { ToolOutputTruncatedEvent } from '../telemetry/types.js'; const debugLogger = createDebugLogger('TRUNCATION'); -const PREVIEW_SIZE_CHARS = 2000; +export const PREVIEW_SIZE_CHARS = 2000; const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50MB export const MAX_SESSION_BYTES = 500 * 1024 * 1024; // 500MB +/** + * Label of the line `buildStub` embeds carrying a sha256 of the FULL + * pre-truncation output. The preview only covers the first + * PREVIEW_SIZE_CHARS chars and the envelope embeds a per-call unique file + * path, so consumers that fingerprint results (the loop guards in + * services/loopDetectionService.ts) read this digest instead of hashing the + * envelope: a board mutating beyond the preview window still fingerprints + * differently each poll, and a frozen board identically no matter where it + * was persisted (issue #9450). + */ +export const FULL_OUTPUT_DIGEST_LABEL = 'Full output sha256: '; + /** * Stable prefix every truncated tool output starts with. Used as an * idempotency sentinel so content that was already truncated (by a tool's own @@ -513,10 +525,16 @@ function buildStub( const preview = generatePreview(content); const sizeKb = Math.round(byteSize / 1024); const isFilePath = path.isAbsolute(filePathOrNote); + // sha256 of the FULL pre-truncation output (see FULL_OUTPUT_DIGEST_LABEL): + // the envelope's per-call unique path would otherwise fingerprint uniquely + // every poll, silently disabling every result-aware loop guard for exactly + // the largest results (issue #9450). + const fullDigest = crypto.createHash('sha256').update(content).digest('hex'); if (isFilePath) { return ` Output too large (${sizeKb} KB). Full output saved to: ${filePathOrNote} +${FULL_OUTPUT_DIGEST_LABEL}${fullDigest} Note: this file may be cleaned up after 24 hours. To read the complete output, use the ${ReadFileTool.Name} tool with the absolute file path above. @@ -526,6 +544,7 @@ ${preview} } return `Output too large (${sizeKb} KB). ${filePathOrNote} +${FULL_OUTPUT_DIGEST_LABEL}${fullDigest} Preview (up to ${PREVIEW_SIZE_CHARS} chars): ${preview}`;