Skip to content

Commit 4e35c54

Browse files
committed
Drop stall-watchdog re-export shim, point consumers at repetition.ts directly
Removed the RepetitionCheck/detectRepetition compatibility re-exports from stall-watchdog.ts — with only two consumers (turn-state.ts and stall-watchdog.test.ts) an alias for an internal module wasn't earning its keep. Both now import directly from subagent/repetition.js. Renamed the moved function to detectTailCharLoop (TailCharLoopCheck) so it reads clearly next to subagent/repetition.ts's existing detectRepetition (the KMP + digit-folding detector for streamed model text) rather than shadowing its name. Threshold values (8 / 24 / 2000 / 8) unchanged. CL-6939 / CL-6790.
1 parent 82c40e8 commit 4e35c54

4 files changed

Lines changed: 22 additions & 31 deletions

File tree

‎src/subagent/repetition.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* threshold, so the run loop can abort the cycle instead of streaming forever.
99
*
1010
* Also home to the TUI stall-watchdog's character-level tail-repetition
11-
* guard (`detectCharRepetition`) — a separate, simpler check consolidated
11+
* guard (`detectTailCharLoop`) — a separate, simpler check consolidated
1212
* here from tui/stall-watchdog.ts so the two repetition detectors live in one
1313
* module instead of two. It solves the same "is the tail looping" question
1414
* for a different consumer with different constants; see its own doc comment
@@ -293,7 +293,7 @@ const CHAR_REPETITION_MAX_PERIOD_CAP = 2_000;
293293
// cycle spans two full sentences, comfortably above it.
294294
const CHAR_REPETITION_MIN_DISTINCT_CHARS = 8;
295295

296-
export type CharRepetitionCheck = SequencePeriodCheck;
296+
export type TailCharLoopCheck = SequencePeriodCheck;
297297

