Skip to content

Commit 82ad8e1

Browse files
committed
Delete repetition/thrash/backstop detection outright
Remove all behavior-policing repetition, cycle, and no-progress-by-turn- count detection: the streamed-text loop detector and contentless-growth guard on sub-agent runs (src/subagent/repetition.ts and its call sites in run.ts), the tool-fingerprint period/cycle thrash pause and turns-since-user-message backstop on the main director loop (stop-policy.ts, director.ts), and the standalone period-detection utility they both used (src/util/period-detection.ts). These were compensating for bugs now fixed at their cause (tool-arg rejections driving identical retries, missing prompt_cache_key, byte-identical thinking-only turns, apply_patch reading line-numbered output), and the streamed-text detector's own defaults were shown to kill healthy runs reacting correctly to a stable external error. Kept: transport-level abort handling (provider stream errors, connection failures, retry/backoff on the model API call) and the turn-budget / no-progress (identical tool-call fingerprint) leaf worker limits, which are unrelated policy.
1 parent 6c05612 commit 82ad8e1

24 files changed

Lines changed: 118 additions & 2738 deletions

‎CHANGELOG.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,23 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain
1111
parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1212
`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script.
1313

14+
## [Unreleased]
15+
16+
### Agent
17+
18+
- Removed behavior-policing repetition/cycle/thrash detection outright: the
19+
streamed-text loop detector and contentless-growth guard on sub-agent runs,
20+
the tool-fingerprint period/cycle thrash pause and turns-since-user-message
21+
backstop on the main director loop, and the standalone period-detection
22+
utility they shared. These were compensating for bugs (tool-arg rejections
23+
driving identical retries, missing prompt-cache keys, byte-identical
24+
thinking-only turns, line-numbered patch input) that are now fixed at their
25+
cause, and the streamed-text detector's own defaults were shown to kill
26+
healthy runs reacting correctly to a stable external error. Transport-level
27+
abort handling (provider stream errors, connection failures, retry/backoff
28+
on the model API call) and turn-budget / no-progress (identical tool-call
29+
fingerprint) limits are unchanged.
30+
1431
## [0.2.108] - 2026-08-24
1532

1633
### Agent

‎src/agent/director.test.ts‎

Lines changed: 10 additions & 933 deletions
Large diffs are not rendered by default.

‎src/agent/director.ts‎

Lines changed: 7 additions & 225 deletions
Large diffs are not rendered by default.

‎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: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,11 @@ import {
1919
isNeverEditedSubAgentReport,
2020
isNoProgressSubAgentReport,
2121
isNoShipSubAgentReport,
22-
isRepetitionSubAgentReport,
2322
isTurnBudgetSubAgentReport,
2423
} from "./stop-policy.js";
2524

2625
/** Salvage classes that must not be re-dispatched with an identical brief. */
27-
export type HardBlockSalvage =
28-
"no-ship" | "no-progress" | "repetition" | "never-acted" | "never-edited";
26+
export type HardBlockSalvage = "no-ship" | "no-progress" | "never-acted" | "never-edited";
2927

3028
export type BriefSalvageKind =
3129
HardBlockSalvage | "turn-budget" | "deadline" | "stalled" | "cancelled" | "incomplete-report";
@@ -55,7 +53,6 @@ export const TURN_BUDGET_STOP_AFTER_DISPATCHES = 3;
5553
const HARD_BLOCK_SALVAGES = new Set<BriefSalvageKind>([
5654
"no-ship",
5755
"no-progress",
58-
"repetition",
5956
"never-acted",
6057
"never-edited",
6158
]);
@@ -86,7 +83,6 @@ export function isIncompleteReportSubAgentReport(report: string): boolean {
8683
export function classifyBriefSalvage(report: string): BriefSalvageKind | null {
8784
// Order: more specific salvage phrases first.
8885
if (isNoShipSubAgentReport(report)) return "no-ship";
89-
if (isRepetitionSubAgentReport(report)) return "repetition";
9086
if (isNeverEditedSubAgentReport(report)) return "never-edited";
9187
if (isNeverActedSubAgentReport(report)) return "never-acted";
9288
if (isNoProgressSubAgentReport(report)) return "no-progress";

‎src/subagent/fleet-report.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,13 +152,13 @@ describe("forced-stop reasons", () => {
152152
lane({
153153
id: "api",
154154
status: "done",
155-
stopReason: 'repetition — window "Groaning. " × 1363',
155+
stopReason: "turn-budget — 40 turns",
156156
}),
157157
lane({ id: "docs" }),
158158
],
159159
T0 + 1000,
160160
);
161-
expect(updates).toEqual(['api stopped — repetition — window "Groaning. " × 1363']);
161+
expect(updates).toEqual(["api stopped — turn-budget — 40 turns"]);
162162
});
163163

164164
test("a cancelled lane carries its recorded reason", () => {

‎src/subagent/index.test.ts‎

Lines changed: 45 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import {
1919
formatSubAgentReport,
2020
nextToolCallStreak,
2121
parseSubAgentReport,
22-
repetitionStopDetail,
2322
stopReasonFromReport,
2423
appendDeadlineParentHint,
2524
appendNeverActedParentHint,
@@ -196,6 +195,49 @@ describe("sub-agent stop helpers", () => {
196195
).toBe("no-progress");
197196
});
198197

198+
// CL-6995: there is no repetition/similarity detector over tool results or
199+
// streamed text any more. A worker that keeps getting the same failure back
200+
// from the environment (e.g. a module a concurrent sibling has not finished
201+
// writing) and keeps varying its own tool calls in response must run to
202+
// completion rather than being killed mid-stream for "looping" on a stable
203+
// external error.
204+
test("many turns reacting to the same repeated tool failure still reach a normal complete", () => {
205+
let consecutiveIdentical = 0;
206+
let lastFingerprint: string | null = null;
207+
const turns = DEFAULT_SUBAGENT_MAX_TURNS - 1;
208+
for (let i = 0; i < turns; i++) {
209+
// Each turn varies its own tool call (different path), even though the
210+
// simulated tool result content would be identical every time.
211+
const fingerprint = fingerprintToolCalls([
212+
{ type: "tool_call", name: "read_file", arguments: { path: `attempt-${i}.ts` } },
213+
]);
214+
consecutiveIdentical = fingerprint === lastFingerprint ? consecutiveIdentical + 1 : 1;
215+
lastFingerprint = fingerprint;
216+
expect(
217+
evaluateSubAgentStop({
218+
hasToolCalls: true,
219+
everHadToolCalls: true,
220+
turnsCompleted: i + 1,
221+
maxTurns: DEFAULT_SUBAGENT_MAX_TURNS,
222+
consecutiveIdentical,
223+
repeatLimit: DEFAULT_SUBAGENT_REPEAT_LIMIT,
224+
}),
225+
).toBeNull();
226+
}
227+
// The worker finally stops calling tools and reports a real result.
228+
expect(
229+
evaluateSubAgentStop({
230+
hasToolCalls: false,
231+
everHadToolCalls: true,
232+
turnsCompleted: turns + 1,
233+
maxTurns: DEFAULT_SUBAGENT_MAX_TURNS,
234+
consecutiveIdentical: 0,
235+
repeatLimit: DEFAULT_SUBAGENT_REPEAT_LIMIT,
236+
lastAssistantText: "## Summary\nDone.\n\n## Findings\nx\n\n## Blockers\nNone\n\n## Paths\n",
237+
}),
238+
).toBe("complete");
239+
});
240+
199241
test("fingerprint is null when a turn has no tool calls", () => {
200242
expect(fingerprintToolCalls([{ type: "text" }])).toBeNull();
201243
});
@@ -775,20 +817,6 @@ describe("sub-agent stop helpers", () => {
775817
});
776818

777819
test("forcedStopReport carries a machine-readable Stopped line the parent sees verbatim", () => {
778-
const repetition = forcedStopReport(
779-
"repetition",
780-
"Looped window (repeated 1363x): Groaning. ",
781-
'window "Groaning. " × 1363',
782-
);
783-
expect(repetition.startsWith('Stopped: repetition — window "Groaning. " × 1363\n')).toBe(true);
784-
expect(parseSubAgentReport(repetition).stopped).toBe('repetition — window "Groaning. " × 1363');
785-
expect(stopReasonFromReport(repetition)).toBe('repetition — window "Groaning. " × 1363');
786-
// Survives runSubAgent's parse/format normalization round-trip.
787-
const roundTripped = formatSubAgentReport(parseSubAgentReport(repetition));
788-
expect(stopReasonFromReport(roundTripped)).toBe('repetition — window "Groaning. " × 1363');
789-
// Classifiers and hints still fire on the unchanged Summary text.
790-
expect(appendSubAgentParentHints(repetition)).toContain("degenerated into a loop");
791-
792820
const cancelled = forcedStopReport("cancelled", "partial", "Session closed");
793821
expect(stopReasonFromReport(cancelled)).toBe("cancelled — Session closed");
794822
// Without a detail the line is the bare reason token.
@@ -808,18 +836,6 @@ describe("sub-agent stop helpers", () => {
808836
expect(stopReasonFromReport("## Summary\nDone.\n\n## Findings\nx")).toBe(null);
809837
});
810838

811-
test("repetitionStopDetail reports period length and repeat count, never the looped text", () => {
812-
expect(repetitionStopDetail({ window: "Groaning. ", repeats: 1363 }, null)).toBe(
813-
"period 10ch × 1363",
814-
);
815-
expect(
816-
repetitionStopDetail(
817-
{ window: "x".repeat(500), repeats: 7 },
818-
{ windowMinChars: 8, repeatThreshold: 16, probeChars: 8192 },
819-
),
820-
).toBe("period 500ch × 7 (threshold 16)");
821-
});
822-
823839
test("createSubAgentRunController aborts on an explicit deadline and reports deadlineHit", async () => {
824840
const ctl = createSubAgentRunController(undefined, 20);
825841
expect(ctl.signal.aborted).toBe(false);
@@ -913,27 +929,6 @@ describe("sub-agent stop helpers", () => {
913929
expect(resolveSubAgentCatchOutcome({ deadlineHit: false, hadProgress: false })).toBe("rethrow");
914930
});
915931

916-
test("resolveSubAgentCatchOutcome salvages a repetition abort even with zero progress", () => {
917-
expect(
918-
resolveSubAgentCatchOutcome({
919-
deadlineHit: false,
920-
hadProgress: false,
921-
repetitionHit: true,
922-
}),
923-
).toBe("salvage-repetition");
924-
});
925-
926-
test("repetition forced stop reports the loop and warns against identical re-dispatch", () => {
927-
const report = forcedStopReport("repetition", "dig footer/chrome... 0/1.0 done. 1 remaining.");
928-
const parsed = parseSubAgentReport(report);
929-
expect(parsed.summary).toContain("degenerate repetition");
930-
expect(parsed.findings).toContain("dig footer/chrome");
931-
expect(parsed.blockers).toContain("will be refused");
932-
expect(parsed.blockers).toContain("not maxTurns alone");
933-
const hinted = appendSubAgentParentHints(report);
934-
expect(hinted).toContain("Do not re-dispatch the identical brief");
935-
});
936-
937932
test("partialTextFromEvent reads stream inference.done data.turn content", () => {
938933
const text = partialTextFromEvent({
939934
type: "inference.done",
@@ -2162,8 +2157,8 @@ describe("brief re-dispatch ledger (CL-4343 / CL-5203)", () => {
21622157
expect(ledger.admit(other).ok).toBe(true);
21632158
});
21642159

2165-
test("hard-blocks no-progress, repetition, never-acted, never-edited; not turn-budget", () => {
2166-
for (const salvage of ["no-progress", "repetition", "never-acted", "never-edited"] as const) {
2160+
test("hard-blocks no-progress, never-acted, never-edited; not turn-budget", () => {
2161+
for (const salvage of ["no-progress", "never-acted", "never-edited"] as const) {
21672162
const ledger = createBriefDispatchLedger();
21682163
const fp = fingerprintTaskBrief({ prompt: `job ${salvage}` });
21692164
expect(ledger.admit(fp).ok).toBe(true);
@@ -2225,7 +2220,6 @@ describe("brief re-dispatch ledger (CL-4343 / CL-5203)", () => {
22252220

22262221
test("classifyBriefSalvage maps forced-stop envelopes", () => {
22272222
expect(classifyBriefSalvage(forcedStopReport("no-progress", "x"))).toBe("no-progress");
2228-
expect(classifyBriefSalvage(forcedStopReport("repetition", "x"))).toBe("repetition");
22292223
expect(classifyBriefSalvage(forcedStopReport("never-acted", "x"))).toBe("never-acted");
22302224
expect(classifyBriefSalvage(forcedStopReport("never-edited", "x"))).toBe("never-edited");
22312225
expect(classifyBriefSalvage(forcedStopReport("no-ship", "x"))).toBe("no-ship");

‎src/subagent/index.ts‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ export {
5050
appendDeadlineParentHint,
5151
appendNeverActedParentHint,
5252
appendNoProgressParentHint,
53-
appendRepetitionParentHint,
5453
appendSubAgentParentHints,
5554
appendTurnBudgetParentHint,
5655
evaluateSubAgentStop,
@@ -60,7 +59,6 @@ export {
6059
isNeverActedSubAgentReport,
6160
isNeverEditedSubAgentReport,
6261
isNoProgressSubAgentReport,
63-
isRepetitionSubAgentReport,
6462
isTurnBudgetSubAgentReport,
6563
nextToolCallStreak,
6664
partialTextFromEvent,
@@ -116,7 +114,6 @@ export {
116114
buildSubAgentPrimarySource,
117115
coreSubAgentWebTools,
118116
createSubAgentRunController,
119-
repetitionStopDetail,
120117
runSubAgent,
121118
shouldRequireEvidence,
122119
type SubAgentRunController,

0 commit comments

Comments
 (0)