Skip to content

Commit 2855149

Browse files
authored
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
1 parent b2edb80 commit 2855149

6 files changed

Lines changed: 1097 additions & 14 deletions

File tree

packages/cli/src/nonInteractiveCli.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2063,6 +2063,37 @@ describe('runNonInteractive', () => {
20632063
);
20642064
});
20652065

2066+
it('describes a chanting halt as output-or-reasoning repetition', async () => {
2067+
setupMetricsMock();
2068+
const events: ServerGeminiStreamEvent[] = [
2069+
{
2070+
type: GeminiEventType.LoopDetected,
2071+
value: { loopType: LoopType.CHANTING_IDENTICAL_SENTENCES },
2072+
},
2073+
];
2074+
mockGeminiClient.sendMessageStream.mockReturnValue(
2075+
createStreamFromEvents(events),
2076+
);
2077+
2078+
const exitCode = await runNonInteractive(
2079+
mockConfig,
2080+
mockSettings,
2081+
'Chant',
2082+
'prompt-id-chanting-loop',
2083+
);
2084+
2085+
expect(exitCode).toBe(1);
2086+
// Reasoning-stream chants fire CHANTING_IDENTICAL_SENTENCES while
2087+
// getResponseText filters reasoning out of visible output, so the
2088+
// headless label must name both channels — a halt on an empty stdout
2089+
// with an "output"-only label reads as a detector misfire.
2090+
expect(processStderrSpy).toHaveBeenCalledWith(
2091+
expect.stringContaining(
2092+
'the model repeated the same sentence in its output or reasoning',
2093+
),
2094+
);
2095+
});
2096+
20662097
it('shows the maxToolCallsPerTurn hint when the per-turn cap halts the run', async () => {
20672098
setupMetricsMock();
20682099
const events: ServerGeminiStreamEvent[] = [

packages/cli/src/nonInteractiveCli.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,12 @@ import {
168168
const LOOP_TYPE_LABELS: Record<LoopType, string> = {
169169
[LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS]:
170170
'the model repeated the same tool call with identical arguments',
171+
// Reasoning-stream chants fire this type too (checkReasoningContentLoop),
172+
// and getResponseText filters reasoning out of visible output — the label
173+
// must name both channels so a headless halt on an empty stdout is not
174+
// mistaken for a detector misfire.
171175
[LoopType.CHANTING_IDENTICAL_SENTENCES]:
172-
'the model repeated the same sentence in its output',
176+
'the model repeated the same sentence in its output or reasoning',
173177
[LoopType.REPETITIVE_THOUGHTS]:
174178
'the model repeated the same reasoning thought',
175179
[LoopType.READ_FILE_LOOP]:

packages/core/src/agents/runtime/agent-core.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,7 @@ export function extractParentToolNames(
224224
new Set(
225225
(
226226
generationConfig?.tools as
227-
| Array<{ functionDeclarations?: FunctionDeclaration[] }>
228-
| undefined
227+
Array<{ functionDeclarations?: FunctionDeclaration[] }> | undefined
229228
)
230229
?.flatMap((tool) => tool.functionDeclarations ?? [])
231230
.map((declaration) => declaration.name)
@@ -970,7 +969,14 @@ export class AgentCore {
970969
// retry does not inherit stale data (e.g. wasOutputTruncated) from a
971970
// previous attempt that may have hit MAX_TOKENS.
972971
if (streamEvent.type === 'retry') {
973-
if (checkSubagentLoop({ type: GeminiEventType.Retry })) {
972+
if (
973+
checkSubagentLoop({
974+
type: GeminiEventType.Retry,
975+
...('isContinuation' in streamEvent
976+
? { isContinuation: streamEvent.isContinuation }
977+
: {}),
978+
})
979+
) {
974980
terminateMode = AgentTerminateMode.LOOP_DETECTED;
975981
loopDetectedInStream = true;
976982
break;
@@ -1514,8 +1520,7 @@ export class AgentCore {
15141520
const registeredTool = this.runtimeContext
15151521
.getToolRegistry()
15161522
.getTool(toolName) as
1517-
| { serverName?: unknown; serverToolName?: unknown }
1518-
| undefined;
1523+
{ serverName?: unknown; serverToolName?: unknown } | undefined;
15191524
if (
15201525
typeof registeredTool?.serverName !== 'string' ||
15211526
typeof registeredTool.serverToolName !== 'string'

packages/core/src/agents/runtime/agent-headless.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
AuthType,
3131
} from '../../core/contentGenerator.js';
3232
import { GeminiChat } from '../../core/geminiChat.js';
33+
import { GeminiEventType } from '../../core/turn.js';
3334
import {
3435
getToolCallFingerprint,
3536
normalizeModelToolCallIds,
@@ -61,6 +62,7 @@ import { AgentTerminateMode } from './agent-types.js';
6162
import { WriteFileTool } from '../../tools/write-file.js';
6263
import { ToolNames } from '../../tools/tool-names.js';
6364
import { normalizeToolNameForProvider } from '../../utils/tool-name-utils.js';
65+
import { LoopDetectionService } from '../../services/loopDetectionService.js';
6466

6567
vi.mock('../../core/geminiChat.js');
6668
vi.mock('../../core/contentGenerator.js', async (importOriginal) => {
@@ -3436,6 +3438,62 @@ describe('subagent.ts', () => {
34363438
);
34373439
});
34383440

3441+
it.each([
3442+
{ retry: { type: 'retry' as const, isContinuation: true } },
3443+
{ retry: { type: 'retry' as const } },
3444+
])(
3445+
'forwards retry events to subagent loop detection',
3446+
async ({ retry }) => {
3447+
const loopSpy = vi
3448+
.spyOn(LoopDetectionService.prototype, 'addAndCheckHeuristicLoops')
3449+
.mockReturnValue(false);
3450+
3451+
const { config } = await createMockConfig();
3452+
mockSendMessageStream.mockResolvedValue(
3453+
(async function* () {
3454+
yield {
3455+
...retry,
3456+
};
3457+
yield {
3458+
type: 'chunk',
3459+
value: {
3460+
candidates: [
3461+
{
3462+
finishReason: 'STOP',
3463+
content: { parts: [{ text: 'done' }] },
3464+
},
3465+
],
3466+
},
3467+
};
3468+
})(),
3469+
);
3470+
3471+
const scope = await AgentHeadless.create(
3472+
'test-agent',
3473+
config,
3474+
promptConfig,
3475+
defaultModelConfig,
3476+
defaultRunConfig,
3477+
{ tools: [] },
3478+
new AgentEventEmitter(),
3479+
);
3480+
3481+
await scope.execute(new ContextState());
3482+
3483+
const retryArg = loopSpy.mock.calls.find(
3484+
([event]) => event.type === GeminiEventType.Retry,
3485+
)?.[0] as { type: GeminiEventType; isContinuation?: boolean };
3486+
expect(retryArg).toEqual(
3487+
expect.objectContaining({ type: GeminiEventType.Retry }),
3488+
);
3489+
if ('isContinuation' in retry) {
3490+
expect(retryArg.isContinuation).toBe(true);
3491+
} else {
3492+
expect(retryArg).not.toHaveProperty('isContinuation');
3493+
}
3494+
},
3495+
);
3496+
34393497
it('keeps automatic max token escalation warm for the next agent round', async () => {
34403498
const writeFileToolDef: FunctionDeclaration = {
34413499
name: WriteFileTool.Name,

0 commit comments

Comments
 (0)