298298
/**
299299
* Whether the tail of `text` is an exact repeat of some short span at least
@@ -315,7 +315,7 @@ export type CharRepetitionCheck = SequencePeriodCheck;
315315
* see the config comments on each for why neither threshold set may be
316316
* changed to match the other.
317317
*/
318-
export function detectCharRepetition(text: string): CharRepetitionCheck {
318+
export function detectTailCharLoop(text: string): TailCharLoopCheck {
319319
return detectSequencePeriod(text.split(""), {
320320
minPeriod: CHAR_REPETITION_MIN_PERIOD,
321321
maxPeriod: CHAR_REPETITION_MAX_PERIOD_CAP,

‎src/tui/stall-watchdog.test.ts‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test";
22

33
import {
44
applyStallRecovery,
5-
detectRepetition,
65
isStalledForDisplay,
76
repetitionRecoveryMessage,
87
shouldAbortForStall,
@@ -12,6 +11,7 @@ import {
1211
STALL_RECOVERY_MESSAGE,
1312
STALL_TIMEOUT_MS,
1413
} from "./stall-watchdog.js";
14+
import { detectTailCharLoop } from "../subagent/repetition.js";
1515

1616
describe("shouldAbortForStall", () => {
1717
// Mid-stream hang: tokens already flowed, then everything went silent —
@@ -140,14 +140,14 @@ describe("applyStallRecovery", () => {
140140
});
141141
});
142142

143-
describe("detectRepetition", () => {
143+
describe("detectTailCharLoop", () => {
144144
test("finds nothing in fresh, varied output", () => {
145145
const text = [
146146
"I'll check the callId emission path first.",
147147
"Running the search now.",
148148
"Found three matches across the module.",
149149
].join("\n");
150-
expect(detectRepetition(text).repeating).toBe(false);
150+
expect(detectTailCharLoop(text).repeating).toBe(false);
151151
});
152152

153153
// The captured incident: the two sentences ran together with no line break
@@ -158,7 +158,7 @@ describe("detectRepetition", () => {
158158
"I'll verify callId emission and remaining edges, then write the ranked findings.";
159159
const line2 = "Confirming callId emission, then writing the ranked findings.";
160160
const text = Array(30).fill(`${line1}${line2}`).join("");
161-
const check = detectRepetition(text);
161+
const check = detectTailCharLoop(text);
162162
expect(check.repeating).toBe(true);
163163
expect(check.period).toBe(line1.length + line2.length);
164164
});
@@ -168,7 +168,7 @@ describe("detectRepetition", () => {
168168
// chars keeps it above REPETITION_MIN_DISTINCT_CHARS.
169169
test("flags a short-phrase loop with a 10-char unit", () => {
170170
const text = "Groaning. ".repeat(60);
171-
const check = detectRepetition(text);
171+
const check = detectTailCharLoop(text);
172172
expect(check.repeating).toBe(true);
173173
expect(check.period).toBe("Groaning. ".length);
174174
});
@@ -180,35 +180,35 @@ describe("detectRepetition", () => {
180180
// Fewer than the occurrence threshold: a model can legitimately restate
181181
// a step once or twice across tool-call cycles without looping.
182182
const text = Array(4).fill(`${line1}${line2}`).join("");
183-
expect(detectRepetition(text).repeating).toBe(false);
183+
expect(detectTailCharLoop(text).repeating).toBe(false);
184184
});
185185

186186
test("does not flag a repeated markdown table separator row", () => {
187187
const row = "| ---------------------- | ---------------------- |";
188188
const text = Array(6).fill(row).join("\n");
189-
expect(detectRepetition(text).repeating).toBe(false);
189+
expect(detectTailCharLoop(text).repeating).toBe(false);
190190
});
191191

192192
test("does not flag a few identical code lines", () => {
193193
const line = " const result = await fetchData(request, options, context)";
194194
const text = Array(3).fill(line).join("\n");
195-
expect(detectRepetition(text).repeating).toBe(false);
195+
expect(detectTailCharLoop(text).repeating).toBe(false);
196196
});
197197

198198
test("ignores short recurring fragments", () => {
199199
const text = Array(10).fill("ok").join(" ");
200-
expect(detectRepetition(text).repeating).toBe(false);
200+
expect(detectTailCharLoop(text).repeating).toBe(false);
201201
});
202202

203203
// A monochrome run is periodic at every period by construction — the
204204
// easiest thing to false-trigger on if entropy is not checked.
205205
test("does not flag a long run of the same character", () => {
206-
expect(detectRepetition("x".repeat(500)).repeating).toBe(false);
206+
expect(detectTailCharLoop("x".repeat(500)).repeating).toBe(false);
207207
});
208208

209209
test("does not flag a repeated horizontal rule", () => {
210210
const text = Array(10).fill("----------------------------").join("\n");
211-
expect(detectRepetition(text).repeating).toBe(false);
211+
expect(detectTailCharLoop(text).repeating).toBe(false);
212212
});
213213
});
214214

‎src/tui/stall-watchdog.ts‎

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { TurnStatus } from "./session-chrome.js";
2-
import { detectCharRepetition, type CharRepetitionCheck } from "../subagent/repetition.js";
32

43
// How long the run can be continuously awaiting a response with no new content
54
// before the watchdog fires and aborts the in-flight request.
@@ -26,14 +25,6 @@ export interface ShouldAbortForStallArgs {
2625
readonly activeToolCalls: readonly string[];
2726
}
2827

29-
// The character-level tail-repetition search (constants, doc comments, and
30-
// implementation) lives in ../subagent/repetition.ts as `detectCharRepetition`
31-
// — consolidated there so this module and the subagent module do not each
32-
// hand-roll their own repetition detector. Thresholds are unchanged.
33-
export type RepetitionCheck = CharRepetitionCheck;
34-
35-
export const detectRepetition = detectCharRepetition;
36-
3728
/**
3829
* Whether silence of `thresholdMs` counts as stuck at all. Shared by the notice
3930
* and the abort so they never disagree about which runs are stalled — only

‎src/tui/turn-state.ts‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,15 @@
1111

1212
import { type } from "arktype";
1313

14-
import { detectRepetition } from "./stall-watchdog.js";
14+
import { detectTailCharLoop } from "../subagent/repetition.js";
1515
import type { TurnStatus } from "./session-chrome.js";
1616

1717
// Bound on the accumulated stream text kept for repetition checks. Comfortably
18-
// larger than the periods `detectRepetition` can confirm, so trimming never
18+
// larger than the periods `detectTailCharLoop` can confirm, so trimming never
1919
// drops content the check still needs.
2020
const STREAM_TEXT_BUFFER_CHARS = 8_000;
2121

22-
// `detectRepetition` walks a character-level period search; cheap per call,
22+
// `detectTailCharLoop` walks a character-level period search; cheap per call,
2323
// but the reactor loop can emit a delta per token, and running it on every
2424
// single one makes it the hottest thing in that loop for no benefit — a
2525
// repeating tail does not appear or disappear between two three-character
@@ -108,7 +108,7 @@ export interface TurnState {
108108
* narrating a similar short line before each of several tool calls is
109109
* ordinary and must not accumulate into an apparent loop, whereas a
110110
* genuinely degenerate model repeats within one unbroken stream. Bounded to
111-
* `STREAM_TEXT_BUFFER_CHARS`; feeds `detectRepetition`, nothing else.
111+
* `STREAM_TEXT_BUFFER_CHARS`; feeds `detectTailCharLoop`, nothing else.
112112
*/
113113
readonly streamText: string;
114114
/**
@@ -117,9 +117,9 @@ export interface TurnState {
117117
* throttle below tell "40 more chars arrived" from "the buffer is full."
118118
*/
119119
readonly streamCharsSeen: number;
120-
/** `streamCharsSeen` as of the last `detectRepetition` call. */
120+
/** `streamCharsSeen` as of the last `detectTailCharLoop` call. */
121121
readonly repetitionCheckedAt: number;
122-
/** Result of the most recent `detectRepetition` check on `streamText`. */
122+
/** Result of the most recent `detectTailCharLoop` check on `streamText`. */
123123
readonly repeating: boolean;
124124
/**
125125
* `streamTokenCount` at the moment repetition was first observed this turn.
@@ -138,7 +138,7 @@ export interface TurnState {
138138
* Consecutive completed cycles whose fingerprint matched the one before it.
139139
* A model repeating the same block every cycle, with a tool call in
140140
* between each, builds this streak even though no single cycle's text ever
141-
* gets long enough to trip `detectRepetition` on its own.
141+
* gets long enough to trip `detectTailCharLoop` on its own.
142142
*/
143143
readonly consecutiveMatchingCycles: number;
144144
/**
@@ -458,7 +458,7 @@ const streaming = (
458458
// starts empty (see `runningTool`) and would otherwise read back false on
459459
// the next check, un-latching a real detection the moment a tool call
460460
// interrupts the stream.
461-
const repeating = state.repeating || (due && detectRepetition(streamText).repeating);
461+
const repeating = state.repeating || (due && detectTailCharLoop(streamText).repeating);
462462
return {
463463
...state,
464464
status: state.status === "blocked" ? "blocked" : "running",

0 commit comments

Comments
 (0)