Skip to content

Commit aa2dcd6

Browse files
committed
Delete repetition detection outright (CL-6995)
Removes the streamed-text loop detector (repetition.ts + period-detection.ts), the director's tool-fingerprint period/cycle thrash check and turns-since-user-message backstop, and the leaf no-progress (identical-tool-call) counter. Dead-by-consequence cleanup: the compaction summarizer no longer filters assistant turns for repetition before excerpting, and the TUI stream-buffer throttle that only existed to pace the deleted character-loop check is gone (the independent cross-cycle fingerprint stall recovery in turn-state.ts is untouched). Kept: provider stream error handling, connection retry/backoff, the turn budget stop, and the director's soft tool-only check-in nudge (toolOnlyTurnNudgeAt) — a turn-count nudge, not repetition detection.
1 parent af06c54 commit aa2dcd6

27 files changed

Lines changed: 126 additions & 3144 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
2626
reports directly, not by re-parsing the parent-facing report's prose.
2727
Removes the `isXxxSubAgentReport` classifier family and per-reason parent
2828
hint functions in favor of a single structured switch.
29+
- Removed degenerate-repetition detection outright: the streamed-text loop
30+
detector, the tool-fingerprint period/cycle thrash check, the
31+
turns-since-user-message backstop, and the leaf no-progress (identical
32+
tool-call) counter. These were pattern-matching heuristics layered on top
33+
of the transport/policy line the harness actually needs — provider stream
34+
error handling, connection retry/backoff, and the turn budget — and had
35+
become a source of false-positive stalls without a clear win rate. The
36+
turn budget stop and the director's soft tool-only check-in nudge are
37+
unchanged; nothing else in this run/stop chain was touched.
2938

3039
## [0.2.108] - 2026-08-24
3140

src/agent/director.test.ts

Lines changed: 1 addition & 967 deletions
Large diffs are not rendered by default.

src/agent/director.ts

Lines changed: 14 additions & 223 deletions
Large diffs are not rendered by default.

src/agent/model-family-policy.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,8 @@ export interface ModelFamilyPolicy {
1212
* Consecutive tool-only assistant turns (tool calls, no text) before the
1313
* main chat director injects a one-shot wrap-up nudge. A long tool-only
1414
* streak is normal orchestration (Linear lookups, code reads, etc.) and
15-
* must not by itself stop the session — this is a soft check-in, not a
16-
* loop-protection trigger. The real stop signal is a repeating cycle in
17-
* the tool-fingerprint history, independent of this threshold — see
18-
* detectToolFingerprintThrash in subagent/stop-policy.ts.
15+
* must not by itself stop the session — this is a soft check-in, and it
16+
* never escalates to a pause on its own.
1917
*/
2018
toolOnlyTurnNudgeAt: number;
2119
/** Ephemeral nudge text injected at toolOnlyTurnNudgeAt. */

src/agent/prompts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ export function buildGuidelines(
153153
"- Prefer the typed spawn contract on every worker: `intent`, `success_criteria` (done-when), `do_not` (scope fence), and `report_focus` so workers finish instead of thrashing. Free-form `prompt` alone is weaker.",
154154
"- After workers return, merge their Summary/Findings into a coherent answer for the operator; do not paste raw sub-agent dumps.",
155155
"- Pass `maxTurns` on `task` when a job needs a bounded inference budget (unset is unbounded). On turn-budget salvage, re-dispatch with continuation context and a higher maxTurns only a few times on the same brief — after the re-dispatch cap, change approach instead of bumping turns again.",
156-
"- After thrash / no-progress / repetition / never-acted salvage, do not re-dispatch an identical brief (prompt/agent/intent/success_criteria/do_not) — it is refused. Change the brief to force a re-run; maxTurns alone does not unlock it.",
156+
"- After a thrash / no-ship / never-acted / never-edited salvage, do not re-dispatch an identical brief (prompt/agent/intent/success_criteria/do_not) — it is refused. Change the brief to force a re-run; maxTurns alone does not unlock it.",
157157
"- Use manage_tasks for your own coordination checklist; spawning workers is `task`, not manage_tasks.",
158158
"- If context is compacted automatically, do not stop tasks early due to token fear; persist progress via manage_tasks and worker reports.",
159159
]),

