mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-25 08:33:55 +00:00
fix(core): detect long verbatim repetition loops in content and reasoning streams (#9668)
* fix(core): detect long verbatim repetition loops in content and reasoning streams The chunk-hash content loop rule only treats repeated 50-char chunks as a loop when their occurrences cluster within 1.5 chunk lengths (75 chars), so a verbatim-repeated unit longer than that (the ~300-char analysis block chanted in issue #1775) never fires. Add a long-period rule: five equally spaced occurrences of an identical chunk mark a candidate period, and the spanned region is verified to be exactly periodic with that stride before halting. Raise the content history window so long units stay observable. Also route thought text into the content-repetition detectors when the structured thought check does not fire: OpenAI-compatible providers stream reasoning as thought parts that getResponseText filters out of Content events, so chants in the thinking stage never reached the chunk-hash rules. * fix(core): isolate reasoning deltas from the content channel's markdown state Route thought-sourced text through an append-and-analyze-only entry point instead of checkContentLoop. Reasoning text is raw chain-of-thought, never rendered markdown: an unbalanced code fence in a thought used to flip the shared inCodeBlock parity — which nothing clears mid-turn — silently disabling visible-content chant detection for the rest of the turn, and list/heading-shaped thought deltas reset the shared history, erasing already-accumulated content evidence when a provider interleaves thought and content parts. * fix(core): grow the periodic-rule verified region with the repetition count The long-period rule only inspected the last five occurrences, pinning the verified region at 4 x stride + 50 chars: units of ~76-237 chars fell in a gap between the clustered rule's 75-char bound and the 1000-char region floor at any repetition count, and units of ~1 KB or more could never fit five occurrences into the 4000-char history window at all. Extend the candidate run backwards over the longest equally-spaced suffix of occurrences so the verified region grows with the repetition count, and once the history saturates accept a shorter run (>= 3 occurrences) when the entire retained region is verified periodic back to the history start, so earlier occurrences truncated out of the window cannot hide a chant. Also correct the constants' comments describing the rule's domains. * test(core): cover post-truncation chant detection after a long varied turn Add the realistic #1775 shape that had no positive coverage: a long varied turn filling the history window, then a ~700-char chant streamed as misaligned deltas. Asserts detection at exactly the fifth in-window occurrence, pinning MAX_HISTORY_LENGTH, truncateAndUpdate's index adjustment, and the long-unit case together — a shrunken window would fire early via the truncated-run path once the filler flushes, and a broken index adjustment would never fire. * fix(cli): widen chanting halt label to cover reasoning-stream repetitions Reasoning-stream chants fire CHANTING_IDENTICAL_SENTENCES via checkReasoningContentLoop, but getResponseText filters reasoning out of visible output, so the headless label 'repeated the same sentence in its output' sends users looking for a repetition that is never rendered. Widen the label to 'output or reasoning' and add a headless-path regression test asserting the wording. * refactor(core): share the append/truncate/analyze tail across loop channels checkReasoningContentLoop duplicated the streamContentHistory append, truncateAndUpdate, analyzeContentChunksForLoop tail of checkContentLoop, leaving the history contract in two copies that a future fix could let drift. Extract the tail into appendToContentHistoryAndAnalyze and call it from both entry points. * perf(core): compare periodic regions in place instead of slicing history isRegionPeriodicWithStride sliced up to ~4 KB of history per invocation. Near-periodic chants fail verification repeatedly while their occurrence runs persist, so once a run reaches length 5 the check fires on up to every streamed character -- a probe measured ~136 MB of transient copies over one 49k-char stream. Index the existing string directly instead; comparison semantics are unchanged. * fix(core): reset stream-content loop state on retry replays and model fallback A replay (non-continuation) retry re-streams the failed attempt's content and reasoning through the chunk detectors — the #7832 transport-replay gate admits thought-only cuts, and with deterministic decoding the re-stream is verbatim. The Retry case in addAndCheckHeuristicLoops cleared only the tool-call counters, so the accumulated identical copies could fire CHANTING_IDENTICAL_SENTENCES mid-way through an otherwise healthy attempt. Continuation retries (isContinuation) keep the delivered text and append new output, so their state stays. ModelFallback had no case at all: the fallback model restarts from scratch, so mirror the replay resets for it. A genuine chant simply re-accumulates after the restart. * perf(core): defer content-history truncation with a hysteresis slack Once streamContentHistory saturates, truncateAndUpdate walked the whole contentStats map on every streamed event — Θ(window) entries in steady state, since the stride-1 sliding window hashes every position (~385 µs/event at window 4000 vs ~12 µs pre-saturation). With high-frequency small reasoning deltas now routed through the path, healthy long-thinking turns paid thousands of events of synchronous CPU. Trim only when the length exceeds MAX_HISTORY_LENGTH by a TRUNCATION_SLACK margin (1000 chars), slicing back to exactly MAX_HISTORY_LENGTH, so the index-rebase walk is amortized over appended chars. The change is behavior-neutral: the detection rules now always operate on the logical window of the last MAX_HISTORY_LENGTH chars — occurrences the window has passed are dropped at lookup (the exact set a per-event trim would have removed) and the periodic rule's escape valve verifies from the window start, i.e. exactly the content a fully-trimmed history retains. Tests pin pre-change fire offsets across saturation and multiple trims, plus the deferred-trim mechanics. * feat(core): log a chanting-region excerpt on loop halt for debug A reasoning-channel halt exits headless runs with empty stdout and only the loop-type label on stderr; neither the LoopDetected event (loop_type + prompt_id only), telemetry, nor any log carried an excerpt of what repeated, leaving no way to tell a true repetition from a detector misfire without instrumenting a repro. Capture one period of the matched region (the span between the last two occurrences, capped at 80 chars) when the chanting detector fires and emit it through the config debug logger at the firing site. The LoopDetected event contract is deliberately unchanged. * fix(core): preserve subagent continuation retries * test(core): cover plain subagent retry forwarding * fix(core): omit plain retry continuation flag
This commit is contained in:
parent
b2edb80a57
commit
2855149d47
6 changed files with 1097 additions and 14 deletions
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -168,8 +168,12 @@ import {
|
|||
const LOOP_TYPE_LABELS: Record<LoopType, string> = {
|
||||
[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]:
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue