@@ -27,6 +27,38 @@ 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 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+
3062export 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
85131export 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.
279339const 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