src/session/stream-journal.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,11 @@ describe("createCycleTextRecorder", () => {
7070
test("flush writes the buffer with a reason and resets", async () => {
7171
const recorder = createCycleTextRecorder(() => dir);
7272
recorder.handleEvent(delta("looping output"));
73-
await recorder.flush("repetition");
73+
await recorder.flush("cancelled");
7474

7575
const records = await readPartialRecords();
7676
expect(records).toHaveLength(1);
77-
expect(records[0]?.reason).toBe("repetition");
77+
expect(records[0]?.reason).toBe("cancelled");
7878
expect(records[0]?.text).toBe("looping output");
7979
expect(recorder.text()).toBe("");
8080
});
@@ -177,7 +177,7 @@ describe("createCycleTextRecorder", () => {
177177
expect(recorder.text()).toBe("visible reply");
178178
expect(recorder.thinkingText()).toBe("0/1 1/2 2/3 ");
179179

180-
await recorder.flush("repetition");
180+
await recorder.flush("cancelled");
181181
const records = await readPartialRecords();
182182
expect(records[0]?.text).toBe("visible reply");
183183
expect(records[0]?.thinkingText).toBe("0/1 1/2 2/3 ");
@@ -189,11 +189,11 @@ describe("createCycleTextRecorder", () => {
189189
// must still be diagnosable from thinkingText alone.
190190
const recorder = createCycleTextRecorder(() => dir);
191191
recorder.handleEvent(thinkingDelta("0/1 1/2 2/3 3/4 4/5 "));
192-
const snapshot = await recorder.dispose("repetition");
192+
const snapshot = await recorder.dispose("cancelled");
193193

194194
expect(snapshot).toBe("");
195195
const records = await readPartialRecords();
196-
expect(records[0]?.reason).toBe("repetition");
196+
expect(records[0]?.reason).toBe("cancelled");
197197
expect(records[0]?.text).toBe("");
198198
expect(records[0]?.thinkingText).toBe("0/1 1/2 2/3 3/4 4/5 ");
199199
});

src/session/stream-journal.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ export function appendCycleText(
3333
}
3434

3535
export type PartialFlushReason =
36-
| "repetition"
3736
| "deadline"
3837
| "cancelled"
3938
| "interrupted"

src/session/summarizer.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import { createDefaultDependencies } from "@intx/inference/providers";
1212
import { getLogger } from "@intx/log";
1313
import type { ConversationTurn, InferenceSource } from "@intx/types/runtime";
1414
import { LOG_NAMESPACE_ROOT } from "../branding.js";
15-
import { detectRepetition } from "../subagent/repetition.js";
1615
import { buildTurnSummary } from "./compactor.js";
1716

1817
const logger = getLogger([LOG_NAMESPACE_ROOT, "session", "summarizer"]);
@@ -75,13 +74,7 @@ export function condenseTurns(turns: ConversationTurn[]): string {
7574
if (turn.role === "user") {
7675
userMessages.push(block.text.slice(0, 400));
7776
} else if (turn.role === "assistant" && block.text.length > 0) {
78-
// Compaction often fires mid-degeneration, when the tail of the
79-
// history is the model looping one phrase. Seeding the summary from
80-
// those turns hands the looped text to the summarizer verbatim, so
81-
// repetition-flagged turns are dropped from the excerpt entirely.
82-
if (detectRepetition(block.text) === null) {
83-
assistantSnippets.push(block.text.slice(0, 300));
84-
}
77+
assistantSnippets.push(block.text.slice(0, 300));
8578
}
8679
}
8780
if (block.type === "tool_call") {

src/subagent/brief-dispatch.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,7 @@ import type { TaskIntent } from "./report.js";
1515
import type { ForcedStopReason } from "./stop-policy.js";
1616

1717
/** Salvage classes that must not be re-dispatched with an identical brief. */
18-
export type HardBlockSalvage =
19-
"no-ship" | "no-progress" | "repetition" | "never-acted" | "never-edited";
18+
export type HardBlockSalvage = "no-ship" | "never-acted" | "never-edited";
2019

2120
// Every forced-stop reason a leaf can report maps 1:1 onto a salvage kind
2221
// the parent ledger cares about.
@@ -44,13 +43,7 @@ export interface BriefDispatchRecord {
4443
*/
4544
export const TURN_BUDGET_STOP_AFTER_DISPATCHES = 3;
4645

47-
const HARD_BLOCK_SALVAGES = new Set<BriefSalvageKind>([
48-
"no-ship",
49-
"no-progress",
50-
"repetition",
51-
"never-acted",
52-
"never-edited",
53-
]);
46+
const HARD_BLOCK_SALVAGES = new Set<BriefSalvageKind>(["no-ship", "never-acted", "never-edited"]);
5447

5548
export function isHardBlockSalvage(kind: BriefSalvageKind): kind is HardBlockSalvage {
5649
return HARD_BLOCK_SALVAGES.has(kind);

0 commit comments

Comments
 (0)