@@ -27,6 +27,47 @@ const STREAM_TEXT_BUFFER_CHARS = 8_000
2727// cost proportional to output, not token count.
2828const 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+
3071export 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
85140export 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.
279348const 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