@@ -21,6 +21,43 @@ export type ShouldAbortForStallArgs = {
2121 readonly streamingType : "text" | "thinking" | "tool" | null
2222}
2323
24+ // A repeated line has to be long enough that short, legitimately-recurring
25+ // fragments ("Let me check.") do not trip the guard.
26+ const REPETITION_MIN_LINE_LENGTH = 20
27+ // How far back into the streamed text to look for repeats. Bounded so the
28+ // check stays cheap however long the turn runs.
29+ const REPETITION_LOOKBACK_LINES = 12
30+ // Three verbatim repeats of the same substantial line is not a coincidence
31+ // of phrasing — it is the model looping.
32+ const REPETITION_MIN_OCCURRENCES = 3
33+
34+ export type RepetitionCheck = {
35+ readonly repeating : boolean
36+ readonly repeatedLine : string | null
37+ readonly occurrences : number
38+ }
39+
40+ /**
41+ * Whether the tail of the streamed text is dominated by a line repeated
42+ * verbatim. Pure text-in, decision-out: the caller owns accumulating the
43+ * buffer across deltas and cycles within a turn.
44+ */
45+ export function detectRepetition ( text : string ) : RepetitionCheck {
46+ const lines = text
47+ . split ( "\n" )
48+ . map ( ( line ) => line . trim ( ) )
49+ . filter ( ( line ) => line . length >= REPETITION_MIN_LINE_LENGTH )
50+ const tail = lines . slice ( - REPETITION_LOOKBACK_LINES )
51+ const counts = new Map < string , number > ( )
52+ for ( const line of tail ) counts . set ( line , ( counts . get ( line ) ?? 0 ) + 1 )
53+ for ( const [ line , occurrences ] of counts ) {
54+ if ( occurrences >= REPETITION_MIN_OCCURRENCES ) {
55+ return { repeating : true , repeatedLine : line , occurrences }
56+ }
57+ }
58+ return { repeating : false , repeatedLine : null , occurrences : 0 }
59+ }
60+
2461/**
2562 * Whether silence of `thresholdMs` counts as stuck at all. Shared by the notice
2663 * and the abort so they never disagree about which runs are stalled — only
@@ -62,19 +99,35 @@ export function shouldNoticeStall(args: ShouldNoticeStallArgs): boolean {
6299 return silentPastThreshold ( args , args . stallNoticeMs )
63100}
64101
65- /** Shown while the run is silent; names the state and the way out. */
102+ /**
103+ * Shown while nothing is arriving at all. Never fires while tokens are
104+ * flowing — a model looping on repeated content is still producing output,
105+ * so it is reported by `repetitionRecoveryMessage` instead, not this one.
106+ */
66107export const STALL_NOTICE_MESSAGE = "no response for a while — ctrl+c to interrupt"
67108
68109export const STALL_RECOVERY_MESSAGE =
69110 "stopped after no response — send again to retry"
70111
112+ /**
113+ * Shown once a repeated line aborts the turn. Named as degeneration, not a
114+ * generic failure, so a retry reads as the reasonable next step rather than
115+ * papering over a suspected hang or network fault.
116+ */
117+ export function repetitionRecoveryMessage ( repeatedTokens : number ) : string {
118+ return `stopped after repeating itself — ~${ repeatedTokens } tokens looped — send again to retry`
119+ }
120+
71121export type ApplyStallRecoveryDeps = {
72122 /** Abort the in-flight run through the session port. */
73123 readonly abort : ( ) => void
74124 readonly notify : ( message : string ) => void
75125}
76126
77- export function applyStallRecovery ( deps : ApplyStallRecoveryDeps ) : void {
127+ export function applyStallRecovery (
128+ deps : ApplyStallRecoveryDeps ,
129+ message : string = STALL_RECOVERY_MESSAGE ,
130+ ) : void {
78131 deps . abort ( )
79- deps . notify ( STALL_RECOVERY_MESSAGE )
132+ deps . notify ( message )
80133}
0 commit comments