diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 26861e464e..e10b490c99 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -2063,6 +2063,37 @@ describe('runNonInteractive', () => { ); }); + it('describes a chanting halt as output-or-reasoning repetition', async () => { + setupMetricsMock(); + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.LoopDetected, + value: { loopType: LoopType.CHANTING_IDENTICAL_SENTENCES }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + const exitCode = await runNonInteractive( + mockConfig, + mockSettings, + 'Chant', + 'prompt-id-chanting-loop', + ); + + expect(exitCode).toBe(1); + // Reasoning-stream chants fire CHANTING_IDENTICAL_SENTENCES while + // getResponseText filters reasoning out of visible output, so the + // headless label must name both channels — a halt on an empty stdout + // with an "output"-only label reads as a detector misfire. + expect(processStderrSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'the model repeated the same sentence in its output or reasoning', + ), + ); + }); + it('shows the maxToolCallsPerTurn hint when the per-turn cap halts the run', async () => { setupMetricsMock(); const events: ServerGeminiStreamEvent[] = [ diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index a3b32f6440..75c924f8fb 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -168,8 +168,12 @@ import { const LOOP_TYPE_LABELS: Record = { [LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS]: 'the model repeated the same tool call with identical arguments', + // Reasoning-stream chants fire this type too (checkReasoningContentLoop), + // and getResponseText filters reasoning out of visible output — the label + // must name both channels so a headless halt on an empty stdout is not + // mistaken for a detector misfire. [LoopType.CHANTING_IDENTICAL_SENTENCES]: - 'the model repeated the same sentence in its output', + 'the model repeated the same sentence in its output or reasoning', [LoopType.REPETITIVE_THOUGHTS]: 'the model repeated the same reasoning thought', [LoopType.READ_FILE_LOOP]: diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index a9ffe53ccc..b270e5a736 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -224,8 +224,7 @@ export function extractParentToolNames( new Set( ( generationConfig?.tools as - | Array<{ functionDeclarations?: FunctionDeclaration[] }> - | undefined + Array<{ functionDeclarations?: FunctionDeclaration[] }> | undefined ) ?.flatMap((tool) => tool.functionDeclarations ?? []) .map((declaration) => declaration.name) @@ -970,7 +969,14 @@ export class AgentCore { // retry does not inherit stale data (e.g. wasOutputTruncated) from a // previous attempt that may have hit MAX_TOKENS. if (streamEvent.type === 'retry') { - if (checkSubagentLoop({ type: GeminiEventType.Retry })) { + if ( + checkSubagentLoop({ + type: GeminiEventType.Retry, + ...('isContinuation' in streamEvent + ? { isContinuation: streamEvent.isContinuation } + : {}), + }) + ) { terminateMode = AgentTerminateMode.LOOP_DETECTED; loopDetectedInStream = true; break; @@ -1514,8 +1520,7 @@ export class AgentCore { const registeredTool = this.runtimeContext .getToolRegistry() .getTool(toolName) as - | { serverName?: unknown; serverToolName?: unknown } - | undefined; + { serverName?: unknown; serverToolName?: unknown } | undefined; if ( typeof registeredTool?.serverName !== 'string' || typeof registeredTool.serverToolName !== 'string' diff --git a/packages/core/src/agents/runtime/agent-headless.test.ts b/packages/core/src/agents/runtime/agent-headless.test.ts index e670503194..b39a96f8f5 100644 --- a/packages/core/src/agents/runtime/agent-headless.test.ts +++ b/packages/core/src/agents/runtime/agent-headless.test.ts @@ -30,6 +30,7 @@ import { AuthType, } from '../../core/contentGenerator.js'; import { GeminiChat } from '../../core/geminiChat.js'; +import { GeminiEventType } from '../../core/turn.js'; import { getToolCallFingerprint, normalizeModelToolCallIds, @@ -61,6 +62,7 @@ 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'; vi.mock('../../core/geminiChat.js'); vi.mock('../../core/contentGenerator.js', async (importOriginal) => { @@ -3436,6 +3438,62 @@ describe('subagent.ts', () => { ); }); + it.each([ + { retry: { type: 'retry' as const, isContinuation: true } }, + { retry: { type: 'retry' as const } }, + ])( + 'forwards retry events to subagent loop detection', + async ({ retry }) => { + const loopSpy = vi + .spyOn(LoopDetectionService.prototype, 'addAndCheckHeuristicLoops') + .mockReturnValue(false); + + const { config } = await createMockConfig(); + mockSendMessageStream.mockResolvedValue( + (async function* () { + yield { + ...retry, + }; + yield { + type: 'chunk', + value: { + candidates: [ + { + finishReason: 'STOP', + content: { parts: [{ text: 'done' }] }, + }, + ], + }, + }; + })(), + ); + + const scope = await AgentHeadless.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + defaultRunConfig, + { tools: [] }, + new AgentEventEmitter(), + ); + + await scope.execute(new ContextState()); + + const retryArg = loopSpy.mock.calls.find( + ([event]) => event.type === GeminiEventType.Retry, + )?.[0] as { type: GeminiEventType; isContinuation?: boolean }; + expect(retryArg).toEqual( + expect.objectContaining({ type: GeminiEventType.Retry }), + ); + if ('isContinuation' in retry) { + expect(retryArg.isContinuation).toBe(true); + } else { + expect(retryArg).not.toHaveProperty('isContinuation'); + } + }, + ); + it('keeps automatic max token escalation warm for the next agent round', async () => { const writeFileToolDef: FunctionDeclaration = { name: WriteFileTool.Name, diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index 88db5b72b5..e971609081 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -8,6 +8,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; import type { ServerGeminiContentEvent, + ServerGeminiModelFallbackEvent, + ServerGeminiRetryEvent, ServerGeminiStreamEvent, ServerGeminiThoughtEvent, ServerGeminiToolCallRequestEvent, @@ -15,6 +17,7 @@ import type { 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 { DEFAULT_MAX_TOOL_CALLS_PER_TURN, LoopDetectionService, @@ -38,6 +41,7 @@ const ALTERNATING_PATTERN_CYCLES = 3; describe('LoopDetectionService', () => { let service: LoopDetectionService; let mockConfig: Config; + let mockDebugLogger: DebugLogger; // getMaxToolCallsPerTurn mimics the real Config getter, which always // returns an effective cap (default applied, <= 0 resolved to Infinity). @@ -51,9 +55,17 @@ describe('LoopDetectionService', () => { getTelemetryEnabled: () => true, getMaxToolCallsPerTurn: () => cap, isMaxToolCallsPerTurnExplicit: () => explicit, + getDebugLogger: () => mockDebugLogger, }) as unknown as Config; beforeEach(() => { + mockDebugLogger = { + isEnabled: () => true, + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; mockConfig = makeConfig(); service = new LoopDetectionService(mockConfig); vi.clearAllMocks(); @@ -601,12 +613,17 @@ describe('LoopDetectionService', () => { it('should not detect a loop if repetitions are very far apart', () => { service.reset(''); const repeatedContent = createRepetitiveContent(1, CONTENT_CHUNK_SIZE); - const fillerContent = generateRandomString(500); let isLoop = false; for (let i = 0; i < CONTENT_LOOP_THRESHOLD; i++) { isLoop = service.addAndCheck(createContentEvent(repeatedContent)); - isLoop = service.addAndCheck(createContentEvent(fillerContent)); + // A fresh filler each cycle: repetitions separated by VARYING + // content are not a loop. (Reusing one identical filler made the + // whole stream byte-periodic, which the long-period rule for + // issue #1775 correctly treats as a chant.) + isLoop = service.addAndCheck( + createContentEvent(generateRandomString(500)), + ); } expect(isLoop).toBe(false); expect(loggers.logLoopDetected).not.toHaveBeenCalled(); @@ -1199,6 +1216,646 @@ describe('LoopDetectionService', () => { }); }); + describe('Long verbatim repetition loops (issue #1775)', () => { + // The report shows one multi-sentence analysis block (~300 chars) + // chanted verbatim many times without the turn halting. The repeated + // unit is far longer than the clustered chunk rule's 75-char window, + // and on OpenAI-compatible providers such chants often run in the + // reasoning stream, which only reaches the service as Thought events. + // These tests stream the repeated block with deliberately misaligned + // deltas (a size that does not divide the unit) so no two adjacent + // deltas are identical, matching real token-stream chunking. + const CHANTED_UNIT = + 'The issue might be that the API call is not being made properly ' + + 'when the switch is toggled. Let me make sure the fetchPublicRecipes ' + + 'function is called correctly with the right parameters. The issue ' + + 'might be that the API call is not being made with the correct ' + + 'parameters when the switch is toggled.'; + const DELTA = 17; + + const streamAsMisalignedThoughtDeltas = ( + text: string, + deltaSize = DELTA, + ): boolean => { + let detected = false; + for (let i = 0; i < text.length && !detected; i += deltaSize) { + detected = service.addAndCheck( + createThoughtEvent('', text.slice(i, i + deltaSize)), + ); + } + return detected; + }; + + const streamAsMisalignedContentDeltas = ( + text: string, + deltaSize = DELTA, + ): boolean => { + let detected = false; + for (let i = 0; i < text.length && !detected; i += deltaSize) { + detected = service.addAndCheck( + createContentEvent(text.slice(i, i + deltaSize)), + ); + } + return detected; + }; + + it('unit shape sanity: the chanted block exceeds the cluster window', () => { + expect(CHANTED_UNIT.length % DELTA).not.toBe(0); + expect(CHANTED_UNIT.length).toBeGreaterThan(CONTENT_CHUNK_SIZE * 1.5); + }); + + it('detects the long chant in the reasoning/thought channel', () => { + service.reset(''); + const detected = streamAsMisalignedThoughtDeltas(CHANTED_UNIT.repeat(40)); + expect(detected).toBe(true); + expect(service.getLastLoopType()).toBe( + LoopType.CHANTING_IDENTICAL_SENTENCES, + ); + }); + + it('detects the long chant on the visible content channel', () => { + service.reset(''); + const detected = streamAsMisalignedContentDeltas(CHANTED_UNIT.repeat(40)); + expect(detected).toBe(true); + expect(service.getLastLoopType()).toBe( + LoopType.CHANTING_IDENTICAL_SENTENCES, + ); + }); + + it('detects an even longer (~550-char) repeated unit', () => { + service.reset(''); + // Same symptom class as the follow-up comment on the issue, whose + // repeated block is roughly half a kilobyte. Well inside the history + // window the long-period rule retains (see MAX_HISTORY_LENGTH). + const longUnit = + "Now I'm implementing the fix by modifying the version comparison " + + "logic to use the API's supportedIosVersions field when available, " + + 'falling back to the static table only if the API does not have ' + + 'that information. I realize the core issue: if the device is ' + + 'already on the newest major release and the table claims a lower ' + + 'maximum, the comparison correctly evaluates to false. The real ' + + 'problem is that the static table values are stale and do not ' + + 'match what the API reports, so I need to prioritize the API data.'; + expect(longUnit.length).toBeGreaterThan(500); + expect(longUnit.length % DELTA).not.toBe(0); + + const detected = streamAsMisalignedThoughtDeltas(longUnit.repeat(20)); + expect(detected).toBe(true); + }); + + // Pseudo-random, internally aperiodic units (lowercase only, so no + // markdown-structure delta ever resets tracking) for probing unit + // lengths the original chant block does not cover. + const makeAperiodicUnit = (length: number, seed: number): string => { + let state = Math.imul(seed + 1, 2654435761) >>> 0 || 1; + let out = ''; + while (out.length < length) { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + out += String.fromCharCode(97 + ((state >>> 16) % 26)); + } + return out.slice(0, length); + }; + + // Between the clustered rule's ~75-char bound and the span a fixed + // five-occurrence window can verify (~238 chars), the verified region + // must grow with the occurrence run — a run pinned to the last five + // occurrences left these units permanently undetectable. + it.each([100, 150, 200])( + 'detects a %d-char repeated unit in the mid-length band', + (unitLength) => { + service.reset(''); + const unit = makeAperiodicUnit(unitLength, unitLength); + expect(unit.length % DELTA).not.toBe(0); + + const detected = streamAsMisalignedContentDeltas(unit.repeat(40)); + expect(detected).toBe(true); + expect(service.getLastLoopType()).toBe( + LoopType.CHANTING_IDENTICAL_SENTENCES, + ); + }, + ); + + // Units of ~1 KB or more can never fit five occurrences into the + // retained history window; once the window saturates, the truncated-run + // path must admit them by verifying the whole retained region. + it.each([1000, 1500])( + 'detects a %d-char repeated unit that cannot fit five occurrences in the window', + (unitLength) => { + service.reset(''); + const unit = makeAperiodicUnit(unitLength, unitLength); + expect(unit.length % DELTA).not.toBe(0); + + const detected = streamAsMisalignedContentDeltas(unit.repeat(30)); + expect(detected).toBe(true); + expect(service.getLastLoopType()).toBe( + LoopType.CHANTING_IDENTICAL_SENTENCES, + ); + }, + ); + + it('does not accept a short occurrence run in fresh history', () => { + service.reset(''); + // Three occurrences of a 1000-char unit span only 2050 chars — the + // history has not saturated, so the run cannot have been truncated + // and the short-run path must not admit it. + const unit = makeAperiodicUnit(1000, 7); + const detected = streamAsMisalignedContentDeltas(unit.repeat(3)); + expect(detected).toBe(false); + }); + + it('detects a chant that starts after a long varied turn fills the window', () => { + service.reset(''); + // The realistic #1775 shape: a long varied turn beyond the retained + // window, then the chant starts. Detection must survive + // truncateAndUpdate's index adjustment, and it must happen exactly + // when the fifth in-window occurrence of the unit lands. The two + // bounds pin the window size: a shrunken window (e.g. 2500) cannot + // hold five occurrences of a 700-char unit and instead fires early + // via the truncated-run path as soon as the filler has flushed out + // of the pure-chant window — before the bound below. + let filler = ''; + for (let i = 0; i < 100; i++) { + filler += `Step ${i}: consider aspect ${i * 7 + 3} of the problem. `; + } + expect(filler.length).toBeGreaterThan(2500); + const unit = makeAperiodicUnit(700, 42); + expect(unit.length % DELTA).not.toBe(0); + + expect(streamAsMisalignedContentDeltas(filler)).toBe(false); + + const chant = unit.repeat(20); + let detectedAt = -1; + for (let i = 0; i < chant.length; i += DELTA) { + if ( + service.addAndCheck(createContentEvent(chant.slice(i, i + DELTA))) + ) { + detectedAt = i + DELTA; + break; + } + } + expect(detectedAt).not.toBe(-1); + expect(service.getLastLoopType()).toBe( + LoopType.CHANTING_IDENTICAL_SENTENCES, + ); + // Not before the fifth occurrence can exist (four full units of + // span), and immediately once its final chunk lands (plus a + // one-delta margin for the streaming boundary). + expect(detectedAt).toBeGreaterThan(4 * unit.length); + expect(detectedAt).toBeLessThanOrEqual( + 4 * unit.length + CONTENT_CHUNK_SIZE + DELTA, + ); + }); + + it('does not halt on a long, varied reasoning stream', () => { + service.reset(''); + let text = ''; + for (let i = 0; i < 200; i++) { + text += `Step ${i}: consider aspect ${i * 7 + 3} of the problem. `; + } + expect(streamAsMisalignedThoughtDeltas(text)).toBe(false); + expect(loggers.logLoopDetected).not.toHaveBeenCalled(); + }); + + it('does not halt when identical chunks recur at an even stride but intervening text varies', () => { + service.reset(''); + // A fixed 50-char anchor reappearing every 200 chars with VARYING + // same-length filler between occurrences: equal-stride occurrences + // without a genuinely periodic region must not fire. + const anchor = + 'The quick brown fox jumps over the lazy dog again! '.slice( + 0, + CONTENT_CHUNK_SIZE, + ); + // Pseudo-random, internally aperiodic filler that still has the SAME + // length for every seed, so anchor occurrences stay exactly 200 chars + // apart. (A modular padding like `(seed + k*7) % 26` is periodic with + // period 26 and the existing clustered rule rightly halts on it.) + const filler = (seed: number, length: number): string => { + let state = ((seed + 1) * 2654435761) >>> 0; + let out = `Varying filler number ${seed} `; + while (out.length < length) { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + out += String.fromCharCode(97 + ((state >>> 16) % 26)); + } + return out; + }; + + let text = ''; + for (let i = 0; i < 6; i++) { + text += anchor + filler(i, 150); + } + expect(streamAsMisalignedContentDeltas(text, CONTENT_CHUNK_SIZE)).toBe( + false, + ); + expect(loggers.logLoopDetected).not.toHaveBeenCalled(); + }); + + it('does not halt on fewer than five occurrences of a long unit', () => { + service.reset(''); + // Four full repetitions only yield four equally-spaced occurrences + // of any one chunk — below the long-period threshold. + const detected = streamAsMisalignedContentDeltas(CHANTED_UNIT.repeat(4)); + expect(detected).toBe(false); + }); + + it('detects a visible-content chant after a fenced thought delta', () => { + service.reset(''); + // Reasoning deltas must not drive the content channel's code-block + // state: an unbalanced fence in a thought used to flip the shared + // inCodeBlock parity, which nothing clears mid-turn, silently + // disabling visible-content detection for the rest of the turn. + service.addAndCheck( + createThoughtEvent('', 'Let me look at this snippet:\n```'), + ); + const detected = streamAsMisalignedContentDeltas(CHANTED_UNIT.repeat(40)); + expect(detected).toBe(true); + expect(service.getLastLoopType()).toBe( + LoopType.CHANTING_IDENTICAL_SENTENCES, + ); + }); + + it('detects a reasoning chant whose unit contains markdown list markers', () => { + service.reset(''); + // Chain-of-thought often repeats structured units (checklists, + // steps). Reasoning text is never rendered markdown, so + // list-item-shaped thought deltas must not reset the shared history — + // they used to wipe the accumulated evidence every cycle, making the + // chant undetectable at any length. + const unit = + 'Review the migration plan:\n' + + '- check rollback safety\n' + + '- verify indexes\n' + + '- confirm the cache invalidation path\n'; + expect(unit.length).toBeGreaterThan(CONTENT_CHUNK_SIZE * 1.5); + expect(unit.length % DELTA).not.toBe(0); + + const detected = streamAsMisalignedThoughtDeltas(unit.repeat(60)); + expect(detected).toBe(true); + expect(service.getLastLoopType()).toBe( + LoopType.CHANTING_IDENTICAL_SENTENCES, + ); + }); + }); + + describe('Retry and ModelFallback stream-state resets', () => { + // The #7832 transport-replay gate admits thought-only cuts, so a + // replay retry re-streams the failed attempt's reasoning through the + // chunk detectors. With deterministic decoding the re-stream is + // verbatim; the accumulated identical copies must not read as a chant, + // or a healthy turn halts mid-attempt on a false positive. + const ATTEMPT_DELTA = 17; + + // Deterministic pseudo-random non-repetitive text (LCG over a word + // list): no repeated 50-gram inside one attempt, so a single streamed + // attempt — or a replayed one after a reset — can never fire on its + // own. + const variedText = (len: number, seed: number): string => { + let out = ''; + let x = seed + 1; + const words = [ + 'alpha', + 'bravo', + 'charlie', + 'delta', + 'echo', + 'foxtrot', + 'golf', + 'hotel', + 'india', + 'juliet', + 'kilo', + 'lima', + 'mike', + 'november', + 'oscar', + 'papa', + 'quebec', + 'romeo', + 'sierra', + 'tango', + ]; + while (out.length < len) { + x = (x * 1103515245 + 12345) % 2147483648; + out += words[x % words.length] + String(x % 97) + ' '; + } + return out.slice(0, len); + }; + + const createRetryEvent = ( + isContinuation?: boolean, + ): ServerGeminiRetryEvent => ({ + type: GeminiEventType.Retry, + ...(isContinuation !== undefined && { isContinuation }), + }); + + const createModelFallbackEvent = (): ServerGeminiModelFallbackEvent => ({ + type: GeminiEventType.ModelFallback, + fromModel: 'primary-model', + toModel: 'fallback-model', + fallbackIndex: 1, + }); + + const streamAsThoughts = (text: string): boolean => { + let detected = false; + for (let i = 0; i < text.length && !detected; i += ATTEMPT_DELTA) { + detected = service.addAndCheck( + createThoughtEvent('', text.slice(i, i + ATTEMPT_DELTA)), + ); + } + return detected; + }; + + const streamAsContent = (text: string): boolean => { + let detected = false; + for (let i = 0; i < text.length && !detected; i += ATTEMPT_DELTA) { + detected = service.addAndCheck( + createContentEvent(text.slice(i, i + ATTEMPT_DELTA)), + ); + } + return detected; + }; + + it('does not halt a healthy turn when replay retries re-stream identical reasoning', () => { + service.reset(''); + // The witness shape: a ~1.4 KB reasoning phase cut twice and + // re-streamed byte-identically. Three copies saturate the window; + // without the reset the third (healthy) attempt fires + // CHANTING_IDENTICAL_SENTENCES mid-stream. + const attempt = variedText(1400, 42); + expect(streamAsThoughts(attempt)).toBe(false); + service.addAndCheck(createRetryEvent()); + expect(streamAsThoughts(attempt)).toBe(false); + service.addAndCheck(createRetryEvent()); + expect(streamAsThoughts(attempt)).toBe(false); + expect(service.getLastLoopType()).toBeNull(); + }); + + it('does not halt a healthy turn when replay retries re-stream identical content', () => { + service.reset(''); + const attempt = variedText(1400, 43); + expect(streamAsContent(attempt)).toBe(false); + service.addAndCheck(createRetryEvent()); + expect(streamAsContent(attempt)).toBe(false); + service.addAndCheck(createRetryEvent()); + expect(streamAsContent(attempt)).toBe(false); + expect(service.getLastLoopType()).toBeNull(); + }); + + it('does not halt when rate-limit retries replay five shorter identical copies', () => { + service.reset(''); + // The rate-limit branch replays without a yielded-content guard; five + // ~300-char copies reach the five-occurrence path unsaturated. + const attempt = variedText(300, 7); + for (let copy = 0; copy < 5; copy++) { + if (copy > 0) { + service.addAndCheck(createRetryEvent()); + } + expect(streamAsThoughts(attempt)).toBe(false); + } + expect(service.getLastLoopType()).toBeNull(); + }); + + it('keeps accumulated evidence across a continuation retry', () => { + service.reset(''); + // Continuation recovery (#7832) keeps the delivered text and appends + // genuinely new output — nothing is re-streamed, so the accumulated + // evidence must survive. An uninterrupted chant of this unit fires at + // ~1258 chars; streaming 1192, continuing, then 100 more must fire at + // the same point a continuous stream would. + const unit = variedText(298, 21); + const chant = unit.repeat(6); + expect(streamAsThoughts(chant.slice(0, 1192))).toBe(false); + service.addAndCheck(createRetryEvent(true)); + expect(streamAsThoughts(chant.slice(1192, 1292))).toBe(true); + expect(service.getLastLoopType()).toBe( + LoopType.CHANTING_IDENTICAL_SENTENCES, + ); + }); + + it('drops accumulated evidence on a replay retry at the same point', () => { + service.reset(''); + // Contrast with the continuation test: a replay re-streams from the + // start, so the same partial chant must NOT be one short continuation + // away from firing after it. + const unit = variedText(298, 21); + const chant = unit.repeat(6); + expect(streamAsThoughts(chant.slice(0, 1192))).toBe(false); + service.addAndCheck(createRetryEvent()); + expect(streamAsThoughts(chant.slice(1192, 1292))).toBe(false); + expect(service.getLastLoopType()).toBeNull(); + }); + + it('drops the failed model stream state on ModelFallback', () => { + service.reset(''); + // The fallback model restarts from scratch; with the failed model's + // state retained, its two copies plus two more from the fallback model + // would fire the long-period escape valve mid-way through the fourth + // copy. + const attempt = variedText(1400, 99); + expect(streamAsThoughts(attempt)).toBe(false); + expect(streamAsThoughts(attempt)).toBe(false); + service.addAndCheck(createModelFallbackEvent()); + expect(streamAsThoughts(attempt)).toBe(false); + expect(streamAsThoughts(attempt)).toBe(false); + expect(service.getLastLoopType()).toBeNull(); + }); + + it('still halts a genuine chant after a replay restart', () => { + service.reset(''); + // The reset must not blind the detector: a real chant re-accumulates + // after the restart and still fires. + const unit = variedText(298, 21); + service.addAndCheck(createRetryEvent()); + expect(streamAsThoughts(unit.repeat(40))).toBe(true); + expect(service.getLastLoopType()).toBe( + LoopType.CHANTING_IDENTICAL_SENTENCES, + ); + }); + }); + + describe('Truncation hysteresis', () => { + // The physical trim walks the whole contentStats map, which at + // saturation holds one entry per window position — Θ(window) + // synchronous CPU per streamed event. The trim now runs with hysteresis + // (a TRUNCATION_SLACK margin), and these tests pin that the change is + // behavior-neutral: the fire offsets below were recorded on the + // pre-hysteresis implementation and must not drift. + const MAX_HISTORY_LENGTH = 4000; + const TRUNCATION_SLACK = 1000; + const DELTA = 17; + + const variedText = (len: number, seed: number): string => { + let out = ''; + let x = seed + 1; + const words = [ + 'alpha', + 'bravo', + 'charlie', + 'delta', + 'echo', + 'foxtrot', + 'golf', + 'hotel', + 'india', + 'juliet', + 'kilo', + 'lima', + 'mike', + 'november', + 'oscar', + 'papa', + 'quebec', + 'romeo', + 'sierra', + 'tango', + ]; + while (out.length < len) { + x = (x * 1103515245 + 12345) % 2147483648; + out += words[x % words.length] + String(x % 97) + ' '; + } + return out.slice(0, len); + }; + + const U300 = + 'The issue might be that the API call is not being made properly ' + + 'when the switch is toggled. Let me make sure the fetchPublicRecipes ' + + 'function is called correctly with the right parameters. The issue ' + + 'might be that the API call is not being made with the correct ' + + 'parameters when the switch is toggled.'; + const U1200 = variedText(1200, 7); + const U1350 = variedText(1350, 9); + + const historyLength = (): number => + (service as unknown as { streamContentHistory: string }) + .streamContentHistory.length; + + // Streams as Content deltas of DELTA chars and returns the number of + // chars streamed when detection fired (-1 when it never fired). + const fireOffset = (text: string): number => { + service.reset(''); + let streamed = 0; + for (let i = 0; i < text.length; i += DELTA) { + const piece = text.slice(i, i + DELTA); + streamed += piece.length; + if ( + service.addAndCheck({ + type: GeminiEventType.Content, + value: piece, + }) + ) { + return streamed; + } + } + return -1; + }; + + it('unit shape sanity', () => { + expect(U300.length).toBe(298); + expect(U1200.length).toBe(1200); + expect(U1350.length).toBe(1350); + }); + + it('defers physical truncation until the slack margin, then trims to the window', () => { + service.reset(''); + const text = variedText(5100, 1); + // Delta-aligned stream positions: one inside the slack band (past + // the window, before the margin) and the first event crossing it. + const insideSlackBand = + DELTA * Math.floor((MAX_HISTORY_LENGTH + TRUNCATION_SLACK / 2) / DELTA); + const trimPoint = + DELTA * Math.ceil((MAX_HISTORY_LENGTH + TRUNCATION_SLACK + 1) / DELTA); + let streamed = 0; + for (let i = 0; i < text.length; i += DELTA) { + const piece = text.slice(i, i + DELTA); + streamed += piece.length; + expect( + service.addAndCheck({ type: GeminiEventType.Content, value: piece }), + ).toBe(false); + if (streamed === insideSlackBand) { + // Past the window, inside the slack band: no physical trim yet — + // the per-event trim would have pinned the length to the window. + expect(historyLength()).toBe(insideSlackBand); + } + if (streamed === trimPoint) { + // Crossing the margin trims back to exactly the window. + expect(historyLength()).toBe(MAX_HISTORY_LENGTH); + } + } + expect(historyLength()).toBeLessThanOrEqual( + MAX_HISTORY_LENGTH + TRUNCATION_SLACK, + ); + }); + + it('keeps detection fire offsets identical to the pre-hysteresis baseline', () => { + // Recorded on the per-event-trim implementation. Shapes chosen to + // fire before saturation (S1), right at it (S2, S4, S7), and after + // several physical trims with a non-periodic prefix still inside the + // slack band (S3, S5, S6) — the cases where a lazy trim could change + // what the escape valve and occurrence runs see. + expect(fireOffset(U300.repeat(60))).toBe(1258); + expect(fireOffset(U1200.repeat(12))).toBe(4012); + expect(fireOffset(variedText(3000, 3) + U1200.repeat(12))).toBe(7004); + expect(fireOffset(U1350.repeat(12))).toBe(4012); + expect(fireOffset(variedText(4500, 5) + U300.repeat(60))).toBe(5763); + expect(fireOffset(variedText(200, 11) + U1200.repeat(12))).toBe(4216); + expect(fireOffset(U1200.repeat(4))).toBe(4012); + }); + + it('never fires on a long varied stream across many trims', () => { + expect(fireOffset(variedText(30000, 13))).toBe(-1); + }); + }); + + describe('Chanting halt debug-log excerpt', () => { + // A reasoning-channel halt exits headless runs with empty stdout and a + // label-only stderr; the excerpt debug log is the artifact that tells a + // true repetition from a misfire. Kept out of the LoopDetected event + // payload on purpose (the event contract stays loop_type + prompt_id). + const DELTA = 17; + + const unit = + 'The issue might be that the API call is not being made properly ' + + 'when the switch is toggled. Let me make sure the fetchPublicRecipes ' + + 'function is called correctly with the right parameters. The issue ' + + 'might be that the API call is not being made with the correct ' + + 'parameters when the switch is toggled.'; + + const streamAsThoughts = (text: string): boolean => { + let detected = false; + for (let i = 0; i < text.length && !detected; i += DELTA) { + detected = service.addAndCheck( + createThoughtEvent('', text.slice(i, i + DELTA)), + ); + } + return detected; + }; + + it('logs a short excerpt of one period of the repeated region', () => { + service.reset(''); + expect(streamAsThoughts(unit.repeat(40))).toBe(true); + + const debug = vi.mocked(mockDebugLogger.debug); + expect(debug).toHaveBeenCalledTimes(1); + const message = String(debug.mock.calls[0]?.[0]); + expect(message).toContain(LoopType.CHANTING_IDENTICAL_SENTENCES); + const match = /excerpt \((\d+) chars\): (.*)$/.exec(message); + expect(match).not.toBeNull(); + const excerpt = JSON.parse(String(match?.[2])) as string; + expect(excerpt.length).toBeGreaterThan(0); + expect(excerpt.length).toBeLessThanOrEqual(80); + expect(Number(match?.[1])).toBe(excerpt.length); + // The excerpt is one period of the chant: it must reappear verbatim + // in the repeated unit (allowing a wrap across the unit boundary). + expect((unit + unit).includes(excerpt)).toBe(true); + }); + + it('does not log an excerpt when nothing fires', () => { + service.reset(''); + expect(streamAsThoughts(unit.slice(0, 500))).toBe(false); + expect(vi.mocked(mockDebugLogger.debug)).not.toHaveBeenCalled(); + }); + }); + describe('Read File Loop Detection', () => { // Cold-start exemption: a prompt that has not yet fired any non-read-like // tool is still in its opening-exploration phase, so the detector gives diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index a396a4c89a..03b84b039f 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -35,7 +35,54 @@ export { getToolCallRepeatKey }; const TOOL_CALL_LOOP_THRESHOLD = 5; const CONTENT_LOOP_THRESHOLD = 10; const CONTENT_CHUNK_SIZE = 50; -const MAX_HISTORY_LENGTH = 1000; +// Cap for the debug-log excerpt of a fired chanting region (~one period, +// see captureChantExcerpt). +const CHANT_EXCERPT_MAX_LENGTH = 80; +// Kept large enough that the long-period rule below can still see +// PERIODIC_OCCURRENCES_REQUIRED occurrences of a long (~700 char) repeated +// unit after truncation. The window also bounds the detectable unit length: +// a retained window holds at most floor((MAX_HISTORY_LENGTH - +// CONTENT_CHUNK_SIZE) / unitLength) + 1 occurrences of any one gram, which +// drops below PERIODIC_OCCURRENCES_REQUIRED for units of ~1 KB — the +// truncated-run path in isPeriodicChunkRepetition admits those once the +// history saturates. Units longer than ~MAX_HISTORY_LENGTH / 2 cannot +// accumulate even three in-window occurrences and remain out of reach. +const MAX_HISTORY_LENGTH = 4000; + +// Truncation hysteresis slack. Once the history saturates, the physical +// trim (which walks the whole contentStats map to re-base stored indices) +// runs only when the length exceeds MAX_HISTORY_LENGTH by this margin, +// slicing back to exactly MAX_HISTORY_LENGTH — amortizing the walk over +// ~TRUNCATION_SLACK appended chars instead of paying it on every streamed +// event. Purely a memory/mechanics optimization: the detection logic always +// operates on the logical window of the last MAX_HISTORY_LENGTH chars (see +// windowStart), so detection decisions are identical regardless of how +// rarely the physical trim runs. Peak memory is bounded at +// MAX_HISTORY_LENGTH + TRUNCATION_SLACK chars. +const TRUNCATION_SLACK = 1000; + +// Long-period verbatim repetition detection (issue #1775). A unit repeated +// verbatim spaces identical CONTENT_CHUNK_SIZE-grams exactly one unit-length +// apart, which the clustered rule cannot see: its average-distance bound +// (1.5 * CONTENT_CHUNK_SIZE) only admits repeat units up to ~75 chars, so a +// chanted multi-sentence block (~300 chars in the report) spins forever. +// Instead, require a run of occurrences at exactly equal spacing and then +// verify the spanned region is genuinely periodic with that stride. +const PERIODIC_OCCURRENCES_REQUIRED = 5; +// A run whose earlier occurrences may have been truncated away (see +// isPeriodicChunkRepetition) must still span at least this many equally +// spaced occurrences before the whole retained region is verified periodic +// to compensate for the weaker occurrence evidence. +const PERIODIC_MIN_TRUNCATED_OCCURRENCES = 3; +// The verified periodic span must be substantial before halting a turn. The +// verified region grows with the occurrence run (see +// isPeriodicChunkRepetition), so mid-length units (~76-237 chars) cross the +// floor after a handful of extra repetitions and long units cross it almost +// immediately. The floor keeps a small number of short-period repetitions +// (e.g. a phrase emitted a handful of times before a markdown reset) out of +// the long-period path while still firing early for long repeated units +// (~5th repetition for the ~300-char block in the report). +const MIN_PERIODIC_REGION_LENGTH = 1000; // Thought tracking const THOUGHT_REPEAT_THRESHOLD = 3; @@ -206,6 +253,14 @@ export class LoopDetectionService { // the user which detector actually fired. private lastLoopType: LoopType | null = null; + // Short excerpt of the repeated region captured when the chanting + // detector fires, for debug logging only. Deliberately NOT part of the + // LoopDetected event payload: the event contract stays loop_type-only and + // the excerpt rides the debug log instead, so a headless reasoning-channel + // halt (empty stdout, label-only stderr) leaves an artifact that tells a + // true repetition from a misfire. + private lastChantExcerpt = ''; + constructor(config: Config) { this.config = config; } @@ -293,6 +348,34 @@ export class LoopDetectionService { // streak reset). this.globalToolCallCounts.clear(); this.recentToolCallKeys = []; + // 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 + // deterministic decoding the re-stream is verbatim, so the + // accumulated identical copies would fire + // CHANTING_IDENTICAL_SENTENCES mid-way through an otherwise healthy + // attempt. Reset the stream state the replay duplicates. A + // continuation retry (isContinuation) keeps the delivered text and + // appends genuinely new output — nothing is re-streamed, so its + // state must stay. A genuine chant simply re-accumulates after the + // restart. + if (!event.isContinuation) { + this.resetContentTracking(); + this.thoughtHistory = []; + } + break; + } + case GeminiEventType.ModelFallback: { + // The fallback model restarts the attempt from scratch: Turn clears + // pending tool calls and stream consumers discard the failed model's + // buffer, so the failed model's streamed content/thought text and + // tool-call keys would otherwise mix with the new model's stream and + // manufacture repetition runs across the boundary. Mirror the + // replay-retry resets. + this.globalToolCallCounts.clear(); + this.recentToolCallKeys = []; + this.resetContentTracking(); + this.thoughtHistory = []; break; } case GeminiEventType.Content: { @@ -302,6 +385,23 @@ export class LoopDetectionService { case GeminiEventType.Thought: { this.trackThought(event.value); this.loopDetected = this.checkRepetitiveThoughts(); + if (!this.loopDetected) { + // Also route the thought text into the content-repetition + // detector. OpenAI-compatible providers stream reasoning as + // thought parts, which getResponseText filters out of Content + // events, so a verbatim chant in the thinking stage never reaches + // the chunk-hash detectors otherwise. The Thought-only check above + // compares whole stream deltas adjacently, which misaligned + // chunking defeats — the chunk-hash detectors accumulate the text + // across deltas and catch the repetition regardless of chunk + // boundaries (issues #9656, #1775). Reasoning text enters through + // checkReasoningContentLoop so it can never drive the + // markdown/code-block machinery that guards the visible channel. + const thoughtText = this.getThoughtText(event.value); + if (thoughtText) { + this.loopDetected = this.checkReasoningContentLoop(thoughtText); + } + } break; } default: @@ -576,18 +676,68 @@ export class LoopDetectionService { return false; } + return this.appendToContentHistoryAndAnalyze(content); + } + + /** + * Entry point for reasoning-stream deltas into the content-repetition + * detector. Reasoning text is raw chain-of-thought, never rendered + * markdown, so it must skip checkContentLoop's structure heuristics: an + * odd number of code fences in a thought would flip the shared + * `inCodeBlock` parity — which nothing clears mid-turn — and silently + * disable visible-content detection for the rest of the turn, and a + * list-item or heading-shaped thought delta would reset the shared + * history, erasing already-accumulated content evidence whenever a + * provider interleaves thought and content parts. Reasoning deltas are + * appended to the shared history and analyzed only. + */ + private checkReasoningContentLoop(content: string): boolean { + return this.appendToContentHistoryAndAnalyze(content); + } + + /** + * Shared append/truncate/analyze tail behind checkContentLoop and + * checkReasoningContentLoop, so the history contract lives in one copy: + * a future change to the sequence (normalising before append, an extra + * reset, different truncation handling) applies to both channels instead + * of silently leaving the reasoning path on the old behaviour. + */ + private appendToContentHistoryAndAnalyze(content: string): boolean { this.streamContentHistory += content; this.truncateAndUpdate(); return this.analyzeContentChunksForLoop(); } + /** + * Start of the logical detection window inside streamContentHistory: the + * detection rules may only see the last MAX_HISTORY_LENGTH chars, even + * while the physical trim's hysteresis (see truncateAndUpdate) lets the + * buffer temporarily hold up to MAX_HISTORY_LENGTH + TRUNCATION_SLACK. + * Everything before this offset is treated as already truncated away. + */ + private windowStart(): number { + return Math.max(0, this.streamContentHistory.length - MAX_HISTORY_LENGTH); + } + /** * Truncates the content history to prevent unbounded memory growth. * When truncating, adjusts all stored indices to maintain their relative positions. + * + * Runs with hysteresis: once saturated, trims only when the length + * exceeds the window by TRUNCATION_SLACK, then slices back to exactly + * MAX_HISTORY_LENGTH. The index-rebase walk below is Θ(map size), and at + * saturation the stride-1 sliding window keeps one entry per position, so + * running it per streamed event cost Θ(window) synchronous CPU on the + * token-streaming path; the slack amortizes it over appended chars. + * Detection semantics are unaffected: the rules only ever see the logical + * window (windowStart), which is identical with or without the slack. */ private truncateAndUpdate(): void { - if (this.streamContentHistory.length <= MAX_HISTORY_LENGTH) { + if ( + this.streamContentHistory.length <= + MAX_HISTORY_LENGTH + TRUNCATION_SLACK + ) { return; } @@ -642,6 +792,20 @@ export class LoopDetectionService { this.promptId, ), ); + // The LoopDetected event carries only loop_type + prompt_id, and a + // reasoning-channel halt prints nothing to stdout — without an + // artifact there is no way to tell a true repetition from a + // detector misfire. Log one period of the matched region instead of + // widening the event contract. + if (this.lastChantExcerpt) { + this.config + .getDebugLogger() + .debug( + `Loop detection halted on ${LoopType.CHANTING_IDENTICAL_SENTENCES}; ` + + `repeated region excerpt (${this.lastChantExcerpt.length} chars): ` + + JSON.stringify(this.lastChantExcerpt), + ); + } return true; } @@ -670,9 +834,29 @@ export class LoopDetectionService { * within a small average distance (≤ 1.5 * chunk size) */ private isLoopDetectedForChunk(chunk: string, hash: string): boolean { - const existingIndices = this.contentStats.get(hash); + let existingIndices = this.contentStats.get(hash); - if (!existingIndices) { + if (existingIndices) { + // The physical truncation runs with hysteresis, so occurrences the + // logical window has already passed can linger in the map between + // trims. Drop them here — exactly the set a per-event truncation + // would have removed — so detection decisions never depend on how + // rarely the physical trim runs. + const start = this.windowStart(); + if (existingIndices[0] < start) { + let firstKept = 1; + while ( + firstKept < existingIndices.length && + existingIndices[firstKept] < start + ) { + firstKept++; + } + existingIndices = existingIndices.slice(firstKept); + this.contentStats.set(hash, existingIndices); + } + } + + if (!existingIndices || existingIndices.length === 0) { this.contentStats.set(hash, [this.lastContentIndex]); return false; } @@ -683,12 +867,46 @@ export class LoopDetectionService { existingIndices.push(this.lastContentIndex); - if (existingIndices.length < CONTENT_LOOP_THRESHOLD) { + if ( + this.isClusteredChunkRepetition(existingIndices) || + this.isPeriodicChunkRepetition(existingIndices) + ) { + this.lastChantExcerpt = this.captureChantExcerpt(existingIndices); + return true; + } + return false; + } + + /** + * One period of the matched repetition for debug logging: the span + * between the last two occurrences (exactly one stride for a verified + * periodic run), capped so the log line stays short. + */ + private captureChantExcerpt(occurrences: number[]): string { + const start = occurrences[occurrences.length - 2]; + const stride = occurrences[occurrences.length - 1] - start; + if (stride <= 0) { + return ''; + } + return this.streamContentHistory.slice( + start, + start + Math.min(stride, CHANT_EXCERPT_MAX_LENGTH), + ); + } + + /** + * The original chunk rule: the most recent CONTENT_LOOP_THRESHOLD + * occurrences of an identical chunk cluster within 1.5 chunk lengths. + * Only admits repeat units up to ~75 chars (see isPeriodicChunkRepetition + * for longer ones). + */ + private isClusteredChunkRepetition(indices: number[]): boolean { + if (indices.length < CONTENT_LOOP_THRESHOLD) { return false; } // Analyze the most recent occurrences to see if they're clustered closely together - const recentIndices = existingIndices.slice(-CONTENT_LOOP_THRESHOLD); + const recentIndices = indices.slice(-CONTENT_LOOP_THRESHOLD); const totalDistance = recentIndices[recentIndices.length - 1] - recentIndices[0]; const averageDistance = totalDistance / (CONTENT_LOOP_THRESHOLD - 1); @@ -697,6 +915,103 @@ export class LoopDetectionService { return averageDistance <= maxAllowedDistance; } + /** + * Detects verbatim repetition of a long unit (issue #1775): a chant whose + * repeated block exceeds the clustered rule's 75-char window, such as the + * ~300-char analysis block looped in the report. A unit repeated verbatim + * re-emits each of its CONTENT_CHUNK_SIZE-grams at exactly one unit-length + * of spacing, so a run of equally-spaced occurrences marks a candidate + * period. Equal spacing alone could still interleave varying text between + * occurrences, so the spanned region is additionally verified to be + * exactly periodic with that stride before firing. + * + * The candidate run is the longest equally-spaced suffix of the recorded + * occurrences, not just the last PERIODIC_OCCURRENCES_REQUIRED: the + * verified region grows with the repetition count, which admits units + * between the clustered rule's ~75-char bound and the span a fixed + * 5-occurrence window can verify (e.g. a 150-char unit crosses + * MIN_PERIODIC_REGION_LENGTH at its 8th occurrence). + * + * Once the history saturates, earlier occurrences can be truncated away, + * so a shorter run (>= PERIODIC_MIN_TRUNCATED_OCCURRENCES) is accepted + * when the whole retained region — back to the start of the logical + * window (windowStart), i.e. exactly the content a fully-trimmed history + * retains — is verified periodic with the candidate stride. Without that + * escape valve, units of ~1 KB or more could never accumulate + * PERIODIC_OCCURRENCES_REQUIRED occurrences inside the window and a + * full-paragraph chant would spin the turn forever. + */ + private isPeriodicChunkRepetition(indices: number[]): boolean { + if (indices.length < PERIODIC_MIN_TRUNCATED_OCCURRENCES) { + return false; + } + + const last = indices.length - 1; + const stride = indices[last] - indices[last - 1]; + + // Extend the run backwards over the longest equally-spaced suffix so + // the verified region grows with the repetition count. + let first = last; + while (first > 0 && indices[first] - indices[first - 1] === stride) { + first--; + } + const runLength = last - first + 1; + + if (runLength >= PERIODIC_OCCURRENCES_REQUIRED) { + return this.isRegionPeriodicWithStride( + indices[first], + indices[last] + CONTENT_CHUNK_SIZE, + stride, + ); + } + + // The run may have been truncated by the history window. Accept it only + // when the history actually saturated and the entire retained region is + // periodic with the candidate stride, so a short run of occurrences in + // fresh (untruncated) history still needs the full occurrence count. + if ( + runLength >= PERIODIC_MIN_TRUNCATED_OCCURRENCES && + this.streamContentHistory.length >= MAX_HISTORY_LENGTH + ) { + return this.isRegionPeriodicWithStride( + this.windowStart(), + indices[last] + CONTENT_CHUNK_SIZE, + stride, + ); + } + + return false; + } + + /** + * Verifies that streamContentHistory[start, end) is exactly periodic with + * the given stride and spans at least MIN_PERIODIC_REGION_LENGTH chars. + */ + private isRegionPeriodicWithStride( + start: number, + end: number, + stride: number, + ): boolean { + const regionLength = end - start; + if (regionLength < MIN_PERIODIC_REGION_LENGTH) { + return false; + } + // Compare in place instead of slicing the region out: near-periodic + // chants (the target input class) fail verification repeatedly while + // their equally-spaced occurrence runs persist, so once a run reaches + // length 5 this check can fire on up to every streamed character, and + // a slice would copy up to ~4 KB of history per call. + for (let i = 0; i + stride < regionLength; i++) { + if ( + this.streamContentHistory[start + i] !== + this.streamContentHistory[start + i + stride] + ) { + return false; + } + } + return true; + } + /** * Verifies that two chunks with the same hash actually contain identical content. * This prevents false positives from hash collisions. @@ -712,6 +1027,18 @@ export class LoopDetectionService { return originalChunk === currentChunk; } + /** + * Joins a thought summary back into raw text for the content-repetition + * detector. For reasoning streamed from OpenAI-compatible providers the + * subject is empty and the description is the reasoning delta, so this + * yields the reasoning text verbatim. + */ + private getThoughtText(summary: ThoughtSummary): string { + return [summary.subject, summary.description] + .filter((part) => part.length > 0) + .join(' '); + } + /** * Records a structured thought summary for repetition detection. Uses both * subject and description so two thoughts with the same subject but @@ -982,6 +1309,7 @@ export class LoopDetectionService { this.resetToolCallCount(); this.resetContentTracking(); this.loopDetected = false; + this.lastChantExcerpt = ''; // Reset new tracking variables this.thoughtHistory = [];