Skip to content

Commit 20320cb

Browse files
Merge pull request #562 from corbitsdev/cl-6939-consolidate-the-duplicate-repetition-detector
Consolidate stall-watchdog's repetition detector into subagent/repetition.ts
2 parents f076309 + dc2a10a commit 20320cb

4 files changed

Lines changed: 97 additions & 79 deletions

File tree

src/subagent/repetition.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,17 @@
66
* invisible to them. This module watches the accumulated text of the current
77
* inference cycle and flags a trailing window that repeats verbatim past a
88
* threshold, so the run loop can abort the cycle instead of streaming forever.
9+
*
10+
* Also home to the TUI stall-watchdog's character-level tail-repetition
11+
* guard (`detectTailCharLoop`) — a separate, simpler check consolidated
12+
* here from tui/stall-watchdog.ts so the two repetition detectors live in one
13+
* module instead of two. It solves the same "is the tail looping" question
14+
* for a different consumer with different constants; see its own doc comment
15+
* for why it is not merged into `detectRepetition` above.
916
*/
1017

18+
import { detectSequencePeriod, type SequencePeriodCheck } from "../util/period-detection.js";
19+
1120
/** Tunable thresholds for the trailing-window repetition check. */
1221
export interface RepetitionConfig {
1322
/** Smallest normalized window (chars) considered a loop unit. */
@@ -245,3 +254,72 @@ export function trackContentlessGrowth(
245254
hit: visibleChars < config.minVisibleChars,
246255
};
247256
}
257+
258+
// The captured incident looped two sentences with no line break between them
259+
// ("...ranked findings.Confirming callId emission...") — degeneration is a
260+
// character-level loop, not a line-level one. Splitting on "\n" misses it
261+
// entirely, so the tail is treated as a plain string and checked for the
262+
// smallest period it exactly repeats: the shortest span p such that the last
263+
// several hundred characters equal p repeated.
264+
//
265+
// A period below this is more likely a short structural tic (indentation, a
266+
// repeated bullet or table-cell divider) than a looping phrase. Live loops
267+
// repeat units as short as 10 chars ("Groaning. " emitted ~1,363 times), so
268+
// the floor sits at 8 — short structural tics that survive it (a "- item\n"
269+
// bullet is 7 chars) fall below, and the ones at or above it are filtered by
270+
// the distinct-chars floor and the raised repeat bar instead. Still well
271+
// under the ~140-char period of the captured incident's two-sentence cycle.
272+
const CHAR_REPETITION_MIN_PERIOD = 8;
273+
// How many exact repeats of the period are required before it counts as a
274+
// loop rather than a coincidence. Raised 3x in step with the 3x-lower period
275+
// floor so the minimum exactly-periodic span stays at 192 chars (was 24*8,
276+
// now 8*24). Verified against real non-degenerate repetition: a 6-row
277+
// markdown table separator (period ~51 chars, 6 exact repeats) and 3
278+
// identical code lines (period ~60 chars, 3 exact repeats) both land far
279+
// under this bar and are not flagged; a genuine degenerate loop repeats
280+
// hundreds of times, so it still clears the bar long before the stream ends.
281+
const CHAR_REPETITION_MIN_REPEATS = 24;
282+
// Hard ceiling on the period search regardless of buffer size, purely to cap
283+
// worst-case work per check — token-level degeneration loops on a phrase or
284+
// two, never on multi-paragraph spans.
285+
const CHAR_REPETITION_MAX_PERIOD_CAP = 2_000;
286+
// A monochrome run ("x".repeat(500), a "----" rule, a wall of spaces) is
287+
// trivially periodic at *every* period, which would otherwise make it the
288+
// single easiest thing to false-trigger on — verified by execution against
289+
// `thinking-reveal.test.ts`'s burst-of-"x" fixture, which tripped the guard
290+
// before this floor existed. Requiring the repeating unit itself to contain
291+
// this many distinct characters keeps single-character and low-variety runs
292+
// out without weakening the sentence-level case: the captured incident's
293+
// cycle spans two full sentences, comfortably above it.
294+
const CHAR_REPETITION_MIN_DISTINCT_CHARS = 8;
295+
296+
export type TailCharLoopCheck = SequencePeriodCheck;
297+
298+
/**
299+
* Whether the tail of `text` is an exact repeat of some short span at least
300+
* `CHAR_REPETITION_MIN_REPEATS` times. Pure text-in, decision-out: the caller
301+
* (the TUI stall watchdog) owns accumulating the buffer across deltas and
302+
* cycles within a turn.
303+
*
304+
* Delegates to the generic detectSequencePeriod over the character array —
305+
* periods longer than `text.length / CHAR_REPETITION_MIN_REPEATS` are skipped
306+
* there, not as an arbitrary cutoff but because they cannot mathematically
307+
* reach the occurrence threshold within the given text.
308+
*
309+
* This is deliberately not merged with `detectRepetition` above: that one
310+
* normalizes whitespace/invisibles and optionally folds digits before
311+
* running a KMP period search tuned for streamed model text, while this is a
312+
* plain per-character search with a distinct-chars floor instead of digit
313+
* folding, tuned for the TUI's live character buffer. Same question ("is the
314+
* tail looping"), different constants and different false-positive shape —
315+
* see the config comments on each for why neither threshold set may be
316+
* changed to match the other.
317+
*/
318+
export function detectTailCharLoop(text: string): TailCharLoopCheck {
319+
return detectSequencePeriod(text.split(""), {
320+
minPeriod: CHAR_REPETITION_MIN_PERIOD,
321+
maxPeriod: CHAR_REPETITION_MAX_PERIOD_CAP,
322+
minRepeats: CHAR_REPETITION_MIN_REPEATS,
323+
minDistinct: () => CHAR_REPETITION_MIN_DISTINCT_CHARS,
324+
});
325+
}

