Skip to content

Commit 683f3ab

Browse files
committed
Catch short-phrase loops, counter/emoji floods, and zero-width floods
Holes in degenerate-output detection let model loops burn to the token limit (verified from live traces; 670K wasted streamed chars observed): - Short-phrase loops: a live subagent emitted a 10-char unit ("Groaning. ") ~1,363 times; the 16-char window floor and the watchdog's 24-char period floor both missed it. Lower the floors to 8 and raise the repeat thresholds 2x/3x so the minimum exactly-periodic span stays at 128 / 192 chars. - Counter/timestamp/fence/emoji floods: incrementing counters are never byte-periodic, and "0% ", "}\n", "```\n", "🤔 " units fall under the plain window floor. Add a second, folded pass over text streams (windowMinChars 2, repeatThreshold 64, maxFoldedPeriodChars 16) — the period cap refuses prose-shaped folds (numbered lists, tables). - Zero-width floods: detectRepetition strips invisibles before the periodicity check, so a wall of U+200C/U+200D normalizes to a short healthy string. Add a contentless-growth guard that flags a raw-growth window (2048 chars) whose visible content stays under 32 chars, wired into the subagent run-loop abort path with its own abort reason.
1 parent bebe563 commit 683f3ab

7 files changed

Lines changed: 347 additions & 33 deletions

File tree

src/subagent/repetition.test.ts

Lines changed: 151 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@ import { describe, expect, test } from "bun:test";
33
import { appendCycleText, CYCLE_TEXT_CAP_CHARS } from "../session/stream-journal.js";
44
import {
55
detectRepetition,
6+
DEFAULT_CONTENTLESS_GROWTH_CONFIG,
67
DEFAULT_REPETITION_CONFIG,
8+
DEFAULT_TEXT_FOLDED_REPETITION_CONFIG,
79
DEFAULT_THINKING_REPETITION_CONFIG,
10+
INITIAL_CONTENTLESS_GROWTH_STATE,
811
REPETITION_CHECK_INTERVAL_CHARS,
12+
trackContentlessGrowth,
13+
type ContentlessGrowthState,
914
} from "./repetition.js";
1015

