diff --git a/src/subagent/repetition.test.ts b/src/subagent/repetition.test.ts index cb79ca869..b56ed4ee3 100644 --- a/src/subagent/repetition.test.ts +++ b/src/subagent/repetition.test.ts @@ -3,9 +3,14 @@ import { describe, expect, test } from "bun:test"; import { appendCycleText, CYCLE_TEXT_CAP_CHARS } from "../session/stream-journal.js"; import { detectRepetition, + DEFAULT_CONTENTLESS_GROWTH_CONFIG, DEFAULT_REPETITION_CONFIG, + DEFAULT_TEXT_FOLDED_REPETITION_CONFIG, DEFAULT_THINKING_REPETITION_CONFIG, + INITIAL_CONTENTLESS_GROWTH_STATE, REPETITION_CHECK_INTERVAL_CHARS, + trackContentlessGrowth, + type ContentlessGrowthState, } from "./repetition.js"; // A monotonic counter that never repeats verbatim: each pair's numerator and @@ -21,8 +26,9 @@ const LOOP_SENTENCE = describe("detectRepetition", () => { test("flags a looped status sentence with an oscillating counter", () => { // Counters that flip between values keep the raw text periodic β€” the - // period just spans one full oscillation (two sentences here). - const iterations = Array.from({ length: 20 }, (_, i) => + // period just spans one full oscillation (two sentences here), so hitting + // the repeat threshold takes twice as many iterations. + const iterations = Array.from({ length: 40 }, (_, i) => LOOP_SENTENCE.replace("0/1.0", `${i % 2}/1.0`), ); const text = `some earlier legitimate prose about the task. ${iterations.join("")}`; @@ -73,6 +79,26 @@ describe("detectRepetition", () => { expect(detectRepetition(looped)).not.toBeNull(); }); + test("flags a short-phrase loop (10-char unit, observed live)", () => { + // The second captured incident: "Groaning. " emitted ~1,363 times. The + // old 16-char window floor never saw a 10-char unit. + const text = "Groaning. ".repeat(1300); + const hit = detectRepetition(text); + expect(hit).not.toBeNull(); + expect(hit?.window).toBe("Groaning. "); + expect(hit?.repeats).toBeGreaterThanOrEqual(DEFAULT_REPETITION_CONFIG.repeatThreshold); + }); + + test("does not flag a repeated markdown table separator row", () => { + const row = "| ---------------------- | ---------------------- |\n"; + expect(detectRepetition(`| Left | Right |\n${row.repeat(6)}`)).toBeNull(); + }); + + test("does not flag a few identical code lines", () => { + const line = " const result = await fetchData(request, options, context)\n"; + expect(detectRepetition(line.repeat(3))).toBeNull(); + }); + test("returns null for text shorter than one full window set", () => { expect(detectRepetition("short")).toBeNull(); expect(detectRepetition("")).toBeNull(); @@ -170,12 +196,135 @@ test("flags a loop that injects zero-width spaces between identical windows", () // detector misses the loop (observed in live thrash fleets). const window = "I'll open the remaining source files and implement the activity preview. "; const zwsp = "\u200B"; - const text = (window + zwsp).repeat(12); + const text = (window + zwsp).repeat(20); const hit = detectRepetition(text); expect(hit).not.toBeNull(); expect(hit?.repeats).toBeGreaterThanOrEqual(DEFAULT_REPETITION_CONFIG.repeatThreshold); }); +describe("folded text pass (DEFAULT_TEXT_FOLDED_REPETITION_CONFIG)", () => { + // Mirrors run.ts: digit-preserving default first, capped folded pass second. + function detectText(text: string) { + return ( + detectRepetition(text) ?? + detectRepetition(text, DEFAULT_TEXT_FOLDED_REPETITION_CONFIG, { normalizeDigits: true }) + ); + } + + test("flags an incrementing counter flood in visible text", () => { + // Observed live: "14279 14280 14281…" streamed to inference-error. + const text = Array.from({ length: 500 }, (_, i) => `${14279 + i} `).join(""); + expect(detectRepetition(text)).toBeNull(); + expect(detectText(text)).not.toBeNull(); + }); + + test("flags an incrementing pair-counter flood", () => { + // Observed live: "5620/5620. 5621/5621. …" + const text = Array.from({ length: 300 }, (_, i) => `${5620 + i}/${5620 + i}. `).join(""); + expect(detectText(text)).not.toBeNull(); + }); + + test("flags a repeated-timestamp flood", () => { + // Observed live: "18:22:27." emitted hundreds of times, with drift. + const text = Array.from({ length: 300 }, (_, i) => `18:22:${27 + (i % 30)}. `).join(""); + expect(detectText(text)).not.toBeNull(); + }); + + test("flags a zero-percent flood", () => { + // "0% " is a 3-char unit β€” under the plain 8-char window floor. + const text = "0% ".repeat(200); + expect(detectRepetition(text)).toBeNull(); + expect(detectText(text)).not.toBeNull(); + }); + + test("flags fence and brace floods", () => { + expect(detectText("```\n".repeat(100))).not.toBeNull(); + expect(detectText("}\n".repeat(200))).not.toBeNull(); + }); + + test("flags an emoji flood (surrogate-pair unit)", () => { + // Observed live: "πŸ€” " Γ—~40K chars. The unit is 3 UTF-16 units; the + // code-point reversal must keep the pair intact for it to stay periodic. + const text = "πŸ€” ".repeat(2000); + expect(detectText(text)).not.toBeNull(); + }); + + test("does not flag a numbered list under the folded text pass", () => { + // Folds to a ~47-char period β€” refused by maxFoldedPeriodChars. + const items = Array.from( + { length: 400 }, + (_, i) => `${i + 1}. Ran batch ${i + 1} and verified ${i * 3} records migrated\n`, + ).join(""); + expect(detectText(`Migration progress:\n${items}`)).toBeNull(); + }); + + test("does not flag a digit-varying markdown table under the folded text pass", () => { + const rows = Array.from( + { length: 40 }, + (_, i) => `| 202${i % 10} | ${i * 10} requests | ${i} errors |\n`, + ).join(""); + expect(detectText(`| Year | Volume | Errors |\n|---|---|---|\n${rows}`)).toBeNull(); + }); + + test("a short user-requested enumeration stays under the folded repeat bar", () => { + // "print 1..40" folds to "0 " Γ—40 β€” under repeatThreshold 64. + const text = Array.from({ length: 40 }, (_, i) => `${i + 1} `).join(""); + expect(detectText(text)).toBeNull(); + }); +}); + +describe("trackContentlessGrowth", () => { + function feed(tokens: readonly string[]): boolean { + let state: ContentlessGrowthState = INITIAL_CONTENTLESS_GROWTH_STATE; + for (const token of tokens) { + const next = trackContentlessGrowth(state, token); + if (next.hit) return true; + state = next.state; + } + return false; + } + + test("flags a zero-width flood (ZWNJ/ZWJ walls, observed live)", () => { + // Observed live: 500–53,000 U+200C/U+200D chars per stream. + // detectRepetition strips invisibles before checking, so it must not be + // the only line of defense. + const flood = Array.from({ length: 60 }, () => "β€Œβ€".repeat(32)); + expect(detectRepetition(flood.join(""))).toBeNull(); + expect(feed(flood)).toBe(true); + }); + + test("flags a flood even when prefixed by healthy prose", () => { + const tokens = [ + "Let me look at the config first. ".repeat(4), + ...Array.from({ length: 100 }, () => "‍".repeat(64)), + ]; + expect(feed(tokens)).toBe(true); + }); + + test("does not flag ordinary prose or sparse code", () => { + const tokens = Array.from( + { length: 200 }, + (_, i) => ` const value${i} = await compute(input${i});\n\n`, + ); + expect(feed(tokens)).toBe(false); + }); + + test("a visible-rich window re-arms rather than latching", () => { + // Enough visible content inside every window keeps the guard quiet no + // matter how long the stream runs. + const tokens = Array.from( + { length: 50 }, + () => `${"β€Œ".repeat(100)} some genuinely visible sentence with plenty of characters. `, + ); + expect(feed(tokens)).toBe(false); + }); + + test("whitespace does not count as visible content", () => { + const raw = " \n\t".repeat(DEFAULT_CONTENTLESS_GROWTH_CONFIG.rawWindowChars); + expect(feed([raw])).toBe(true); + }); +}); + describe("appendCycleText", () => { test("keeps only the tail past the cap", () => { const text = appendCycleText("a".repeat(10), "b".repeat(10), 15); diff --git a/src/subagent/repetition.ts b/src/subagent/repetition.ts index c5d794dff..1c8c80e1f 100644 --- a/src/subagent/repetition.ts +++ b/src/subagent/repetition.ts @@ -29,11 +29,18 @@ export interface RepetitionConfig { maxFoldedPeriodChars?: number; } -// windowMinChars * repeatThreshold = 128 chars of exactly periodic text β€” -// far beyond anything legitimate prose or code produces by accident. +// windowMinChars * repeatThreshold = 8 * 16 = 128 chars of exactly periodic +// text β€” far beyond anything legitimate prose or code produces by accident. +// windowMinChars sits at 8 because live loops repeat units as short as 10 +// chars ("Groaning. " emitted ~1,363 times), which a 16-char floor never sees; +// the repeat threshold rises to 16 in compensation so the minimum periodic +// span stays at 128 chars. Structural tics that legitimately repeat ("- item\n" +// normalizes to 7 chars) still fall under the window floor, and longer healthy +// repeats (a 6-row table separator, 3 identical code lines, a repeat(4) +// paragraph) stay far below 16 consecutive repeats. export const DEFAULT_REPETITION_CONFIG: RepetitionConfig = { - windowMinChars: 16, - repeatThreshold: 8, + windowMinChars: 8, + repeatThreshold: 16, probeChars: 8192, }; @@ -65,6 +72,33 @@ export const DEFAULT_THINKING_REPETITION_CONFIG: RepetitionConfig = { maxFoldedPeriodChars: 16, }; +// Second, folded pass over *text* streams, for the flood shapes the default +// (digit-preserving) config is structurally blind to. Live traces ending as +// inference-error (670K wasted streamed chars) showed: incrementing counters +// ("14279 14280 14281…", "5620/5620. 5621/5621…" β€” never byte-periodic), +// repeated timestamps with drift ("18:22:27. 18:22:28."), "0% 0% 0%…", +// repeated "```\n" fences and "}\n" braces, and emoji floods ("πŸ€” " Γ—~40K +// chars). All fold (or already normalize) to a tiny 2–16 char period. +// +// The safety story for visible text is different from thinking, hence the +// stricter numbers rather than reusing the thinking config: +// - maxFoldedPeriodChars 16 refuses prose-shaped folds exactly as it does for +// thinking: a numbered-list or table row folds to a ~30–50 char period and +// never fires (see the normalize() rationale below). +// - windowMinChars 2 (vs thinking's 4) reaches the shortest observed units: +// "0% " and "} " fold to 2–3 chars, below the thinking floor. +// - repeatThreshold 64 (vs 32): the residual false-positive risk for text is +// a user-requested raw enumeration ("print 1..N"), which folds to "0 " β€” +// a legit dump of a few dozen numbers stays under 64 consecutive repeats, +// while the observed floods repeat thousands of times. Minimum folded +// periodic span: 2 * 64 = 128 chars. +export const DEFAULT_TEXT_FOLDED_REPETITION_CONFIG: RepetitionConfig = { + windowMinChars: 2, + repeatThreshold: 64, + probeChars: 8192, + maxFoldedPeriodChars: 16, +}; + export interface RepetitionHit { /** The normalized window that repeats. */ window: string; @@ -85,9 +119,11 @@ export interface RepetitionHit { // evade the detector. Observed thrash loops used U+200B between repeats. // `normalizeDigits` opts a caller into folding digit runs to one placeholder, // which collapses a monotonic counter's varying digits into a repeating unit. -// Reserved for thinking streams (see DEFAULT_THINKING_REPETITION_CONFIG), -// which are never rendered to the user and so carry none of the numbered-list -// / table false-positive risk that keeps text normalization digit-preserving. +// The digit-preserving default protects text streams' numbered lists and +// tables; folded detection runs on them only as a second pass capped to tiny +// periods (DEFAULT_TEXT_FOLDED_REPETITION_CONFIG), and uncapped-in-spirit on +// thinking streams (DEFAULT_THINKING_REPETITION_CONFIG), which are never +// rendered to the user and so carry less false-positive cost. function normalize(text: string, normalizeDigits: boolean): string { const stripped = text .replace(/[\u200B-\u200D\uFEFF\u00AD\u2060\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, "") @@ -121,6 +157,10 @@ export function detectRepetition( const tail = normalize(text.slice(-config.probeChars), opts.normalizeDigits ?? false); if (tail.length < config.windowMinChars * config.repeatThreshold) return null; + // Reverse by code point so surrogate pairs survive intact β€” an emoji flood + // ("πŸ€” " Γ—thousands) must stay byte-periodic after reversal. The prefix + // function and window extraction then both count plain UTF-16 units of the + // (pair-preserving) reversed string, so periods and slices stay consistent. const reversed = [...tail].reverse().join(""); const pi = prefixFunction(reversed); @@ -140,3 +180,68 @@ export function detectRepetition( } return best; } + +/** Tunables for the contentless-growth guard. */ +export interface ContentlessGrowthConfig { + /** Raw streamed chars per measurement window. */ + rawWindowChars: number; + /** A window with fewer visible chars than this counts as contentless. */ + minVisibleChars: number; +} + +// detectRepetition can never see a zero-width flood: normalize() strips +// invisibles *before* the periodicity check, so thousands of U+200C/U+200D +// chars (observed live: 500–53,000 per stream) collapse to a short, healthy- +// looking string. This guard watches the inverse signal β€” raw text keeps +// growing while its visible content does not. The bar: 2048 raw chars with +// fewer than 32 visible. Legitimate sparse output never approaches it β€” even +// a heavily indented code block or a wide table row carries hundreds of +// visible chars per 2048 raw, and a healthy stream would need 64:1 +// invisible-or-whitespace-to-content to trip it. +export const DEFAULT_CONTENTLESS_GROWTH_CONFIG: ContentlessGrowthConfig = { + rawWindowChars: 2048, + minVisibleChars: 32, +}; + +export interface ContentlessGrowthState { + /** Raw chars accumulated in the current window. */ + rawChars: number; + /** Visible (invisible-stripped, whitespace-removed) chars in the window. */ + visibleChars: number; +} + +export const INITIAL_CONTENTLESS_GROWTH_STATE: ContentlessGrowthState = { + rawChars: 0, + visibleChars: 0, +}; + +// Whitespace is removed rather than collapsed: a window of pure newlines is +// as contentless as one of pure ZWJ, and counting collapsed runs would let a +// space-interleaved flood (ZWJ, space, ZWJ, space, …) smuggle half its +// length past the epsilon. +function visibleLength(token: string): number { + return normalize(token, false).replace(/ /g, "").length; +} + +/** + * Fold one streamed token into the contentless-growth window. Returns the + * next state and whether the just-completed window was contentless: raw text + * grew by a full window while visible content grew less than the epsilon. + * Pure reducer β€” the caller owns the state across deltas; the window resets + * on completion either way, so one visible-rich window re-arms the guard. + */ +export function trackContentlessGrowth( + state: ContentlessGrowthState, + token: string, + config: ContentlessGrowthConfig = DEFAULT_CONTENTLESS_GROWTH_CONFIG, +): { state: ContentlessGrowthState; hit: boolean } { + const rawChars = state.rawChars + token.length; + const visibleChars = state.visibleChars + visibleLength(token); + if (rawChars < config.rawWindowChars) { + return { state: { rawChars, visibleChars }, hit: false }; + } + return { + state: INITIAL_CONTENTLESS_GROWTH_STATE, + hit: visibleChars < config.minVisibleChars, + }; +} diff --git a/src/subagent/run.ts b/src/subagent/run.ts index ed5751405..8afb1b436 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -55,6 +55,10 @@ import { consumeStream } from "../session/stream-consumer.js"; import { createCycleTextRecorder } from "../session/stream-journal.js"; import { detectRepetition, + INITIAL_CONTENTLESS_GROWTH_STATE, + trackContentlessGrowth, + type ContentlessGrowthState, + DEFAULT_TEXT_FOLDED_REPETITION_CONFIG, DEFAULT_THINKING_REPETITION_CONFIG, REPETITION_CHECK_INTERVAL_CHARS, type RepetitionHit, @@ -566,9 +570,34 @@ export async function runSubAgent(params: RunSubAgentParams): Promise { // Holder object rather than a let: the value is written inside the stream // sink closure, and flow analysis would otherwise narrow a let to null at // the later catch-site reads. - const repetition: { hit: RepetitionHit | null } = { hit: null }; + const repetition: { hit: RepetitionHit | null; contentless: boolean } = { + hit: null, + contentless: false, + }; let charsSinceRepetitionCheck = 0; let charsSinceThinkingRepetitionCheck = 0; + // Contentless-growth guard: catches zero-width floods (U+200C/U+200D walls) + // that detectRepetition is structurally blind to β€” its normalize() strips + // invisibles before the periodicity check. One window per stream kind. + let textContentless: ContentlessGrowthState = INITIAL_CONTENTLESS_GROWTH_STATE; + let thinkingContentless: ContentlessGrowthState = INITIAL_CONTENTLESS_GROWTH_STATE; + const degenerate = (): boolean => repetition.hit !== null || repetition.contentless; + const checkContentless = ( + state: ContentlessGrowthState, + token: string, + stream: string, + ): ContentlessGrowthState => { + const next = trackContentlessGrowth(state, token); + if (next.hit) { + repetition.contentless = true; + runController.abort( + new Error( + `sub-agent ${stream} output grew with only invisible/contentless characters (zero-width flood)`, + ), + ); + } + return next.state; + }; const streamSink = (event: ReactorEmittedEvent): void => { const name = subAgentToolName(event); if (name !== null) { @@ -576,15 +605,25 @@ export async function runSubAgent(params: RunSubAgentParams): Promise { params.onProgress?.({ description: params.description, toolName: name }); } cycleRecorder.handleEvent(event); - if (event.type === "inference.text.delta" && repetition.hit === null) { + if (event.type === "inference.text.delta" && !degenerate()) { // Count the raw token, not the buffer growth: once the buffer is pinned // at its cap, appends no longer change its length and a growth-based // counter would disarm detection for the rest of the turn. const token = (event.data as { token?: unknown }).token; + if (typeof token === "string") { + textContentless = checkContentless(textContentless, token, "streamed"); + } charsSinceRepetitionCheck += typeof token === "string" ? token.length : 0; - if (charsSinceRepetitionCheck >= REPETITION_CHECK_INTERVAL_CHARS) { + if (charsSinceRepetitionCheck >= REPETITION_CHECK_INTERVAL_CHARS && !degenerate()) { charsSinceRepetitionCheck = 0; - const hit = detectRepetition(cycleRecorder.text()); + // Two passes: digit-preserving for phrase loops, then the capped + // folded pass for counter/timestamp/fence/emoji floods that are + // never byte-periodic or fall under the plain window floor. + const hit = + detectRepetition(cycleRecorder.text()) ?? + detectRepetition(cycleRecorder.text(), DEFAULT_TEXT_FOLDED_REPETITION_CONFIG, { + normalizeDigits: true, + }); if (hit !== null) { repetition.hit = hit; runController.abort( @@ -597,10 +636,13 @@ export async function runSubAgent(params: RunSubAgentParams): Promise { // loop confined to them (the observed live thrash) never trips the // turn-level stop checks either β€” sample them on the same interval with // digit-normalized detection tuned for the collapsed period. - if (event.type === "inference.thinking.delta" && repetition.hit === null) { + if (event.type === "inference.thinking.delta" && !degenerate()) { const token = (event.data as { token?: unknown }).token; + if (typeof token === "string") { + thinkingContentless = checkContentless(thinkingContentless, token, "thinking"); + } charsSinceThinkingRepetitionCheck += typeof token === "string" ? token.length : 0; - if (charsSinceThinkingRepetitionCheck >= REPETITION_CHECK_INTERVAL_CHARS) { + if (charsSinceThinkingRepetitionCheck >= REPETITION_CHECK_INTERVAL_CHARS && !degenerate()) { charsSinceThinkingRepetitionCheck = 0; const hit = detectRepetition( cycleRecorder.thinkingText(), @@ -698,12 +740,10 @@ export async function runSubAgent(params: RunSubAgentParams): Promise { // events before bare-vs-salvage is decided. // Repetition and deadline are already known here; a parent cancel is // labeled cancelled even if the outcome below resolves to rethrow. + // A contentless flood shares the repetition salvage path: both are + // self-inflicted degenerate-output aborts whose tail is the evidence. const abortedCycleText = await cycleRecorder.dispose( - repetition.hit !== null - ? "repetition" - : runController.deadlineHit() - ? "deadline" - : "cancelled", + degenerate() ? "repetition" : runController.deadlineHit() ? "deadline" : "cancelled", { drain: streamPromise }, ); // Deadline always salvages (even with zero output). Cancel after any @@ -713,7 +753,7 @@ export async function runSubAgent(params: RunSubAgentParams): Promise { const outcome = resolveSubAgentCatchOutcome({ deadlineHit: runController.deadlineHit(), hadProgress, - repetitionHit: repetition.hit !== null, + repetitionHit: degenerate(), }); if (outcome !== "rethrow") { const reason = @@ -729,13 +769,17 @@ export async function runSubAgent(params: RunSubAgentParams): Promise { const partial = repetition.hit !== null ? `Looped window (repeated ${repetition.hit.repeats}x): ${repetition.hit.window.slice(0, 300)}\n\n${tail}` - : tail; + : repetition.contentless + ? `Contentless output: the stream grew with only invisible characters (zero-width flood).\n\n${tail}` + : tail; const detail = repetition.hit !== null ? repetitionStopDetail(repetition.hit) - : reason === "deadline" && resolvedDeadlineMs !== undefined - ? `${resolvedDeadlineMs}ms elapsed` - : abortReasonText(runController.signal); + : repetition.contentless + ? "contentless/zero-width flood" + : reason === "deadline" && resolvedDeadlineMs !== undefined + ? `${resolvedDeadlineMs}ms elapsed` + : abortReasonText(runController.signal); return appendActivitySummary(forcedStopReport(reason, partial, detail), toolNamesUsed); } } diff --git a/src/tui/stall-watchdog.test.ts b/src/tui/stall-watchdog.test.ts index 6a0ca92c2..e0645430e 100644 --- a/src/tui/stall-watchdog.test.ts +++ b/src/tui/stall-watchdog.test.ts @@ -157,12 +157,22 @@ describe("detectRepetition", () => { const line1 = "I'll verify callId emission and remaining edges, then write the ranked findings."; const line2 = "Confirming callId emission, then writing the ranked findings."; - const text = Array(10).fill(`${line1}${line2}`).join(""); + const text = Array(30).fill(`${line1}${line2}`).join(""); const check = detectRepetition(text); expect(check.repeating).toBe(true); expect(check.period).toBe(line1.length + line2.length); }); + // The second captured incident: a 10-char unit ("Groaning. ") emitted + // ~1,363 times. The old 24-char period floor never saw it; 9 distinct + // chars keeps it above REPETITION_MIN_DISTINCT_CHARS. + test("flags a short-phrase loop with a 10-char unit", () => { + const text = "Groaning. ".repeat(60); + const check = detectRepetition(text); + expect(check.repeating).toBe(true); + expect(check.period).toBe("Groaning. ".length); + }); + test("does not flag the same cycle a handful of times", () => { const line1 = "I'll verify callId emission and remaining edges, then write the ranked findings."; diff --git a/src/tui/stall-watchdog.ts b/src/tui/stall-watchdog.ts index 582db858d..d4ea9388d 100644 --- a/src/tui/stall-watchdog.ts +++ b/src/tui/stall-watchdog.ts @@ -34,17 +34,22 @@ export interface ShouldAbortForStallArgs { // several hundred characters equal p repeated. // // A period below this is more likely a short structural tic (indentation, a -// repeated bullet or table-cell divider) than a looping phrase. Chosen well -// under the ~140-char period of the captured incident's two-sentence cycle, -// with headroom for shorter degenerate loops (a single repeated sentence). -const REPETITION_MIN_PERIOD = 24; +// repeated bullet or table-cell divider) than a looping phrase. Live loops +// repeat units as short as 10 chars ("Groaning. " emitted ~1,363 times), so +// the floor sits at 8 β€” short structural tics that survive it (a "- item\n" +// bullet is 7 chars) fall below, and the ones at or above it are filtered by +// the distinct-chars floor and the raised repeat bar instead. Still well +// under the ~140-char period of the captured incident's two-sentence cycle. +const REPETITION_MIN_PERIOD = 8; // How many exact repeats of the period are required before it counts as a -// loop rather than a coincidence. Verified against real non-degenerate -// repetition: a 6-row markdown table separator (period ~51 chars, 6 exact -// repeats) and 3 identical code lines (period ~60 chars, 3 exact repeats) -// both land under this bar and are not flagged; the captured incident's -// sentence pair comfortably clears it well before the stream ends. -const REPETITION_MIN_REPEATS = 8; +// loop rather than a coincidence. Raised 3x in step with the 3x-lower period +// floor so the minimum exactly-periodic span stays at 192 chars (was 24*8, +// now 8*24). Verified against real non-degenerate repetition: a 6-row +// markdown table separator (period ~51 chars, 6 exact repeats) and 3 +// identical code lines (period ~60 chars, 3 exact repeats) both land far +// under this bar and are not flagged; a genuine degenerate loop repeats +// hundreds of times, so it still clears the bar long before the stream ends. +const REPETITION_MIN_REPEATS = 24; // Hard ceiling on the period search regardless of buffer size, purely to cap // worst-case work per check β€” token-level degeneration loops on a phrase or // two, never on multi-paragraph spans. diff --git a/src/tui/turn-monitor.test.ts b/src/tui/turn-monitor.test.ts index 2502816a3..7c36ced70 100644 --- a/src/tui/turn-monitor.test.ts +++ b/src/tui/turn-monitor.test.ts @@ -577,7 +577,7 @@ describe("repetition guard", () => { const cycle = `${line1}${line2}`; // Tokens keep landing every tick β€” a real stall would never fire here. - for (let i = 0; i < 10; i++) { + for (let i = 0; i < 30; i++) { t.bridge.handle({ type: "inference.text.delta", data: { token: cycle }, diff --git a/src/tui/turn-state.test.ts b/src/tui/turn-state.test.ts index 853bd91eb..81ef781ec 100644 --- a/src/tui/turn-state.test.ts +++ b/src/tui/turn-state.test.ts @@ -347,7 +347,7 @@ describe("repetition tracking", () => { }); test("the captured incident shape (no separator between cycles) flips repeating", () => { - const deltas = Array(10) + const deltas = Array(30) .fill(cycle) .map((text) => textDelta(text)); const s = fold([{ type: "inference.start" }, ...deltas]); @@ -369,7 +369,7 @@ describe("repetition tracking", () => { // in-cycle detection that already fired must stay latched β€” the model // did loop, and a coincidental tool call right after should not erase // that fact. - const deltas = Array(10) + const deltas = Array(30) .fill(cycle) .map((text) => textDelta(text)); const looping = fold([{ type: "inference.start" }, ...deltas]);