Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,23 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain
parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script.

## [Unreleased]

### Agent

- Removed behavior-policing repetition/cycle/thrash detection outright: the
streamed-text loop detector and contentless-growth guard on sub-agent runs,
the tool-fingerprint period/cycle thrash pause and turns-since-user-message
backstop on the main director loop, and the standalone period-detection
utility they shared. These were compensating for bugs (tool-arg rejections
driving identical retries, missing prompt-cache keys, byte-identical
thinking-only turns, line-numbered patch input) that are now fixed at their
cause, and the streamed-text detector's own defaults were shown to kill
healthy runs reacting correctly to a stable external error. Transport-level
abort handling (provider stream errors, connection failures, retry/backoff
on the model API call) and turn-budget / no-progress (identical tool-call
fingerprint) limits are unchanged.

## [0.2.108] - 2026-08-24

### Agent
Expand Down
943 changes: 10 additions & 933 deletions src/agent/director.test.ts

Large diffs are not rendered by default.

232 changes: 7 additions & 225 deletions src/agent/director.ts

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions src/session/stream-journal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,11 @@ describe("createCycleTextRecorder", () => {
test("flush writes the buffer with a reason and resets", async () => {
const recorder = createCycleTextRecorder(() => dir);
recorder.handleEvent(delta("looping output"));
await recorder.flush("repetition");
await recorder.flush("cancelled");

const records = await readPartialRecords();
expect(records).toHaveLength(1);
expect(records[0]?.reason).toBe("repetition");
expect(records[0]?.reason).toBe("cancelled");
expect(records[0]?.text).toBe("looping output");
expect(recorder.text()).toBe("");
});
Expand Down Expand Up @@ -177,7 +177,7 @@ describe("createCycleTextRecorder", () => {
expect(recorder.text()).toBe("visible reply");
expect(recorder.thinkingText()).toBe("0/1 1/2 2/3 ");

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

expect(snapshot).toBe("");
const records = await readPartialRecords();
expect(records[0]?.reason).toBe("repetition");
expect(records[0]?.reason).toBe("cancelled");
expect(records[0]?.text).toBe("");
expect(records[0]?.thinkingText).toBe("0/1 1/2 2/3 3/4 4/5 ");
});
Expand Down
1 change: 0 additions & 1 deletion src/session/stream-journal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ export function appendCycleText(
}

export type PartialFlushReason =
| "repetition"
| "deadline"
| "cancelled"
| "interrupted"
Expand Down
9 changes: 1 addition & 8 deletions src/session/summarizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import { createDefaultDependencies } from "@intx/inference/providers";
import { getLogger } from "@intx/log";
import type { ConversationTurn, InferenceSource } from "@intx/types/runtime";
import { LOG_NAMESPACE_ROOT } from "../branding.js";
import { detectRepetition } from "../subagent/repetition.js";
import { buildTurnSummary } from "./compactor.js";

const logger = getLogger([LOG_NAMESPACE_ROOT, "session", "summarizer"]);
Expand Down Expand Up @@ -75,13 +74,7 @@ export function condenseTurns(turns: ConversationTurn[]): string {
if (turn.role === "user") {
userMessages.push(block.text.slice(0, 400));
} else if (turn.role === "assistant" && block.text.length > 0) {
// Compaction often fires mid-degeneration, when the tail of the
// history is the model looping one phrase. Seeding the summary from
// those turns hands the looped text to the summarizer verbatim, so
// repetition-flagged turns are dropped from the excerpt entirely.
if (detectRepetition(block.text) === null) {
assistantSnippets.push(block.text.slice(0, 300));
}
assistantSnippets.push(block.text.slice(0, 300));
}
}
if (block.type === "tool_call") {
Expand Down
6 changes: 1 addition & 5 deletions src/subagent/brief-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,11 @@ import {
isNeverEditedSubAgentReport,
isNoProgressSubAgentReport,
isNoShipSubAgentReport,
isRepetitionSubAgentReport,
isTurnBudgetSubAgentReport,
} from "./stop-policy.js";

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

export type BriefSalvageKind =
HardBlockSalvage | "turn-budget" | "deadline" | "stalled" | "cancelled" | "incomplete-report";
Expand Down Expand Up @@ -55,7 +53,6 @@ export const TURN_BUDGET_STOP_AFTER_DISPATCHES = 3;
const HARD_BLOCK_SALVAGES = new Set<BriefSalvageKind>([
"no-ship",
"no-progress",
"repetition",
"never-acted",
"never-edited",
]);
Expand Down Expand Up @@ -86,7 +83,6 @@ export function isIncompleteReportSubAgentReport(report: string): boolean {
export function classifyBriefSalvage(report: string): BriefSalvageKind | null {
// Order: more specific salvage phrases first.
if (isNoShipSubAgentReport(report)) return "no-ship";
if (isRepetitionSubAgentReport(report)) return "repetition";
if (isNeverEditedSubAgentReport(report)) return "never-edited";
if (isNeverActedSubAgentReport(report)) return "never-acted";
if (isNoProgressSubAgentReport(report)) return "no-progress";
Expand Down
4 changes: 2 additions & 2 deletions src/subagent/fleet-report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,13 +152,13 @@ describe("forced-stop reasons", () => {
lane({
id: "api",
status: "done",
stopReason: 'repetitionwindow "Groaning. " × 1363',
stopReason: "turn-budget40 turns",
}),
lane({ id: "docs" }),
],
T0 + 1000,
);
expect(updates).toEqual(['api stopped — repetitionwindow "Groaning. " × 1363']);
expect(updates).toEqual(["api stopped — turn-budget40 turns"]);
});

test("a cancelled lane carries its recorded reason", () => {
Expand Down
96 changes: 45 additions & 51 deletions src/subagent/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
formatSubAgentReport,
nextToolCallStreak,
parseSubAgentReport,
repetitionStopDetail,
stopReasonFromReport,
appendDeadlineParentHint,
appendNeverActedParentHint,
Expand Down Expand Up @@ -196,6 +195,49 @@ describe("sub-agent stop helpers", () => {
).toBe("no-progress");
});

// CL-6995: there is no repetition/similarity detector over tool results or
// streamed text any more. A worker that keeps getting the same failure back
// from the environment (e.g. a module a concurrent sibling has not finished
// writing) and keeps varying its own tool calls in response must run to
// completion rather than being killed mid-stream for "looping" on a stable
// external error.
test("many turns reacting to the same repeated tool failure still reach a normal complete", () => {
let consecutiveIdentical = 0;
let lastFingerprint: string | null = null;
const turns = DEFAULT_SUBAGENT_MAX_TURNS - 1;
for (let i = 0; i < turns; i++) {
// Each turn varies its own tool call (different path), even though the
// simulated tool result content would be identical every time.
const fingerprint = fingerprintToolCalls([
{ type: "tool_call", name: "read_file", arguments: { path: `attempt-${i}.ts` } },
]);
consecutiveIdentical = fingerprint === lastFingerprint ? consecutiveIdentical + 1 : 1;
lastFingerprint = fingerprint;
expect(
evaluateSubAgentStop({
hasToolCalls: true,
everHadToolCalls: true,
turnsCompleted: i + 1,
maxTurns: DEFAULT_SUBAGENT_MAX_TURNS,
consecutiveIdentical,
repeatLimit: DEFAULT_SUBAGENT_REPEAT_LIMIT,
}),
).toBeNull();
}
// The worker finally stops calling tools and reports a real result.
expect(
evaluateSubAgentStop({
hasToolCalls: false,
everHadToolCalls: true,
turnsCompleted: turns + 1,
maxTurns: DEFAULT_SUBAGENT_MAX_TURNS,
consecutiveIdentical: 0,
repeatLimit: DEFAULT_SUBAGENT_REPEAT_LIMIT,
lastAssistantText: "## Summary\nDone.\n\n## Findings\nx\n\n## Blockers\nNone\n\n## Paths\n",
}),
).toBe("complete");
});

test("fingerprint is null when a turn has no tool calls", () => {
expect(fingerprintToolCalls([{ type: "text" }])).toBeNull();
});
Expand Down Expand Up @@ -775,20 +817,6 @@ describe("sub-agent stop helpers", () => {
});

test("forcedStopReport carries a machine-readable Stopped line the parent sees verbatim", () => {
const repetition = forcedStopReport(
"repetition",
"Looped window (repeated 1363x): Groaning. ",
'window "Groaning. " × 1363',
);
expect(repetition.startsWith('Stopped: repetition — window "Groaning. " × 1363\n')).toBe(true);
expect(parseSubAgentReport(repetition).stopped).toBe('repetition — window "Groaning. " × 1363');
expect(stopReasonFromReport(repetition)).toBe('repetition — window "Groaning. " × 1363');
// Survives runSubAgent's parse/format normalization round-trip.
const roundTripped = formatSubAgentReport(parseSubAgentReport(repetition));
expect(stopReasonFromReport(roundTripped)).toBe('repetition — window "Groaning. " × 1363');
// Classifiers and hints still fire on the unchanged Summary text.
expect(appendSubAgentParentHints(repetition)).toContain("degenerated into a loop");

const cancelled = forcedStopReport("cancelled", "partial", "Session closed");
expect(stopReasonFromReport(cancelled)).toBe("cancelled — Session closed");
// Without a detail the line is the bare reason token.
Expand All @@ -808,18 +836,6 @@ describe("sub-agent stop helpers", () => {
expect(stopReasonFromReport("## Summary\nDone.\n\n## Findings\nx")).toBe(null);
});

test("repetitionStopDetail reports period length and repeat count, never the looped text", () => {
expect(repetitionStopDetail({ window: "Groaning. ", repeats: 1363 }, null)).toBe(
"period 10ch × 1363",
);
expect(
repetitionStopDetail(
{ window: "x".repeat(500), repeats: 7 },
{ windowMinChars: 8, repeatThreshold: 16, probeChars: 8192 },
),
).toBe("period 500ch × 7 (threshold 16)");
});

test("createSubAgentRunController aborts on an explicit deadline and reports deadlineHit", async () => {
const ctl = createSubAgentRunController(undefined, 20);
expect(ctl.signal.aborted).toBe(false);
Expand Down Expand Up @@ -913,27 +929,6 @@ describe("sub-agent stop helpers", () => {
expect(resolveSubAgentCatchOutcome({ deadlineHit: false, hadProgress: false })).toBe("rethrow");
});

test("resolveSubAgentCatchOutcome salvages a repetition abort even with zero progress", () => {
expect(
resolveSubAgentCatchOutcome({
deadlineHit: false,
hadProgress: false,
repetitionHit: true,
}),
).toBe("salvage-repetition");
});

test("repetition forced stop reports the loop and warns against identical re-dispatch", () => {
const report = forcedStopReport("repetition", "dig footer/chrome... 0/1.0 done. 1 remaining.");
const parsed = parseSubAgentReport(report);
expect(parsed.summary).toContain("degenerate repetition");
expect(parsed.findings).toContain("dig footer/chrome");
expect(parsed.blockers).toContain("will be refused");
expect(parsed.blockers).toContain("not maxTurns alone");
const hinted = appendSubAgentParentHints(report);
expect(hinted).toContain("Do not re-dispatch the identical brief");
});

test("partialTextFromEvent reads stream inference.done data.turn content", () => {
const text = partialTextFromEvent({
type: "inference.done",
Expand Down Expand Up @@ -2162,8 +2157,8 @@ describe("brief re-dispatch ledger (CL-4343 / CL-5203)", () => {
expect(ledger.admit(other).ok).toBe(true);
});

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

test("classifyBriefSalvage maps forced-stop envelopes", () => {
expect(classifyBriefSalvage(forcedStopReport("no-progress", "x"))).toBe("no-progress");
expect(classifyBriefSalvage(forcedStopReport("repetition", "x"))).toBe("repetition");
expect(classifyBriefSalvage(forcedStopReport("never-acted", "x"))).toBe("never-acted");
expect(classifyBriefSalvage(forcedStopReport("never-edited", "x"))).toBe("never-edited");
expect(classifyBriefSalvage(forcedStopReport("no-ship", "x"))).toBe("no-ship");
Expand Down
3 changes: 0 additions & 3 deletions src/subagent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ export {
appendDeadlineParentHint,
appendNeverActedParentHint,
appendNoProgressParentHint,
appendRepetitionParentHint,
appendSubAgentParentHints,
appendTurnBudgetParentHint,
evaluateSubAgentStop,
Expand All @@ -60,7 +59,6 @@ export {
isNeverActedSubAgentReport,
isNeverEditedSubAgentReport,
isNoProgressSubAgentReport,
isRepetitionSubAgentReport,
isTurnBudgetSubAgentReport,
nextToolCallStreak,
partialTextFromEvent,
Expand Down Expand Up @@ -116,7 +114,6 @@ export {
buildSubAgentPrimarySource,
coreSubAgentWebTools,
createSubAgentRunController,
repetitionStopDetail,
runSubAgent,
shouldRequireEvidence,
type SubAgentRunController,
Expand Down
Loading
Loading