@@ -21,41 +21,88 @@ export type ShouldAbortForStallArgs = {
2121 readonly streamingType : "text" | "thinking" | "tool" | null
2222}
2323
24- // A repeated line has to be long enough that short, legitimately-recurring
25- // fragments ("Let me check.") do not trip the guard.
26- const REPETITION_MIN_LINE_LENGTH = 20
27- // How far back into the streamed text to look for repeats. Bounded so the
28- // check stays cheap however long the turn runs.
29- const REPETITION_LOOKBACK_LINES = 12
30- // Three verbatim repeats of the same substantial line is not a coincidence
31- // of phrasing — it is the model looping.
32- const REPETITION_MIN_OCCURRENCES = 3
24+ // The captured incident looped two sentences with no line break between them
25+ // ("...ranked findings.Confirming callId emission...") — degeneration is a
26+ // character-level loop, not a line-level one. Splitting on "\n" misses it
27+ // entirely, so the tail is treated as a plain string and checked for the
28+ // smallest period it exactly repeats: the shortest span p such that the last
29+ // several hundred characters equal p repeated.
30+ //
31+ // A period below this is more likely a short structural tic (indentation, a
32+ // repeated bullet or table-cell divider) than a looping phrase. Chosen well
33+ // under the ~140-char period of the captured incident's two-sentence cycle,
34+ // with headroom for shorter degenerate loops (a single repeated sentence).
35+ const REPETITION_MIN_PERIOD = 24
36+ // How many exact repeats of the period are required before it counts as a
37+ // loop rather than a coincidence. Verified against real non-degenerate
38+ // repetition: a 6-row markdown table separator (period ~51 chars, 6 exact
39+ // repeats) and 3 identical code lines (period ~60 chars, 3 exact repeats)
40+ // both land under this bar and are not flagged; the captured incident's
41+ // sentence pair comfortably clears it well before the stream ends.
42+ const REPETITION_MIN_REPEATS = 8
43+ // Hard ceiling on the period search regardless of buffer size, purely to cap
44+ // worst-case work per check — token-level degeneration loops on a phrase or
45+ // two, never on multi-paragraph spans.
46+ const REPETITION_MAX_PERIOD_CAP = 2_000
47+ // A monochrome run ("x".repeat(500), a "----" rule, a wall of spaces) is
48+ // trivially periodic at *every* period, which would otherwise make it the
49+ // single easiest thing to false-trigger on — verified by execution against
50+ // `thinking-reveal.test.ts`'s burst-of-"x" fixture, which tripped the guard
51+ // before this floor existed. Requiring the repeating unit itself to contain
52+ // this many distinct characters keeps single-character and low-variety runs
53+ // out without weakening the sentence-level case: the captured incident's
54+ // cycle spans two full sentences, comfortably above it.
55+ const REPETITION_MIN_DISTINCT_CHARS = 8
3356
3457export type RepetitionCheck = {
3558 readonly repeating : boolean
36- readonly repeatedLine : string | null
37- readonly occurrences : number
59+ readonly period : number | null
60+ readonly repeats : number
3861}
3962
4063/**
41- * Whether the tail of the streamed text is dominated by a line repeated
42- * verbatim. Pure text-in, decision-out: the caller owns accumulating the
43- * buffer across deltas and cycles within a turn.
64+ * Length of the exact-period run ending at the last character of `text`,
65+ * including the base period itself. `text[i] === text[i - period]` walked
66+ * backwards from the end; stops at the first mismatch or the start of the
67+ * string.
68+ */
69+ function periodicSuffixLength ( text : string , period : number ) : number {
70+ let i = text . length - 1
71+ let j = i - period
72+ let matched = 0
73+ while ( j >= 0 && text [ i ] === text [ j ] ) {
74+ matched ++
75+ i --
76+ j --
77+ }
78+ return matched + period
79+ }
80+
81+ /**
82+ * Whether the tail of `text` is an exact repeat of some short span at least
83+ * `REPETITION_MIN_REPEATS` times. Pure text-in, decision-out: the caller owns
84+ * accumulating the buffer across deltas and cycles within a turn.
85+ *
86+ * Periods longer than `text.length / REPETITION_MIN_REPEATS` are skipped, not
87+ * as an arbitrary cutoff but because they cannot mathematically reach the
88+ * occurrence threshold within the given text — a loop with a longer period
89+ * needs a longer buffer to confirm, which is a buffer-size trade-off owned by
90+ * the caller, not a second detection path here.
4491 */
4592export function detectRepetition ( text : string ) : RepetitionCheck {
46- const lines = text
47- . split ( "\n" )
48- . map ( ( line ) => line . trim ( ) )
49- . filter ( ( line ) => line . length >= REPETITION_MIN_LINE_LENGTH )
50- const tail = lines . slice ( - REPETITION_LOOKBACK_LINES )
51- const counts = new Map < string , number > ( )
52- for ( const line of tail ) counts . set ( line , ( counts . get ( line ) ?? 0 ) + 1 )
53- for ( const [ line , occurrences ] of counts ) {
54- if ( occurrences >= REPETITION_MIN_OCCURRENCES ) {
55- return { repeating : true , repeatedLine : line , occurrences }
56- }
93+ const maxPeriod = Math . min (
94+ REPETITION_MAX_PERIOD_CAP ,
95+ Math . floor ( text . length / REPETITION_MIN_REPEATS ) ,
96+ )
97+ for ( let period = REPETITION_MIN_PERIOD ; period <= maxPeriod ; period ++ ) {
98+ const matched = periodicSuffixLength ( text , period )
99+ const repeats = matched / period
100+ if ( repeats < REPETITION_MIN_REPEATS ) continue
101+ const unit = text . slice ( text . length - period )
102+ if ( new Set ( unit ) . size < REPETITION_MIN_DISTINCT_CHARS ) continue
103+ return { repeating : true , period , repeats }
57104 }
58- return { repeating : false , repeatedLine : null , occurrences : 0 }
105+ return { repeating : false , period : null , repeats : 0 }
59106}
60107
61108/**
@@ -87,14 +134,18 @@ export function shouldAbortForStall(args: ShouldAbortForStallArgs): boolean {
87134
88135export type ShouldNoticeStallArgs = ShouldAbortForStallArgs & {
89136 readonly stallNoticeMs : number
137+ /** Whether the repetition guard currently sees a looping tail. */
138+ readonly repeating : boolean
90139}
91140
92141/**
93142 * Returns true while the run has been silent long enough to say so but not yet
94143 * long enough to abort. False once the abort takes over, so the two never
95- * paint at the same time.
144+ * paint at the same time, and false while repeating — that run is producing
145+ * output, just not useful output, and "no response" would misdescribe it.
96146 */
97147export function shouldNoticeStall ( args : ShouldNoticeStallArgs ) : boolean {
148+ if ( args . repeating ) return false
98149 if ( shouldAbortForStall ( args ) ) return false
99150 return silentPastThreshold ( args , args . stallNoticeMs )
100151}
0 commit comments