Skip to content

Commit d2ec915

Browse files
Merge pull request #511 from corbitsdev/cl-6634-repetition-detector-ignores-thinking-token-loops
Detect repetition loops in thinking token streams
2 parents 8d99910 + b7f2e0f commit d2ec915

6 files changed

Lines changed: 227 additions & 14 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ Two directors, selected by role:
107107

108108
- **ChatDirector** (interactive, `src/agent/director.ts`) — Extends `DefaultDirector` with task list tracking, workflow nudges, LSP auto-activation, and multi-turn chat semantics. It never terminates the session: operator declines are surfaced as replies and the reactor stays alive for the next message. Auto mode is toggled by CLI flags (`--auto` / `--no-auto`); there is currently no in-session key to toggle it (default on; constrained envelope — workspace writes and unconstrained shell auto-allow; installs, recursive rm, force/uncontained worktree changes, sensitive-path and opaque-wrapper shell still ask; contained non-force `git worktree add`/`remove`/`prune` and `list` auto-allow; shell file-mutation denied). It is not a separate edit/plan mode.
109109
- **SubAgentDirector** (delegated work, `src/subagent/index.ts`) — Drives a dispatched worker until a turn arrives with no tool calls, then replies with the final assistant text and ends the run. A tool-less turn **after tools** completes only with the four-heading envelope (Summary, Findings, Blockers, Paths); a missing envelope nudges once then salvages as **incomplete-report**. A tool-less completion with **zero tool calls in the entire run** is returned as a **never-acted** salvage report (not a successful implement). When `task(intent="implement")` is set, a tool-using run that never wrote/edited/deleted a file is returned as **never-edited** instead of complete — so a pure-explore "plan" cannot look shipped to the parent (tracked via `thrashState.editedPaths` from `edit_file` / `write_file` / `delete_file`). Explore/read-only workers that used tools then replied with findings remain normal completes. Hard stops also fire after 2 consecutive identical tool-call fingerprints (**no-progress**), on progressive re-read pressure (**thrash** — the same path re-read past a limit amid enough tool volume, tracked by `src/subagent/thrash.ts`), or after the leaf turn budget (**turn-budget**, default 30, overridable via `task(maxTurns)`, agent profile `maxTurns`, or `settings.subagentMaxTurns`, capped at 100), each returning a structured salvage report (reason, partial findings, blockers) so a thrashing child cannot burn tokens indefinitely. Before hard thrash, a one-shot **re-read-nudge** fires when re-read pressure crosses a soft threshold (default 3 same-path reads with enough tool volume, still below the hard re-read limit of 4): the director injects an ephemeral redirect — implement leaves are asked to edit or wrap up; explore leaves are asked to expand findings / change approach / report, never forced into edit — then keeps running so hard thrash remains reachable if the leaf ignores it. A fourth hard stop, **repetition**, is detected outside the director entirely:
110-
`runSubAgent`'s stream sink watches the streamed text of the in-flight cycle for degenerate token loops (`src/subagent/repetition.ts`) — format chars (ZWSP, BOM, bidi marks, soft hyphen, …) stripped then whitespace-collapsed raw text, a smallest-period KMP check over the probe tail, default window >= 16 chars repeated >= 8 times, evaluated every 256 streamed chars — and on a hit aborts the run controller mid-cycle, returning a `repetition` salvage report that leads with the looped window and warns the parent against re-dispatching the identical brief. Because directors only see completed turns, this is the only stop that can catch a loop inside a single turn that never finishes. A one-shot **report-forced** signal fires a few turns before the cap while the leaf is still tooling — it is not a stop: the director injects a wrap-up nudge and lets the leaf finish on its own, so turn-budget stays reachable for a leaf still making progress. When both report-forced and re-read-nudge apply, report-forced wins (near-budget wrap-up is more urgent than a mid-run redirect). Operator/parent cancel after any progress likewise returns a **cancelled** salvage report (partial findings + tool activity) instead of a bare cancel string; cancel before progress still surfaces as cancelled-by-operator.
110+
`runSubAgent`'s stream sink watches the streamed text of the in-flight cycle for degenerate token loops (`src/subagent/repetition.ts`) — format chars (ZWSP, BOM, bidi marks, soft hyphen, …) stripped then whitespace-collapsed raw text, a smallest-period KMP check over the probe tail, default window >= 16 chars repeated >= 8 times, evaluated every 256 streamed chars — and on a hit aborts the run controller mid-cycle, returning a `repetition` salvage report that leads with the looped window and warns the parent against re-dispatching the identical brief. `inference.thinking.delta` is sampled the same way on its own buffer, but with digit runs folded to one placeholder and a shorter window (>= 4 chars repeated >= 32 times), gated to periods <= 16 chars once folded: thinking is never rendered to the user, so a monotonic counter (e.g. `0/1 1/2 2/3 …`, which stays non-periodic and escapes the raw-text check) can be caught, but folding still erases real information — a healthy templated enumeration line becomes byte-identical to its neighbors once digits are erased, so the period-length cap only lets counter-shaped folded periods (a handful of chars) through and refuses the much longer periods a folded prose line produces. Because directors only see completed turns, this is the only stop that can catch a loop inside a single turn that never finishes. A one-shot **report-forced** signal fires a few turns before the cap while the leaf is still tooling — it is not a stop: the director injects a wrap-up nudge and lets the leaf finish on its own, so turn-budget stays reachable for a leaf still making progress. When both report-forced and re-read-nudge apply, report-forced wins (near-budget wrap-up is more urgent than a mid-run redirect). Operator/parent cancel after any progress likewise returns a **cancelled** salvage report (partial findings + tool activity) instead of a bare cancel string; cancel before progress still surfaces as cancelled-by-operator.
111111
Optional `task(tier=)` (`fast` | `standard` | `clever`) overrides profile inference, profile tier, and the parent provider for that spawn only, and fails closed when the tier is unconfigured. The parent `task` tool keeps a session-scoped brief-dispatch ledger (`src/subagent/brief-dispatch.ts`): fingerprints cover prompt + agent + intent + success_criteria + do_not (not maxTurns/description/tier). After thrash / no-progress / repetition / never-acted / never-edited salvage, an identical re-dispatch is hard-blocked for the rest of the parent chat; change at least one fingerprint field to force a re-run. Turn-budget salvage still invites a higher maxTurns for a few same-brief retries without a successful complete, then flips the parent hint to stop and change approach (soft — further identical dispatches are still admitted). A successful complete resets the same-brief retry budget.
112112