src/tui/stall-watchdog.test.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test";
22

33
import {
44
applyStallRecovery,
5-
detectRepetition,
65
isStalledForDisplay,
76
repetitionRecoveryMessage,
87
shouldAbortForStall,
@@ -12,6 +11,7 @@ import {
1211
STALL_RECOVERY_MESSAGE,
1312
STALL_TIMEOUT_MS,
1413
} from "./stall-watchdog.js";
14+
import { detectTailCharLoop } from "../subagent/repetition.js";
1515

1616
describe("shouldAbortForStall", () => {
1717
// Mid-stream hang: tokens already flowed, then everything went silent —
@@ -140,14 +140,14 @@ describe("applyStallRecovery", () => {
140140
});
141141
});
142142

143-
describe("detectRepetition", () => {
143+
describe("detectTailCharLoop", () => {
144144
test("finds nothing in fresh, varied output", () => {
145145
const text = [
146146
"I'll check the callId emission path first.",
147147
"Running the search now.",
148148
"Found three matches across the module.",
149149
].join("\n");
150-
expect(detectRepetition(text).repeating).toBe(false);
150+
expect(detectTailCharLoop(text).repeating).toBe(false);
151151
});
152152

153153
// The captured incident: the two sentences ran together with no line break
@@ -158,7 +158,7 @@ describe("detectRepetition", () => {
158158
"I'll verify callId emission and remaining edges, then write the ranked findings.";
159159
const line2 = "Confirming callId emission, then writing the ranked findings.";
160160
const text = Array(30).fill(`${line1}${line2}`).join("");
161-
const check = detectRepetition(text);
161+
const check = detectTailCharLoop(text);
162162
expect(check.repeating).toBe(true);
163163
expect(check.period).toBe(line1.length + line2.length);
164164
});
@@ -168,7 +168,7 @@ describe("detectRepetition", () => {
168168
// chars keeps it above REPETITION_MIN_DISTINCT_CHARS.
169169
test("flags a short-phrase loop with a 10-char unit", () => {
170170
const text = "Groaning. ".repeat(60);
171-
const check = detectRepetition(text);
171+
const check = detectTailCharLoop(text);
172172
expect(check.repeating).toBe(true);
173173
expect(check.period).toBe("Groaning. ".length);
174174
});
@@ -180,35 +180,35 @@ describe("detectRepetition", () => {
180180
// Fewer than the occurrence threshold: a model can legitimately restate
181181
// a step once or twice across tool-call cycles without looping.
182182
const text = Array(4).fill(`${line1}${line2}`).join("");
183-
expect(detectRepetition(text).repeating).toBe(false);
183+
expect(detectTailCharLoop(text).repeating).toBe(false);
184184
});
185185

186186
test("does not flag a repeated markdown table separator row", () => {
187187
const row = "| ---------------------- | ---------------------- |";
188188
const text = Array(6).fill(row).join("\n");
189-
expect(detectRepetition(text).repeating).toBe(false);
189+
expect(detectTailCharLoop(text).repeating).toBe(false);
190190
});
191191

192192
test("does not flag a few identical code lines", () => {
193193
const line = " const result = await fetchData(request, options, context)";
194194
const text = Array(3).fill(line).join("\n");
195-
expect(detectRepetition(text).repeating).toBe(false);
195+
expect(detectTailCharLoop(text).repeating).toBe(false);
196196
});
197197

198198
test("ignores short recurring fragments", () => {
199199
const text = Array(10).fill("ok").join(" ");
200-
expect(detectRepetition(text).repeating).toBe(false);
200+
expect(detectTailCharLoop(text).repeating).toBe(false);
201201
});
202202

203203
// A monochrome run is periodic at every period by construction — the
204204
// easiest thing to false-trigger on if entropy is not checked.
205205
test("does not flag a long run of the same character", () => {
206-
expect(detectRepetition("x".repeat(500)).repeating).toBe(false);
206+
expect(detectTailCharLoop("x".repeat(500)).repeating).toBe(false);
207207
});
208208

209209
test("does not flag a repeated horizontal rule", () => {
210210
const text = Array(10).fill("----------------------------").join("\n");
211-
expect(detectRepetition(text).repeating).toBe(false);
211+
expect(detectTailCharLoop(text).repeating).toBe(false);
212212
});
213213
});
214214

src/tui/stall-watchdog.ts

Lines changed: 0 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { TurnStatus } from "./session-chrome.js";
2-
import { detectSequencePeriod, type SequencePeriodCheck } from "../util/period-detection.js";
32

43
// How long the run can be continuously awaiting a response with no new content
54
// before the watchdog fires and aborts the in-flight request.
@@ -26,65 +25,6 @@ export interface ShouldAbortForStallArgs {
2625
readonly activeToolCalls: readonly string[];
2726
}
2827

29-
// The captured incident looped two sentences with no line break between them
30-
// ("...ranked findings.Confirming callId emission...") — degeneration is a
31-
// character-level loop, not a line-level one. Splitting on "\n" misses it
32-
// entirely, so the tail is treated as a plain string and checked for the
33-
// smallest period it exactly repeats: the shortest span p such that the last
34-
// several hundred characters equal p repeated.
35-
//
36-
// A period below this is more likely a short structural tic (indentation, a
37-
// repeated bullet or table-cell divider) than a looping phrase. Live loops
38-
// repeat units as short as 10 chars ("Groaning. " emitted ~1,363 times), so
39-
// the floor sits at 8 — short structural tics that survive it (a "- item\n"
40-
// bullet is 7 chars) fall below, and the ones at or above it are filtered by
41-
// the distinct-chars floor and the raised repeat bar instead. Still well
42-
// under the ~140-char period of the captured incident's two-sentence cycle.
43-
const REPETITION_MIN_PERIOD = 8;
44-
// How many exact repeats of the period are required before it counts as a
45-
// loop rather than a coincidence. Raised 3x in step with the 3x-lower period
46-
// floor so the minimum exactly-periodic span stays at 192 chars (was 24*8,
47-
// now 8*24). Verified against real non-degenerate repetition: a 6-row
48-
// markdown table separator (period ~51 chars, 6 exact repeats) and 3
49-
// identical code lines (period ~60 chars, 3 exact repeats) both land far
50-
// under this bar and are not flagged; a genuine degenerate loop repeats
51-
// hundreds of times, so it still clears the bar long before the stream ends.
52-
const REPETITION_MIN_REPEATS = 24;
53-
// Hard ceiling on the period search regardless of buffer size, purely to cap
54-
// worst-case work per check — token-level degeneration loops on a phrase or
55-
// two, never on multi-paragraph spans.
56-
const REPETITION_MAX_PERIOD_CAP = 2_000;
57-
// A monochrome run ("x".repeat(500), a "----" rule, a wall of spaces) is
58-
// trivially periodic at *every* period, which would otherwise make it the
59-
// single easiest thing to false-trigger on — verified by execution against
60-
// `thinking-reveal.test.ts`'s burst-of-"x" fixture, which tripped the guard
61-
// before this floor existed. Requiring the repeating unit itself to contain
62-
// this many distinct characters keeps single-character and low-variety runs
63-
// out without weakening the sentence-level case: the captured incident's
64-
// cycle spans two full sentences, comfortably above it.
65-
const REPETITION_MIN_DISTINCT_CHARS = 8;
66-
67-
export type RepetitionCheck = SequencePeriodCheck;
68-
69-
/**
70-
* Whether the tail of `text` is an exact repeat of some short span at least
71-
* `REPETITION_MIN_REPEATS` times. Pure text-in, decision-out: the caller owns
72-
* accumulating the buffer across deltas and cycles within a turn.
73-
*
74-
* Delegates to the generic detectSequencePeriod over the character array —
75-
* periods longer than `text.length / REPETITION_MIN_REPEATS` are skipped
76-
* there, not as an arbitrary cutoff but because they cannot mathematically
77-
* reach the occurrence threshold within the given text.
78-
*/
79-
export function detectRepetition(text: string): RepetitionCheck {
80-
return detectSequencePeriod(text.split(""), {
81-
minPeriod: REPETITION_MIN_PERIOD,
82-
maxPeriod: REPETITION_MAX_PERIOD_CAP,
83-
minRepeats: REPETITION_MIN_REPEATS,
84-
minDistinct: () => REPETITION_MIN_DISTINCT_CHARS,
85-
});
86-
}
87-
8828
/**
8929
* Whether silence of `thresholdMs` counts as stuck at all. Shared by the notice
9030
* and the abort so they never disagree about which runs are stalled — only

src/tui/turn-state.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,15 @@
1111

1212
import { type } from "arktype";
1313

14-
import { detectRepetition } from "./stall-watchdog.js";
14+
import { detectTailCharLoop } from "../subagent/repetition.js";
1515
import type { TurnStatus } from "./session-chrome.js";
1616

1717
// Bound on the accumulated stream text kept for repetition checks. Comfortably
18-
// larger than the periods `detectRepetition` can confirm, so trimming never
18+
// larger than the periods `detectTailCharLoop` can confirm, so trimming never
1919
// drops content the check still needs.
2020
const STREAM_TEXT_BUFFER_CHARS = 8_000;
2121

22-
// `detectRepetition` walks a character-level period search; cheap per call,
22+
// `detectTailCharLoop` walks a character-level period search; cheap per call,
2323
// but the reactor loop can emit a delta per token, and running it on every
2424
// single one makes it the hottest thing in that loop for no benefit — a
2525
// repeating tail does not appear or disappear between two three-character
@@ -108,7 +108,7 @@ export interface TurnState {
108108
* narrating a similar short line before each of several tool calls is
109109
* ordinary and must not accumulate into an apparent loop, whereas a
110110
* genuinely degenerate model repeats within one unbroken stream. Bounded to
111-
* `STREAM_TEXT_BUFFER_CHARS`; feeds `detectRepetition`, nothing else.
111+
* `STREAM_TEXT_BUFFER_CHARS`; feeds `detectTailCharLoop`, nothing else.
112112
*/
113113
readonly streamText: string;
114114
/**
@@ -117,9 +117,9 @@ export interface TurnState {
117117
* throttle below tell "40 more chars arrived" from "the buffer is full."
118118
*/
119119
readonly streamCharsSeen: number;
120-
/** `streamCharsSeen` as of the last `detectRepetition` call. */
120+
/** `streamCharsSeen` as of the last `detectTailCharLoop` call. */
121121
readonly repetitionCheckedAt: number;
122-
/** Result of the most recent `detectRepetition` check on `streamText`. */
122+
/** Result of the most recent `detectTailCharLoop` check on `streamText`. */
123123
readonly repeating: boolean;
124124
/**
125125
* `streamTokenCount` at the moment repetition was first observed this turn.
@@ -138,7 +138,7 @@ export interface TurnState {
138138
* Consecutive completed cycles whose fingerprint matched the one before it.
139139
* A model repeating the same block every cycle, with a tool call in
140140
* between each, builds this streak even though no single cycle's text ever
141-
* gets long enough to trip `detectRepetition` on its own.
141+
* gets long enough to trip `detectTailCharLoop` on its own.
142142
*/
143143
readonly consecutiveMatchingCycles: number;
144144
/**
@@ -458,7 +458,7 @@ const streaming = (
458458
// starts empty (see `runningTool`) and would otherwise read back false on
459459
// the next check, un-latching a real detection the moment a tool call
460460
// interrupts the stream.
461-
const repeating = state.repeating || (due && detectRepetition(streamText).repeating);
461+
const repeating = state.repeating || (due && detectTailCharLoop(streamText).repeating);
462462
return {
463463
...state,
464464
status: state.status === "blocked" ? "blocked" : "running",

0 commit comments

Comments
 (0)