@@ -21,6 +21,90 @@ export type ShouldAbortForStallArgs = {
2121 readonly streamingType : "text" | "thinking" | "tool" | null
2222}
2323
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
56+
57+ export type RepetitionCheck = {
58+ readonly repeating : boolean
59+ readonly period : number | null
60+ readonly repeats : number
61+ }
62+
63+ /**
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.
91+ */
92+ export function detectRepetition ( text : string ) : RepetitionCheck {
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 }
104+ }
105+ return { repeating : false , period : null , repeats : 0 }
106+ }
107+
24108/**
25109 * Whether silence of `thresholdMs` counts as stuck at all. Shared by the notice
26110 * and the abort so they never disagree about which runs are stalled — only
@@ -50,31 +134,51 @@ export function shouldAbortForStall(args: ShouldAbortForStallArgs): boolean {
50134
51135export type ShouldNoticeStallArgs = ShouldAbortForStallArgs & {
52136 readonly stallNoticeMs : number
137+ /** Whether the repetition guard currently sees a looping tail. */
138+ readonly repeating : boolean
53139}
54140
55141/**
56142 * Returns true while the run has been silent long enough to say so but not yet
57143 * long enough to abort. False once the abort takes over, so the two never
58- * 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.
59146 */
60147export function shouldNoticeStall ( args : ShouldNoticeStallArgs ) : boolean {
148+ if ( args . repeating ) return false
61149 if ( shouldAbortForStall ( args ) ) return false
62150 return silentPastThreshold ( args , args . stallNoticeMs )
63151}
64152
65- /** Shown while the run is silent; names the state and the way out. */
153+ /**
154+ * Shown while nothing is arriving at all. Never fires while tokens are
155+ * flowing — a model looping on repeated content is still producing output,
156+ * so it is reported by `repetitionRecoveryMessage` instead, not this one.
157+ */
66158export const STALL_NOTICE_MESSAGE = "no response for a while — ctrl+c to interrupt"
67159
68160export const STALL_RECOVERY_MESSAGE =
69161 "stopped after no response — send again to retry"
70162
163+ /**
164+ * Shown once a repeated line aborts the turn. Named as degeneration, not a
165+ * generic failure, so a retry reads as the reasonable next step rather than
166+ * papering over a suspected hang or network fault.
167+ */
168+ export function repetitionRecoveryMessage ( repeatedTokens : number ) : string {
169+ return `stopped after repeating itself — ~${ repeatedTokens } tokens looped — send again to retry`
170+ }
171+
71172export type ApplyStallRecoveryDeps = {
72173 /** Abort the in-flight run through the session port. */
73174 readonly abort : ( ) => void
74175 readonly notify : ( message : string ) => void
75176}
76177
77- export function applyStallRecovery ( deps : ApplyStallRecoveryDeps ) : void {
178+ export function applyStallRecovery (
179+ deps : ApplyStallRecoveryDeps ,
180+ message : string = STALL_RECOVERY_MESSAGE ,
181+ ) : void {
78182 deps . abort ( )
79- deps . notify ( STALL_RECOVERY_MESSAGE )
183+ deps . notify ( message )
80184}
0 commit comments