Skip to content

Commit 82c40e8

Browse files
committed
Consolidate stall-watchdog's repetition detector into subagent/repetition.ts
stall-watchdog.ts carried its own char-level tail-repetition detector (constants + a thin wrapper over detectSequencePeriod), duplicating a detector that already lived in subagent/repetition.ts under a different name and shape. Moved detectCharRepetition (with its constants and rationale comments) into subagent/repetition.ts; stall-watchdog.ts now re-exports it as detectRepetition/RepetitionCheck for its existing callers. No threshold values changed. The two detectors solve the same "is the tail looping" question with genuinely different algorithms (KMP + normalization for streamed model text vs. a plain per-char search with a distinct-chars floor for the TUI's live buffer) so they were kept as separate functions rather than merged into one, per CL-6939 / CL-6790.
1 parent b9bf734 commit 82c40e8

2 files changed

Lines changed: 85 additions & 58 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 (`detectCharRepetition`) — 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 CharRepetitionCheck = 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 detectCharRepetition(text: string): CharRepetitionCheck {
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.ts‎

Lines changed: 7 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { TurnStatus } from "./session-chrome.js";
2-
import { detectSequencePeriod, type SequencePeriodCheck } from "../util/period-detection.js";
2+
import { detectCharRepetition, type CharRepetitionCheck } from "../subagent/repetition.js";
33

44
// How long the run can be continuously awaiting a response with no new content
55
// before the watchdog fires and aborts the in-flight request.
@@ -26,64 +26,13 @@ export interface ShouldAbortForStallArgs {
2626
readonly activeToolCalls: readonly string[];
2727
}
2828

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;
29+
// The character-level tail-repetition search (constants, doc comments, and
30+
// implementation) lives in ../subagent/repetition.ts as `detectCharRepetition`
31+
// — consolidated there so this module and the subagent module do not each
32+
// hand-roll their own repetition detector. Thresholds are unchanged.
33+
export type RepetitionCheck = CharRepetitionCheck;
6834

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-
}
35+
export const detectRepetition = detectCharRepetition;
8736

8837
/**
8938
* Whether silence of `thresholdMs` counts as stuck at all. Shared by the notice

0 commit comments

Comments
 (0)