Skip to content

Commit 284b998

Browse files
Merge repetition guard
2 parents e143db0 + bcc3bc8 commit 284b998

6 files changed

Lines changed: 654 additions & 32 deletions

File tree

src/tui-opentui/runtime-bridge.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,12 @@ import {
4141
import { quotaWaitSeconds, shouldAutoRetryQuota } from "./quota-retry.js"
4242
import {
4343
applyStallRecovery,
44+
repetitionRecoveryMessage,
4445
shouldAbortForStall,
4546
shouldNoticeStall,
4647
STALL_NOTICE_MESSAGE,
4748
STALL_NOTICE_MS,
49+
STALL_RECOVERY_MESSAGE,
4850
STALL_TIMEOUT_MS,
4951
} from "./stall-watchdog.js"
5052
import {
@@ -919,6 +921,27 @@ export function attachSessionBridge(
919921
return
920922
}
921923

924+
// Content-based, not time-based: a repeating line means the model is
925+
// stuck regardless of how fast it is producing it, so this is checked
926+
// before the silence clock rather than folded into it.
927+
//
928+
// Gated on `status === "running"` because every turn-ending transition
929+
// (interrupt, connector.reply with no tools outstanding, reactor.done /
930+
// reactor.error) routes through `initialTurnState`, which clears
931+
// `repeating`. If a future settle path changes `isProcessing` without
932+
// also resetting `status` and `repeating` through that same reset, this
933+
// guard would no longer mean "the turn is actually live" and could fire
934+
// on an already-settled turn — recheck this alongside any such change.
935+
if (bag.turn.status === "running" && bag.turn.repeating) {
936+
const repeatedTokens =
937+
bag.turn.streamTokenCount - (bag.turn.repeatingSinceTokenCount ?? 0)
938+
applyStallRecovery(
939+
{ abort: doInterrupt, notify: (message) => setStatusFlash(shell, message) },
940+
repetitionRecoveryMessage(repeatedTokens),
941+
)
942+
return
943+
}
944+
922945
const stallArgs = {
923946
status: bag.turn.status,
924947
awaitingResponse: bag.turn.awaitingResponse,
@@ -930,16 +953,22 @@ export function attachSessionBridge(
930953
}
931954

932955
if (shouldAbortForStall(stallArgs)) {
933-
applyStallRecovery({
934-
abort: doInterrupt,
935-
notify: (message) => setStatusFlash(shell, message),
936-
})
956+
applyStallRecovery(
957+
{ abort: doInterrupt, notify: (message) => setStatusFlash(shell, message) },
958+
STALL_RECOVERY_MESSAGE,
959+
)
937960
return
938961
}
939962

940963
// Notice only — the phase still paints below, because a ramp that stops
941964
// moving is the very thing that reads as a hang.
942-
if (shouldNoticeStall({ ...stallArgs, stallNoticeMs })) {
965+
if (
966+
shouldNoticeStall({
967+
...stallArgs,
968+
stallNoticeMs,
969+
repeating: bag.turn.repeating,
970+
})
971+
) {
943972
setStatusFlash(shell, STALL_NOTICE_MESSAGE)
944973
}
945974

src/tui-opentui/stall-watchdog.test.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test"
22

33
import {
44
applyStallRecovery,
5+
detectRepetition,
6+
repetitionRecoveryMessage,
57
shouldAbortForStall,
68
shouldNoticeStall,
79
STALL_NOTICE_MS,
@@ -81,14 +83,93 @@ describe("shouldAbortForStall", () => {
8183
})
8284

8385
describe("applyStallRecovery", () => {
84-
test("aborts then notifies", () => {
86+
test("aborts then notifies with the default message", () => {
8587
const calls: string[] = []
8688
applyStallRecovery({
8789
abort: () => calls.push("abort"),
8890
notify: (m) => calls.push(m),
8991
})
9092
expect(calls).toEqual(["abort", STALL_RECOVERY_MESSAGE])
9193
})
94+
95+
test("aborts then notifies with a supplied message", () => {
96+
const calls: string[] = []
97+
applyStallRecovery(
98+
{ abort: () => calls.push("abort"), notify: (m) => calls.push(m) },
99+
"custom message",
100+
)
101+
expect(calls).toEqual(["abort", "custom message"])
102+
})
103+
})
104+
105+
describe("detectRepetition", () => {
106+
test("finds nothing in fresh, varied output", () => {
107+
const text = [
108+
"I'll check the callId emission path first.",
109+
"Running the search now.",
110+
"Found three matches across the module.",
111+
].join("\n")
112+
expect(detectRepetition(text).repeating).toBe(false)
113+
})
114+
115+
// The captured incident: the two sentences ran together with no line break
116+
// at all. A line-splitting detector never sees this; the period search
117+
// does not care where (or whether) the lines break.
118+
test("flags the captured incident string verbatim, with no newlines", () => {
119+
const line1 =
120+
"I'll verify callId emission and remaining edges, then write the ranked findings."
121+
const line2 = "Confirming callId emission, then writing the ranked findings."
122+
const text = Array(10).fill(`${line1}${line2}`).join("")
123+
const check = detectRepetition(text)
124+
expect(check.repeating).toBe(true)
125+
expect(check.period).toBe(line1.length + line2.length)
126+
})
127+
128+
test("does not flag the same cycle a handful of times", () => {
129+
const line1 =
130+
"I'll verify callId emission and remaining edges, then write the ranked findings."
131+
const line2 = "Confirming callId emission, then writing the ranked findings."
132+
// Fewer than the occurrence threshold: a model can legitimately restate
133+
// a step once or twice across tool-call cycles without looping.
134+
const text = Array(4).fill(`${line1}${line2}`).join("")
135+
expect(detectRepetition(text).repeating).toBe(false)
136+
})
137+
138+
test("does not flag a repeated markdown table separator row", () => {
139+
const row = "| ---------------------- | ---------------------- |"
140+
const text = Array(6).fill(row).join("\n")
141+
expect(detectRepetition(text).repeating).toBe(false)
142+
})
143+
144+
test("does not flag a few identical code lines", () => {
145+
const line = " const result = await fetchData(request, options, context)"
146+
const text = Array(3).fill(line).join("\n")
147+
expect(detectRepetition(text).repeating).toBe(false)
148+
})
149+
150+
test("ignores short recurring fragments", () => {
151+
const text = Array(10).fill("ok").join(" ")
152+
expect(detectRepetition(text).repeating).toBe(false)
153+
})
154+
155+
// A monochrome run is periodic at every period by construction — the
156+
// easiest thing to false-trigger on if entropy is not checked.
157+
test("does not flag a long run of the same character", () => {
158+
expect(detectRepetition("x".repeat(500)).repeating).toBe(false)
159+
})
160+
161+
test("does not flag a repeated horizontal rule", () => {
162+
const text = Array(10).fill("----------------------------").join("\n")
163+
expect(detectRepetition(text).repeating).toBe(false)
164+
})
165+
})
166+
167+
describe("repetitionRecoveryMessage", () => {
168+
test("names degeneration and attributes the looped tokens", () => {
169+
const message = repetitionRecoveryMessage(42)
170+
expect(message).toContain("repeating itself")
171+
expect(message).toContain("42")
172+
})
92173
})
93174

94175
describe("shouldNoticeStall", () => {
@@ -101,8 +182,13 @@ describe("shouldNoticeStall", () => {
101182
stallNoticeMs: STALL_NOTICE_MS,
102183
isProcessing: true,
103184
streamingType: null,
185+
repeating: false,
104186
}
105187

188+
test("stays quiet while repeating, even if also silent by the clock", () => {
189+
expect(shouldNoticeStall({ ...base, repeating: true })).toBe(false)
190+
})
191+
106192
test("speaks up long before the abort backstop", () => {
107193
expect(STALL_NOTICE_MS).toBeLessThan(STALL_TIMEOUT_MS)
108194
expect(shouldNoticeStall(base)).toBe(true)

src/tui-opentui/stall-watchdog.ts

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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

51135
export 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
*/
60147
export 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+
*/
66158
export const STALL_NOTICE_MESSAGE = "no response for a while — ctrl+c to interrupt"
67159

68160
export 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+
71172
export 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
}

src/tui-opentui/turn-monitor.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,64 @@ describe("stall watchdog", () => {
369369
})
370370
})
371371

372+
describe("repetition guard", () => {
373+
test("aborts a looping model without waiting on the stall clock", async () => {
374+
await withTestRenderer(async (h) => {
375+
const t: Harness = await setup(h)
376+
try {
377+
t.bridge.submit("build it", "immediate")
378+
t.port.clear()
379+
380+
// The captured incident shape: the two sentences run together with
381+
// no line break between cycles.
382+
const line1 =
383+
"I'll verify callId emission and remaining edges, then write the ranked findings."
384+
const line2 = "Confirming callId emission, then writing the ranked findings."
385+
const cycle = `${line1}${line2}`
386+
387+
// Tokens keep landing every tick — a real stall would never fire here.
388+
for (let i = 0; i < 10; i++) {
389+
t.bridge.handle({
390+
type: "inference.text.delta",
391+
data: { token: cycle },
392+
})
393+
t.advance(10)
394+
t.tick()
395+
}
396+
397+
expect(t.port.calls).toEqual([{ op: "interrupt" }])
398+
expect(t.shell.statusFlash).toContain("repeating itself")
399+
expect(t.shell.statusFlash).not.toBe(STALL_NOTICE_MESSAGE)
400+
} finally {
401+
t.bridge.dispose()
402+
}
403+
})
404+
})
405+
406+
test("a slow but progressing turn is never killed", async () => {
407+
await withTestRenderer(async (h) => {
408+
const t: Harness = await setup(h)
409+
try {
410+
t.bridge.submit("build it", "immediate")
411+
t.port.clear()
412+
413+
for (let i = 0; i < 5; i++) {
414+
t.bridge.handle({
415+
type: "inference.text.delta",
416+
data: { token: `distinct progress update number ${i}\n` },
417+
})
418+
t.advance(500)
419+
t.tick()
420+
}
421+
422+
expect(t.port.calls).toEqual([])
423+
} finally {
424+
t.bridge.dispose()
425+
}
426+
})
427+
})
428+
})
429+
372430
describe("reasoning settles to a summary", () => {
373431
test("a closed thinking row carries its elapsed time", async () => {
374432
await withTestRenderer(async (h) => {

0 commit comments

Comments
 (0)