Skip to content

Commit bcc3bc8

Browse files
committed
Fingerprint each cycle instead of discarding repetition state
Clearing the repetition buffer on every tool call fixed the narration false positive but went too far: a model that loops while interleaving even a no-op tool call between repeats was no longer caught at all, since nothing carried across the reset. Keep a cheap fingerprint of each completed cycle instead of its raw text, and flag only once several consecutive cycles fingerprint alike. Narration varies enough cycle to cycle to clear that bar; an unvarying repeated block does not, and now trips within a small, bounded number of cycles rather than never.
1 parent 95bbd86 commit bcc3bc8

2 files changed

Lines changed: 161 additions & 27 deletions

File tree

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

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -231,27 +231,68 @@ describe("repetition tracking", () => {
231231
expect(s.repeating).toBe(false)
232232
})
233233

234-
test("a tool call ends the streaming cycle and clears the repetition buffer", () => {
235-
// Repeats within one unbroken stream are a real loop; a tool call
236-
// interrupting the stream is not part of that cycle, so it must not
237-
// carry the accumulated repetition state into the next one.
234+
test("a tool call ends the streaming cycle but does not un-latch a real detection", () => {
235+
// The raw text buffer is discarded at the tool-call boundary (that is
236+
// what keeps narration from accumulating into a false loop), but a real
237+
// in-cycle detection that already fired must stay latched — the model
238+
// did loop, and a coincidental tool call right after should not erase
239+
// that fact.
238240
const deltas = Array(10)
239241
.fill(cycle)
240242
.map((text) => textDelta(text))
243+
const looping = fold([{ type: "inference.start" }, ...deltas])
244+
expect(looping.repeating).toBe(true)
245+
241246
const withTool = turnStateFromEvent(
242-
fold([{ type: "inference.start" }, ...deltas]),
247+
looping,
243248
{ type: "tool.start", data: { call: { id: "c1", name: "grep" } } },
244249
100,
245250
)
246-
expect(withTool.repeating).toBe(false)
251+
expect(withTool.repeating).toBe(true)
247252
expect(withTool.streamText).toBe("")
248253

249254
const afterReply = turnStateFromEvent(
250255
withTool,
251256
{ type: "connector.reply" },
252257
101,
253258
)
254-
expect(afterReply.repeating).toBe(false)
259+
expect(afterReply.repeating).toBe(true)
260+
})
261+
262+
test("the same block repeated every cycle, interleaved with tool calls, still trips as a loop", () => {
263+
// The gap this closes: an unconditional per-cycle reset (no cross-cycle
264+
// memory at all) never catches a model that loops while interleaving a
265+
// trivial tool call between every repeat — verified against a 500-cycle,
266+
// 88,000-character run that never flipped `repeating`. A fingerprint of
267+
// each completed cycle, compared to the one before it, catches this
268+
// shape within a small, bounded number of cycles instead.
269+
const block = "xk4mQ2 loop unit that never varies at all here"
270+
expect(block.length).toBeGreaterThanOrEqual(24)
271+
272+
let state = fold([{ type: "inference.start" }])
273+
let clock = 1
274+
let trippedAtCycle = -1
275+
for (let cycleIndex = 0; cycleIndex < 30; cycleIndex++) {
276+
state = turnStateFromEvent(state, textDelta(block), ++clock)
277+
state = turnStateFromEvent(
278+
state,
279+
{
280+
type: "tool.start",
281+
data: { call: { id: `c${cycleIndex}`, name: "noop" } },
282+
},
283+
++clock,
284+
)
285+
state = turnStateFromEvent(state, { type: "connector.reply" }, ++clock)
286+
state = turnStateFromEvent(
287+
state,
288+
{ type: "tool.done", data: { result: { callId: `c${cycleIndex}` } } },
289+
++clock,
290+
)
291+
if (trippedAtCycle === -1 && state.repeating) trippedAtCycle = cycleIndex
292+
}
293+
expect(state.repeating).toBe(true)
294+
expect(trippedAtCycle).toBeGreaterThan(-1)
295+
expect(trippedAtCycle).toBeLessThan(30)
255296
})
256297

257298
test("a short narration line repeated before each of nine tool calls is not a loop", () => {

src/tui-opentui/turn-state.ts

Lines changed: 113 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,47 @@ const STREAM_TEXT_BUFFER_CHARS = 8_000
2727
// cost proportional to output, not token count.
2828
const REPETITION_CHECK_INTERVAL_CHARS = 40
2929

30+
// Cycles shorter than this are skipped when updating the cross-cycle streak:
31+
// a bare tool call with no preceding text, or a one-word aside, is too little
32+
// signal to compare — matching by coincidence is common at this length, and
33+
// skipping neither breaks nor extends a streak already in progress.
34+
const CYCLE_FINGERPRINT_MIN_CHARS = 24
35+
36+
// How many consecutive cycles must fingerprint identically before it counts
37+
// as a loop rather than ordinary phrasing. The fingerprint covers the whole
38+
// cycle's text, so any variation at all — a changing filename, index, or
39+
// detail ("Editing src/module_47.ts next.") produces a different hash and
40+
// never advances the streak, no matter how many cycles run. That is what
41+
// makes this bar tolerable at a bare-number glance: it only ever governs
42+
// content that is byte-for-byte invariant, cycle after cycle, which ordinary
43+
// narration is not. The verified false positive (CL-5577) is a model saying
44+
// the exact same short line before each of 9-12 separate tool calls in one
45+
// turn — that must not abort, so the bar sits above that range with
46+
// headroom. Set well below the reported repro (an unvarying 46-char block
47+
// repeated every cycle for 500 cycles, which the unconditional-reset version
48+
// never caught at all): at this bar the streak still trips a small fraction
49+
// of the way in, a few thousand characters and under two dozen tool calls,
50+
// not after 500 and 88,000 characters. The remaining exposure is narrow and
51+
// explicit: an exact, invariant line of at least `CYCLE_FINGERPRINT_MIN_CHARS`
52+
// chars repeated with zero variation for this many cycles running straight
53+
// through tool calls — contentless boilerplate, not narration.
54+
const CYCLE_REPETITION_MIN_CONSECUTIVE = 20
55+
56+
/**
57+
* Cheap 32-bit fingerprint (FNV-1a) of one completed cycle's text, so the
58+
* cross-cycle streak only has to remember a short string per turn rather than
59+
* retain raw text across cycles — the retained text is exactly what caused
60+
* the cross-cycle false positive this replaces.
61+
*/
62+
function cycleFingerprint(text: string): string {
63+
let hash = 0x811c9dc5
64+
for (let i = 0; i < text.length; i++) {
65+
hash ^= text.charCodeAt(i)
66+
hash = Math.imul(hash, 0x01000193)
67+
}
68+
return (hash >>> 0).toString(16)
69+
}
70+
3071
export type QuotaWait = {
3172
readonly retryAfterMs: number
3273
readonly retryAt: number
@@ -80,6 +121,20 @@ export type TurnState = {
80121
* rather than the whole turn's count.
81122
*/
82123
readonly repeatingSinceTokenCount: number | null
124+
/**
125+
* Fingerprint of the most recently completed streaming cycle (set at each
126+
* tool-call boundary), used only to compare against the next cycle's
127+
* fingerprint. Not the raw text — carrying that across cycles is what
128+
* caused repeats to accumulate into a false positive across tool calls.
129+
*/
130+
readonly cycleFingerprint: string | null
131+
/**
132+
* Consecutive completed cycles whose fingerprint matched the one before it.
133+
* A model repeating the same block every cycle, with a tool call in
134+
* between each, builds this streak even though no single cycle's text ever
135+
* gets long enough to trip `detectRepetition` on its own.
136+
*/
137+
readonly consecutiveMatchingCycles: number
83138
}
84139

85140
export function initialTurnState(nowMs: number): TurnState {
@@ -98,6 +153,8 @@ export function initialTurnState(nowMs: number): TurnState {
98153
repetitionCheckedAt: 0,
99154
repeating: false,
100155
repeatingSinceTokenCount: null,
156+
cycleFingerprint: null,
157+
consecutiveMatchingCycles: 0,
101158
}
102159
}
103160

@@ -118,6 +175,8 @@ export function turnStateOnSubmit(state: TurnState, nowMs: number): TurnState {
118175
repetitionCheckedAt: 0,
119176
repeating: false,
120177
repeatingSinceTokenCount: null,
178+
cycleFingerprint: null,
179+
consecutiveMatchingCycles: 0,
121180
}
122181
}
123182

@@ -251,7 +310,12 @@ const streaming = (
251310
const streamCharsSeen = state.streamCharsSeen + text.length
252311
const due =
253312
streamCharsSeen - state.repetitionCheckedAt >= REPETITION_CHECK_INTERVAL_CHARS
254-
const repeating = due ? detectRepetition(streamText).repeating : state.repeating
313+
// Once true, stays true for the rest of the turn — a fresh cycle's buffer
314+
// starts empty (see `runningTool`) and would otherwise read back false on
315+
// the next check, un-latching a real detection the moment a tool call
316+
// interrupts the stream.
317+
const repeating =
318+
state.repeating || (due && detectRepetition(streamText).repeating)
255319
return {
256320
...state,
257321
status: state.status === "blocked" ? "blocked" : "running",
@@ -271,29 +335,58 @@ const streaming = (
271335
}
272336
}
273337

274-
// A tool call ends the current streaming cycle. Clearing the repetition
275-
// buffer here, rather than only on a fresh turn, is what keeps repeats from
276-
// accumulating across `connector.reply` boundaries — the mechanism that
277-
// turned nine separate narration lines ("Let me check the next file now.")
278-
// into one apparent loop and killed an ordinary turn mid-flight.
338+
// A tool call ends the current streaming cycle. The raw text buffer is
339+
// discarded here, rather than only on a fresh turn, so repeats never
340+
// accumulate across `connector.reply` boundaries — the mechanism that turned
341+
// nine separate narration lines ("Let me check the next file now.") into one
342+
// apparent loop and killed an ordinary turn mid-flight. But discarding the
343+
// buffer outright would also erase a genuine loop that interleaves a tool
344+
// call between every repeat of the same block, so a fingerprint of the
345+
// completed cycle is kept and compared against the next one: several
346+
// consecutive cycles fingerprinting alike is what that shape of loop looks
347+
// like, and nine different narration lines never do.
279348
const runningTool = (
280349
state: TurnState,
281350
name: string | null,
282351
nowMs: number,
283-
): TurnState => ({
284-
...state,
285-
status: state.status === "blocked" ? "blocked" : "running",
286-
isProcessing: true,
287-
awaitingResponse: false,
288-
streamingType: "tool",
289-
currentToolName: name ?? state.currentToolName,
290-
lastActivityAt: nowMs,
291-
streamText: "",
292-
streamCharsSeen: 0,
293-
repetitionCheckedAt: 0,
294-
repeating: false,
295-
repeatingSinceTokenCount: null,
296-
})
352+
): TurnState => {
353+
const cycleText = state.streamText
354+
const longEnoughToCompare = cycleText.length >= CYCLE_FINGERPRINT_MIN_CHARS
355+
const fingerprint = longEnoughToCompare
356+
? cycleFingerprint(cycleText)
357+
: null
358+
const matchedPrevious =
359+
longEnoughToCompare &&
360+
state.cycleFingerprint !== null &&
361+
fingerprint === state.cycleFingerprint
362+
const consecutiveMatchingCycles = matchedPrevious
363+
? state.consecutiveMatchingCycles + 1
364+
: longEnoughToCompare
365+
? 1
366+
: state.consecutiveMatchingCycles
367+
const repeating =
368+
state.repeating || consecutiveMatchingCycles >= CYCLE_REPETITION_MIN_CONSECUTIVE
369+
370+
return {
371+
...state,
372+
status: state.status === "blocked" ? "blocked" : "running",
373+
isProcessing: true,
374+
awaitingResponse: false,
375+
streamingType: "tool",
376+
currentToolName: name ?? state.currentToolName,
377+
lastActivityAt: nowMs,
378+
streamText: "",
379+
streamCharsSeen: 0,
380+
repetitionCheckedAt: 0,
381+
repeating,
382+
repeatingSinceTokenCount:
383+
repeating && state.repeatingSinceTokenCount === null
384+
? state.streamTokenCount
385+
: state.repeatingSinceTokenCount,
386+
cycleFingerprint: longEnoughToCompare ? fingerprint : state.cycleFingerprint,
387+
consecutiveMatchingCycles,
388+
}
389+
}
297390

298391
/**
299392
* Fold one inbound event (reactor-shaped or canonical bridge-shaped) into the

0 commit comments

Comments
 (0)