113113

src/session/stream-journal.test.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,26 @@ function delta(token: string): ReactorEmittedEvent {
2121
return { type: "inference.text.delta", data: { token } } as unknown as ReactorEmittedEvent;
2222
}
2323

24-
async function readPartialRecords(): Promise<Array<{ reason: string; text: string }>> {
24+
function thinkingDelta(token: string): ReactorEmittedEvent {
25+
return { type: "inference.thinking.delta", data: { token } } as unknown as ReactorEmittedEvent;
26+
}
27+
28+
async function readPartialRecords(): Promise<
29+
Array<{ reason: string; text: string; thinkingText?: string; thinkingChars?: number }>
30+
> {
2531
const raw = await readFile(join(dir, PARTIAL_FILE), "utf8");
2632
return raw
2733
.trim()
2834
.split("\n")
29-
.map((line) => JSON.parse(line) as { reason: string; text: string });
35+
.map(
36+
(line) =>
37+
JSON.parse(line) as {
38+
reason: string;
39+
text: string;
40+
thinkingText?: string;
41+
thinkingChars?: number;
42+
},
43+
);
3044
}
3145

3246
describe("createCycleTextRecorder", () => {
@@ -119,6 +133,43 @@ describe("createCycleTextRecorder", () => {
119133
expect(records[0]?.text).toBe("buffered text");
120134
});
121135

136+
test("buffers thinking deltas separately from text and flushes both", async () => {
137+
const recorder = createCycleTextRecorder(() => dir);
138+
recorder.handleEvent(delta("visible reply"));
139+
recorder.handleEvent(thinkingDelta("0/1 1/2 2/3 "));
140+
expect(recorder.text()).toBe("visible reply");
141+
expect(recorder.thinkingText()).toBe("0/1 1/2 2/3 ");
142+
143+
await recorder.flush("repetition");
144+
const records = await readPartialRecords();
145+
expect(records[0]?.text).toBe("visible reply");
146+
expect(records[0]?.thinkingText).toBe("0/1 1/2 2/3 ");
147+
expect(recorder.thinkingText()).toBe("");
148+
});
149+
150+
test("a thinking-only loop still writes a partial record with the looped window", async () => {
151+
// No visible text ever streamed (the observed live failure): the salvage
152+
// must still be diagnosable from thinkingText alone.
153+
const recorder = createCycleTextRecorder(() => dir);
154+
recorder.handleEvent(thinkingDelta("0/1 1/2 2/3 3/4 4/5 "));
155+
const snapshot = await recorder.dispose("repetition");
156+
157+
expect(snapshot).toBe("");
158+
const records = await readPartialRecords();
159+
expect(records[0]?.reason).toBe("repetition");
160+
expect(records[0]?.text).toBe("");
161+
expect(records[0]?.thinkingText).toBe("0/1 1/2 2/3 3/4 4/5 ");
162+
});
163+
164+
test("a turn boundary resets both the text and thinking buffers", () => {
165+
const recorder = createCycleTextRecorder(() => dir);
166+
recorder.handleEvent(delta("hello"));
167+
recorder.handleEvent(thinkingDelta("thinking"));
168+
recorder.handleEvent({ type: "inference.done", data: {} } as unknown as ReactorEmittedEvent);
169+
expect(recorder.text()).toBe("");
170+
expect(recorder.thinkingText()).toBe("");
171+
});
172+
122173
test("reset reopens a closed recorder so new deltas buffer and flush normally", async () => {
123174
const recorder = createCycleTextRecorder(() => dir);
124175
await recorder.dispose("rotation");

src/session/stream-journal.ts

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,10 @@ export type PartialFlushReason =
4646
export type CycleTextRecorder = {
4747
/** Feed every stream event; buffers deltas, resets on done, flushes on error. */
4848
handleEvent: (event: ReactorEmittedEvent) => void;
49-
/** The buffered text of the current (unfinished) cycle. */
49+
/** The buffered visible text of the current (unfinished) cycle. */
5050
text: () => string;
51+
/** The buffered thinking text of the current (unfinished) cycle. */
52+
thinkingText: () => string;
5153
/** Write the buffer to partial.jsonl with a reason, then reset it. */
5254
flush: (reason: PartialFlushReason) => Promise<void>;
5355
/**
@@ -71,13 +73,25 @@ export function createCycleTextRecorder(
7173
resolveContextDir: () => string,
7274
): CycleTextRecorder {
7375
let cycleText = "";
76+
let cycleThinkingText = "";
7477
let closed = false;
7578

76-
const writeRecord = async (reason: PartialFlushReason, text: string): Promise<void> => {
77-
if (text.trim().length === 0) return;
78-
const record = JSON.stringify({ reason, chars: text.length, text });
79+
const writeRecord = async (
80+
reason: PartialFlushReason,
81+
text: string,
82+
thinkingText: string,
83+
): Promise<void> => {
84+
if (text.trim().length === 0 && thinkingText.trim().length === 0) return;
85+
const record: Record<string, unknown> = { reason, chars: text.length, text };
86+
// Omitted when empty: a text-only abort (the common case) keeps the
87+
// existing record shape, and diagnosing a thinking-loop abort needs the
88+
// looped window that never reached visible text.
89+
if (thinkingText.length > 0) {
90+
record.thinkingChars = thinkingText.length;
91+
record.thinkingText = thinkingText;
92+
}
7993
try {
80-
await appendFile(join(resolveContextDir(), PARTIAL_FILE), `${record}\n`, "utf8");
94+
await appendFile(join(resolveContextDir(), PARTIAL_FILE), `${JSON.stringify(record)}\n`, "utf8");
8195
} catch (err) {
8296
getLogger([LOG_NAMESPACE_ROOT, "session", "partial"]).warn(
8397
"failed to write partial stream output: {error}",
@@ -88,8 +102,10 @@ export function createCycleTextRecorder(
88102

89103
const flush = async (reason: PartialFlushReason): Promise<void> => {
90104
const text = cycleText;
105+
const thinkingText = cycleThinkingText;
91106
cycleText = "";
92-
await writeRecord(reason, text);
107+
cycleThinkingText = "";
108+
await writeRecord(reason, text, thinkingText);
93109
};
94110

95111
const handleEvent = (event: ReactorEmittedEvent): void => {
@@ -99,8 +115,14 @@ export function createCycleTextRecorder(
99115
if (typeof token === "string") cycleText = appendCycleText(cycleText, token);
100116
return;
101117
}
118+
if (event.type === "inference.thinking.delta") {
119+
const token = (event.data as { token?: unknown }).token;
120+
if (typeof token === "string") cycleThinkingText = appendCycleText(cycleThinkingText, token);
121+
return;
122+
}
102123
if (onTurnBoundary(event)) {
103124
cycleText = "";
125+
cycleThinkingText = "";
104126
return;
105127
}
106128
if (event.type === "inference.error") {
@@ -115,16 +137,26 @@ export function createCycleTextRecorder(
115137
if (closed) return "";
116138
closed = true;
117139
const snapshot = cycleText;
140+
const thinkingSnapshot = cycleThinkingText;
118141
cycleText = "";
142+
cycleThinkingText = "";
119143
if (opts?.drain !== undefined) await opts.drain.catch(() => undefined);
120-
await writeRecord(reason, snapshot);
144+
await writeRecord(reason, snapshot, thinkingSnapshot);
121145
return snapshot;
122146
};
123147

124148
const reset = (): void => {
125149
closed = false;
126150
cycleText = "";
151+
cycleThinkingText = "";
127152
};
128153

129-
return { handleEvent, text: () => cycleText, flush, dispose, reset };
154+
return {
155+
handleEvent,
156+
text: () => cycleText,
157+
thinkingText: () => cycleThinkingText,
158+
flush,
159+
dispose,
160+
reset,
161+
};
130162
}

src/subagent/repetition.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,17 @@ import { appendCycleText, CYCLE_TEXT_CAP_CHARS } from "../session/stream-journal
44
import {
55
detectRepetition,
66
DEFAULT_REPETITION_CONFIG,
7+
DEFAULT_THINKING_REPETITION_CONFIG,
78
REPETITION_CHECK_INTERVAL_CHARS,
89
} from "./repetition.js";
910

11+
// A monotonic counter that never repeats verbatim: each pair's numerator and
12+
// denominator both grow, so raw text is never byte-periodic (the shape that
13+
// escaped detection live: ~64k thinking tokens of "0/1 1/2 2/3 …").
14+
function monotonicCounterStream(pairs: number): string {
15+
return Array.from({ length: pairs }, (_, i) => `${i}/${i + 1} `).join("");
16+
}
17+
1018
const LOOP_SENTENCE =
1119
"next: dig footer/chrome and module structure for plan. 0/1.0 done. 1 remaining. 1h left. 0 errors. ";
1220

@@ -69,6 +77,63 @@ describe("detectRepetition", () => {
6977
expect(detectRepetition("short")).toBeNull();
7078
expect(detectRepetition("")).toBeNull();
7179
});
80+
81+
test("a strictly monotonic counter escapes the default (text) config even with thousands of tokens", () => {
82+
// Documents the known, deliberate limitation for visible text: a growing
83+
// counter is never byte-periodic, so it stays indistinguishable from a
84+
// legitimate numbered list without digit normalization.
85+
const text = monotonicCounterStream(4000);
86+
expect(detectRepetition(text)).toBeNull();
87+
});
88+
89+
test("digit-normalized detection catches the monotonic counter (thinking-stream shape)", () => {
90+
const text = monotonicCounterStream(4000);
91+
const hit = detectRepetition(text, DEFAULT_THINKING_REPETITION_CONFIG, { normalizeDigits: true });
92+
expect(hit).not.toBeNull();
93+
expect(hit?.repeats).toBeGreaterThanOrEqual(DEFAULT_THINKING_REPETITION_CONFIG.repeatThreshold);
94+
});
95+
96+
test("a healthy numbered list stays untripped under the default (text) config", () => {
97+
// The run loop never passes normalizeDigits for inference.text.delta —
98+
// this pins that visible text keeps the digit-preserving path regardless
99+
// of how many items stream.
100+
const items = Array.from(
101+
{ length: 400 },
102+
(_, i) => `${i + 1}. Ran batch ${i + 1} and verified ${i * 3} records migrated\n`,
103+
).join("");
104+
expect(detectRepetition(`Migration progress:\n${items}`)).toBeNull();
105+
});
106+
107+
test("does not flag templated enumeration in thinking after digit folding", () => {
108+
// Regression: folding digits collapses a healthy templated line to a
109+
// byte-identical ~40+ char unit once its digits are erased. 200 lines
110+
// (~10KB) would trip windowMinChars 4 / repeatThreshold 32 without the
111+
// maxFoldedPeriodChars gate, aborting a healthy worker mid-reasoning.
112+
const items = Array.from(
113+
{ length: 200 },
114+
(_, i) => `${i + 1}. Ran batch ${i + 1} and verified ${i * 3} records migrated\n`,
115+
).join("");
116+
const hit = detectRepetition(items, DEFAULT_THINKING_REPETITION_CONFIG, {
117+
normalizeDigits: true,
118+
});
119+
expect(hit).toBeNull();
120+
});
121+
122+
test("still catches the monotonic counter with thousands of pairs", () => {
123+
const text = monotonicCounterStream(4000);
124+
const hit = detectRepetition(text, DEFAULT_THINKING_REPETITION_CONFIG, { normalizeDigits: true });
125+
expect(hit).not.toBeNull();
126+
expect(hit?.repeats).toBeGreaterThanOrEqual(DEFAULT_THINKING_REPETITION_CONFIG.repeatThreshold);
127+
});
128+
129+
test("a near-counter with a short prose wrapper still folds to a short period and trips", () => {
130+
// "step N/N done. " folds to "step 0/0 done. " — a 15-char period, still
131+
// within maxFoldedPeriodChars (16), so this shape is (deliberately) still
132+
// caught: it reads as a stalled step counter, not templated enumeration.
133+
const text = Array.from({ length: 100 }, (_, i) => `step ${i}/${i + 1} done. `).join("");
134+
const hit = detectRepetition(text, DEFAULT_THINKING_REPETITION_CONFIG, { normalizeDigits: true });
135+
expect(hit).not.toBeNull();
136+
});
72137
});
73138

74139
describe("repetition check accounting at the cycle-text cap", () => {

0 commit comments

Comments
 (0)