Skip to content

Commit 2d0825a

Browse files
committed
Fingerprint each streaming cycle instead of discarding repetition state outright
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 cee303d commit 2d0825a

2 files changed

Lines changed: 152 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: 104 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,38 @@ 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 verified false positive (CL-
38+
// 5577) is a model saying the exact same short line before each of 9-12
39+
// separate tool calls in one turn — that must not abort, so the bar sits
40+
// above that range with headroom. Set well below the reported repro (an
41+
// unvarying 46-char block repeated every cycle for 500 cycles, which the
42+
// unconditional-reset version never caught at all): at this bar the streak
43+
// still trips a small fraction of the way in, a few thousand characters and
44+
// under two dozen tool calls, not after 500 and 88,000 characters.
45+
const CYCLE_REPETITION_MIN_CONSECUTIVE = 20
46+
47+
/**
48+
* Cheap 32-bit fingerprint (FNV-1a) of one completed cycle's text, so the
49+
* cross-cycle streak only has to remember a short string per turn rather than
50+
* retain raw text across cycles — the retained text is exactly what caused
51+
* the cross-cycle false positive this replaces.
52+
*/
53+
function cycleFingerprint(text: string): string {
54+
let hash = 0x811c9dc5
55+
for (let i = 0; i < text.length; i++) {
56+
hash ^= text.charCodeAt(i)
57+
hash = Math.imul(hash, 0x01000193)
58+
}
59+
return (hash >>> 0).toString(16)
60+
}
61+
3062
export type QuotaWait = {
3163
readonly retryAfterMs: number
3264
readonly retryAt: number
@@ -80,6 +112,20 @@ export type TurnState = {
80112
* rather than the whole turn's count.
81113
*/
82114
readonly repeatingSinceTokenCount: number | null
115+
/**
116+
* Fingerprint of the most recently completed streaming cycle (set at each
117+
* tool-call boundary), used only to compare against the next cycle's
118+
* fingerprint. Not the raw text — carrying that across cycles is what
119+
* caused repeats to accumulate into a false positive across tool calls.
120+
*/
121+
readonly cycleFingerprint: string | null
122+
/**
123+
* Consecutive completed cycles whose fingerprint matched the one before it.
124+
* A model repeating the same block every cycle, with a tool call in
125+
* between each, builds this streak even though no single cycle's text ever
126+
* gets long enough to trip `detectRepetition` on its own.
127+
*/
128+
readonly consecutiveMatchingCycles: number
83129
}
84130

85131
export function initialTurnState(nowMs: number): TurnState {
@@ -98,6 +144,8 @@ export function initialTurnState(nowMs: number): TurnState {
98144
repetitionCheckedAt: 0,
99145
repeating: false,
100146
repeatingSinceTokenCount: null,
147+
cycleFingerprint: null,
148+
consecutiveMatchingCycles: 0,
101149
}
102150
}
103151

@@ -118,6 +166,8 @@ export function turnStateOnSubmit(state: TurnState, nowMs: number): TurnState {
118166
repetitionCheckedAt: 0,
119167
repeating: false,
120168
repeatingSinceTokenCount: null,
169+
cycleFingerprint: null,
170+
consecutiveMatchingCycles: 0,
121171
}
122172
}
123173

@@ -251,7 +301,12 @@ const streaming = (
251301
const streamCharsSeen = state.streamCharsSeen + text.length
252302
const due =
253303
streamCharsSeen - state.repetitionCheckedAt >= REPETITION_CHECK_INTERVAL_CHARS
254-
const repeating = due ? detectRepetition(streamText).repeating : state.repeating
304+
// Once true, stays true for the rest of the turn — a fresh cycle's buffer
305+
// starts empty (see `runningTool`) and would otherwise read back false on
306+
// the next check, un-latching a real detection the moment a tool call
307+
// interrupts the stream.
308+
const repeating =
309+
state.repeating || (due && detectRepetition(streamText).repeating)
255310
return {
256311
...state,
257312
status: state.status === "blocked" ? "blocked" : "running",
@@ -271,29 +326,58 @@ const streaming = (
271326
}
272327
}
273328

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.
329+
// A tool call ends the current streaming cycle. The raw text buffer is
330+
// discarded here, rather than only on a fresh turn, so repeats never
331+
// accumulate across `connector.reply` boundaries — the mechanism that turned
332+
// nine separate narration lines ("Let me check the next file now.") into one
333+
// apparent loop and killed an ordinary turn mid-flight. But discarding the
334+
// buffer outright would also erase a genuine loop that interleaves a tool
335+
// call between every repeat of the same block, so a fingerprint of the
336+
// completed cycle is kept and compared against the next one: several
337+
// consecutive cycles fingerprinting alike is what that shape of loop looks
338+
// like, and nine different narration lines never do.
279339
const runningTool = (
280340
state: TurnState,
281341
name: string | null,
282342
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-
})
343+
): TurnState => {
344+
const cycleText = state.streamText
345+
const longEnoughToCompare = cycleText.length >= CYCLE_FINGERPRINT_MIN_CHARS
346+
const fingerprint = longEnoughToCompare
347+
? cycleFingerprint(cycleText)
348+
: null
349+
const matchedPrevious =
350+
longEnoughToCompare &&
351+
state.cycleFingerprint !== null &&
352+
fingerprint === state.cycleFingerprint
353+
const consecutiveMatchingCycles = matchedPrevious
354+
? state.consecutiveMatchingCycles + 1
355+
: longEnoughToCompare
356+
? 1
357+
: state.consecutiveMatchingCycles
358+
const repeating =
359+
state.repeating || consecutiveMatchingCycles >= CYCLE_REPETITION_MIN_CONSECUTIVE
360+
361+
return {
362+
...state,
363+
status: state.status === "blocked" ? "blocked" : "running",
364+
isProcessing: true,
365+
awaitingResponse: false,
366+
streamingType: "tool",
367+
currentToolName: name ?? state.currentToolName,
368+
lastActivityAt: nowMs,
369+
streamText: "",
370+
streamCharsSeen: 0,
371+
repetitionCheckedAt: 0,
372+
repeating,
373+
repeatingSinceTokenCount:
374+
repeating && state.repeatingSinceTokenCount === null
375+
? state.streamTokenCount
376+
: state.repeatingSinceTokenCount,
377+
cycleFingerprint: longEnoughToCompare ? fingerprint : state.cycleFingerprint,
378+
consecutiveMatchingCycles,
379+
}
380+
}
297381

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

0 commit comments

Comments
 (0)