1116
// A monotonic counter that never repeats verbatim: each pair's numerator and
@@ -21,8 +26,9 @@ const LOOP_SENTENCE =
2126
describe("detectRepetition", () => {
2227
test("flags a looped status sentence with an oscillating counter", () => {
2328
// Counters that flip between values keep the raw text periodic — the
24-
// period just spans one full oscillation (two sentences here).
25-
const iterations = Array.from({ length: 20 }, (_, i) =>
29+
// period just spans one full oscillation (two sentences here), so hitting
30+
// the repeat threshold takes twice as many iterations.
31+
const iterations = Array.from({ length: 40 }, (_, i) =>
2632
LOOP_SENTENCE.replace("0/1.0", `${i % 2}/1.0`),
2733
);
2834
const text = `some earlier legitimate prose about the task. ${iterations.join("")}`;
@@ -73,6 +79,26 @@ describe("detectRepetition", () => {
7379
expect(detectRepetition(looped)).not.toBeNull();
7480
});
7581

82+
test("flags a short-phrase loop (10-char unit, observed live)", () => {
83+
// The second captured incident: "Groaning. " emitted ~1,363 times. The
84+
// old 16-char window floor never saw a 10-char unit.
85+
const text = "Groaning. ".repeat(1300);
86+
const hit = detectRepetition(text);
87+
expect(hit).not.toBeNull();
88+
expect(hit?.window).toBe("Groaning. ");
89+
expect(hit?.repeats).toBeGreaterThanOrEqual(DEFAULT_REPETITION_CONFIG.repeatThreshold);
90+
});
91+
92+
test("does not flag a repeated markdown table separator row", () => {
93+
const row = "| ---------------------- | ---------------------- |\n";
94+
expect(detectRepetition(`| Left | Right |\n${row.repeat(6)}`)).toBeNull();
95+
});
96+
97+
test("does not flag a few identical code lines", () => {
98+
const line = " const result = await fetchData(request, options, context)\n";
99+
expect(detectRepetition(line.repeat(3))).toBeNull();
100+
});
101+
76102
test("returns null for text shorter than one full window set", () => {
77103
expect(detectRepetition("short")).toBeNull();
78104
expect(detectRepetition("")).toBeNull();
@@ -165,12 +191,134 @@ describe("repetition check accounting at the cycle-text cap", () => {
165191
// detector misses the loop (observed in live thrash fleets).
166192
const window = "I'll open the remaining source files and implement the activity preview. ";
167193
const zwsp = "\u200B";
168-
const text = (window + zwsp).repeat(12);
194+
const text = (window + zwsp).repeat(20);
169195
const hit = detectRepetition(text);
170196
expect(hit).not.toBeNull();
171197
expect(hit?.repeats).toBeGreaterThanOrEqual(DEFAULT_REPETITION_CONFIG.repeatThreshold);
172198
});
173199

200+
describe("folded text pass (DEFAULT_TEXT_FOLDED_REPETITION_CONFIG)", () => {
201+
// Mirrors run.ts: digit-preserving default first, capped folded pass second.
202+
function detectText(text: string) {
203+
return (
204+
detectRepetition(text) ??
205+
detectRepetition(text, DEFAULT_TEXT_FOLDED_REPETITION_CONFIG, { normalizeDigits: true })
206+
);
207+
}
208+
209+
test("flags an incrementing counter flood in visible text", () => {
210+
// Observed live: "14279 14280 14281…" streamed to inference-error.
211+
const text = Array.from({ length: 500 }, (_, i) => `${14279 + i} `).join("");
212+
expect(detectRepetition(text)).toBeNull();
213+
expect(detectText(text)).not.toBeNull();
214+
});
215+
216+
test("flags an incrementing pair-counter flood", () => {
217+
// Observed live: "5620/5620. 5621/5621. …"
218+
const text = Array.from({ length: 300 }, (_, i) => `${5620 + i}/${5620 + i}. `).join("");
219+
expect(detectText(text)).not.toBeNull();
220+
});
221+
222+
test("flags a repeated-timestamp flood", () => {
223+
// Observed live: "18:22:27." emitted hundreds of times, with drift.
224+
const text = Array.from({ length: 300 }, (_, i) => `18:22:${27 + (i % 30)}. `).join("");
225+
expect(detectText(text)).not.toBeNull();
226+
});
227+
228+
test("flags a zero-percent flood", () => {
229+
// "0% " is a 3-char unit — under the plain 8-char window floor.
230+
const text = "0% ".repeat(200);
231+
expect(detectRepetition(text)).toBeNull();
232+
expect(detectText(text)).not.toBeNull();
233+
});
234+
235+
test("flags fence and brace floods", () => {
236+
expect(detectText("```\n".repeat(100))).not.toBeNull();
237+
expect(detectText("}\n".repeat(200))).not.toBeNull();
238+
});
239+
240+
test("flags an emoji flood (surrogate-pair unit)", () => {
241+
// Observed live: "🤔 " ×~40K chars. The unit is 3 UTF-16 units; the
242+
// code-point reversal must keep the pair intact for it to stay periodic.
243+
const text = "🤔 ".repeat(2000);
244+
expect(detectText(text)).not.toBeNull();
245+
});
246+
247+
test("does not flag a numbered list under the folded text pass", () => {
248+
// Folds to a ~47-char period — refused by maxFoldedPeriodChars.
249+
const items = Array.from(
250+
{ length: 400 },
251+
(_, i) => `${i + 1}. Ran batch ${i + 1} and verified ${i * 3} records migrated\n`,
252+
).join("");
253+
expect(detectText(`Migration progress:\n${items}`)).toBeNull();
254+
});
255+
256+
test("does not flag a digit-varying markdown table under the folded text pass", () => {
257+
const rows = Array.from(
258+
{ length: 40 },
259+
(_, i) => `| 202${i % 10} | ${i * 10} requests | ${i} errors |\n`,
260+
).join("");
261+
expect(detectText(`| Year | Volume | Errors |\n|---|---|---|\n${rows}`)).toBeNull();
262+
});
263+
264+
test("a short user-requested enumeration stays under the folded repeat bar", () => {
265+
// "print 1..40" folds to "0 " ×40 — under repeatThreshold 64.
266+
const text = Array.from({ length: 40 }, (_, i) => `${i + 1} `).join("");
267+
expect(detectText(text)).toBeNull();
268+
});
269+
});
270+
271+
describe("trackContentlessGrowth", () => {
272+
function feed(tokens: readonly string[]): boolean {
273+
let state: ContentlessGrowthState = INITIAL_CONTENTLESS_GROWTH_STATE;
274+
for (const token of tokens) {
275+
const next = trackContentlessGrowth(state, token);
276+
if (next.hit) return true;
277+
state = next.state;
278+
}
279+
return false;
280+
}
281+
282+
test("flags a zero-width flood (ZWNJ/ZWJ walls, observed live)", () => {
283+
// Observed live: 500–53,000 U+200C/U+200D chars per stream.
284+
// detectRepetition strips invisibles before checking, so it must not be
285+
// the only line of defense.
286+
const flood = Array.from({ length: 60 }, () => "‌‍".repeat(32));
287+
expect(detectRepetition(flood.join(""))).toBeNull();
288+
expect(feed(flood)).toBe(true);
289+
});
290+
291+
test("flags a flood even when prefixed by healthy prose", () => {
292+
const tokens = [
293+
"Let me look at the config first. ".repeat(4),
294+
...Array.from({ length: 100 }, () => "‍".repeat(64)),
295+
];
296+
expect(feed(tokens)).toBe(true);
297+
});
298+
299+
test("does not flag ordinary prose or sparse code", () => {
300+
const tokens = Array.from(
301+
{ length: 200 },
302+
(_, i) => ` const value${i} = await compute(input${i});\n\n`,
303+
);
304+
expect(feed(tokens)).toBe(false);
305+
});
306+
307+
test("a visible-rich window re-arms rather than latching", () => {
308+
// Enough visible content inside every window keeps the guard quiet no
309+
// matter how long the stream runs.
310+
const tokens = Array.from({ length: 50 }, () =>
311+
`${"‌".repeat(100)} some genuinely visible sentence with plenty of characters. `,
312+
);
313+
expect(feed(tokens)).toBe(false);
314+
});
315+
316+
test("whitespace does not count as visible content", () => {
317+
const raw = " \n\t".repeat(DEFAULT_CONTENTLESS_GROWTH_CONFIG.rawWindowChars);
318+
expect(feed([raw])).toBe(true);
319+
});
320+
});
321+
174322
describe("appendCycleText", () => {
175323
test("keeps only the tail past the cap", () => {
176324
const text = appendCycleText("a".repeat(10), "b".repeat(10), 15);

src/subagent/repetition.ts

Lines changed: 112 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,18 @@ export type RepetitionConfig = {
2929
maxFoldedPeriodChars?: number;
3030
};
3131

32-
// windowMinChars * repeatThreshold = 128 chars of exactly periodic text —
33-
// far beyond anything legitimate prose or code produces by accident.
32+
// windowMinChars * repeatThreshold = 8 * 16 = 128 chars of exactly periodic
33+
// text — far beyond anything legitimate prose or code produces by accident.
34+
// windowMinChars sits at 8 because live loops repeat units as short as 10
35+
// chars ("Groaning. " emitted ~1,363 times), which a 16-char floor never sees;
36+
// the repeat threshold rises to 16 in compensation so the minimum periodic
37+
// span stays at 128 chars. Structural tics that legitimately repeat ("- item\n"
38+
// normalizes to 7 chars) still fall under the window floor, and longer healthy
39+
// repeats (a 6-row table separator, 3 identical code lines, a repeat(4)
40+
// paragraph) stay far below 16 consecutive repeats.
3441
export const DEFAULT_REPETITION_CONFIG: RepetitionConfig = {
35-
windowMinChars: 16,
36-
repeatThreshold: 8,
42+
windowMinChars: 8,
43+
repeatThreshold: 16,
3744
probeChars: 8192,
3845
};
3946

@@ -65,6 +72,33 @@ export const DEFAULT_THINKING_REPETITION_CONFIG: RepetitionConfig = {
6572
maxFoldedPeriodChars: 16,
6673
};
6774

75+
// Second, folded pass over *text* streams, for the flood shapes the default
76+
// (digit-preserving) config is structurally blind to. Live traces ending as
77+
// inference-error (670K wasted streamed chars) showed: incrementing counters
78+
// ("14279 14280 14281…", "5620/5620. 5621/5621…" — never byte-periodic),
79+
// repeated timestamps with drift ("18:22:27. 18:22:28."), "0% 0% 0%…",
80+
// repeated "```\n" fences and "}\n" braces, and emoji floods ("🤔 " ×~40K
81+
// chars). All fold (or already normalize) to a tiny 2–16 char period.
82+
//
83+
// The safety story for visible text is different from thinking, hence the
84+
// stricter numbers rather than reusing the thinking config:
85+
// - maxFoldedPeriodChars 16 refuses prose-shaped folds exactly as it does for
86+
// thinking: a numbered-list or table row folds to a ~30–50 char period and
87+
// never fires (see the normalize() rationale below).
88+
// - windowMinChars 2 (vs thinking's 4) reaches the shortest observed units:
89+
// "0% " and "} " fold to 2–3 chars, below the thinking floor.
90+
// - repeatThreshold 64 (vs 32): the residual false-positive risk for text is
91+
// a user-requested raw enumeration ("print 1..N"), which folds to "0 " —
92+
// a legit dump of a few dozen numbers stays under 64 consecutive repeats,
93+
// while the observed floods repeat thousands of times. Minimum folded
94+
// periodic span: 2 * 64 = 128 chars.
95+
export const DEFAULT_TEXT_FOLDED_REPETITION_CONFIG: RepetitionConfig = {
96+
windowMinChars: 2,
97+
repeatThreshold: 64,
98+
probeChars: 8192,
99+
maxFoldedPeriodChars: 16,
100+
};
101+
68102
export type RepetitionHit = {
69103
/** The normalized window that repeats. */
70104
window: string;
@@ -85,9 +119,11 @@ export type RepetitionHit = {
85119
// evade the detector. Observed thrash loops used U+200B between repeats.
86120
// `normalizeDigits` opts a caller into folding digit runs to one placeholder,
87121
// which collapses a monotonic counter's varying digits into a repeating unit.
88-
// Reserved for thinking streams (see DEFAULT_THINKING_REPETITION_CONFIG),
89-
// which are never rendered to the user and so carry none of the numbered-list
90-
// / table false-positive risk that keeps text normalization digit-preserving.
122+
// The digit-preserving default protects text streams' numbered lists and
123+
// tables; folded detection runs on them only as a second pass capped to tiny
124+
// periods (DEFAULT_TEXT_FOLDED_REPETITION_CONFIG), and uncapped-in-spirit on
125+
// thinking streams (DEFAULT_THINKING_REPETITION_CONFIG), which are never
126+
// rendered to the user and so carry less false-positive cost.
91127
function normalize(text: string, normalizeDigits: boolean): string {
92128
const stripped = text
93129
.replace(/[\u200B-\u200D\uFEFF\u00AD\u2060\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, "")
@@ -121,6 +157,10 @@ export function detectRepetition(
121157
const tail = normalize(text.slice(-config.probeChars), opts.normalizeDigits ?? false);
122158
if (tail.length < config.windowMinChars * config.repeatThreshold) return null;
123159

160+
// Reverse by code point so surrogate pairs survive intact — an emoji flood
161+
// ("🤔 " ×thousands) must stay byte-periodic after reversal. The prefix
162+
// function and window extraction then both count plain UTF-16 units of the
163+
// (pair-preserving) reversed string, so periods and slices stay consistent.
124164
const reversed = [...tail].reverse().join("");
125165
const pi = prefixFunction(reversed);
126166

@@ -140,3 +180,68 @@ export function detectRepetition(
140180
}
141181
return best;
142182
}
183+
184+
/** Tunables for the contentless-growth guard. */
185+
export type ContentlessGrowthConfig = {
186+
/** Raw streamed chars per measurement window. */
187+
rawWindowChars: number;
188+
/** A window with fewer visible chars than this counts as contentless. */
189+
minVisibleChars: number;
190+
};
191+
192+
// detectRepetition can never see a zero-width flood: normalize() strips
193+
// invisibles *before* the periodicity check, so thousands of U+200C/U+200D
194+
// chars (observed live: 500–53,000 per stream) collapse to a short, healthy-
195+
// looking string. This guard watches the inverse signal — raw text keeps
196+
// growing while its visible content does not. The bar: 2048 raw chars with
197+
// fewer than 32 visible. Legitimate sparse output never approaches it — even
198+
// a heavily indented code block or a wide table row carries hundreds of
199+
// visible chars per 2048 raw, and a healthy stream would need 64:1
200+
// invisible-or-whitespace-to-content to trip it.
201+
export const DEFAULT_CONTENTLESS_GROWTH_CONFIG: ContentlessGrowthConfig = {
202+
rawWindowChars: 2048,
203+
minVisibleChars: 32,
204+
};
205+
206+
export type ContentlessGrowthState = {
207+
/** Raw chars accumulated in the current window. */
208+
rawChars: number;
209+
/** Visible (invisible-stripped, whitespace-removed) chars in the window. */
210+
visibleChars: number;
211+
};
212+
213+
export const INITIAL_CONTENTLESS_GROWTH_STATE: ContentlessGrowthState = {
214+
rawChars: 0,
215+
visibleChars: 0,
216+
};
217+
218+
// Whitespace is removed rather than collapsed: a window of pure newlines is
219+
// as contentless as one of pure ZWJ, and counting collapsed runs would let a
220+
// space-interleaved flood (ZWJ, space, ZWJ, space, …) smuggle half its
221+
// length past the epsilon.
222+
function visibleLength(token: string): number {
223+
return normalize(token, false).replace(/ /g, "").length;
224+
}
225+
226+
/**
227+
* Fold one streamed token into the contentless-growth window. Returns the
228+
* next state and whether the just-completed window was contentless: raw text
229+
* grew by a full window while visible content grew less than the epsilon.
230+
* Pure reducer — the caller owns the state across deltas; the window resets
231+
* on completion either way, so one visible-rich window re-arms the guard.
232+
*/
233+
export function trackContentlessGrowth(
234+
state: ContentlessGrowthState,
235+
token: string,
236+
config: ContentlessGrowthConfig = DEFAULT_CONTENTLESS_GROWTH_CONFIG,
237+
): { state: ContentlessGrowthState; hit: boolean } {
238+
const rawChars = state.rawChars + token.length;
239+
const visibleChars = state.visibleChars + visibleLength(token);
240+
if (rawChars < config.rawWindowChars) {
241+
return { state: { rawChars, visibleChars }, hit: false };
242+
}
243+
return {
244+
state: INITIAL_CONTENTLESS_GROWTH_STATE,
245+
hit: visibleChars < config.minVisibleChars,
246+
};
247+
}

0 commit comments

Comments
 (0)