From f46f74c6e6716af5a7b70a3d7f6e8ac6a5f7b078 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:58:51 -0500 Subject: [PATCH 01/32] fix(agent): detect a repeated same-tool fan-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streaks compared a tool's last fingerprint, so a fan-out of distinct arguments reissued verbatim never accumulated evidence: `read(a), read(b), read(c)` has a different last call every round, and each round's first call reset the streak to 1. Measured before this change — 8 identical rounds of a 3-call fan-out produced zero detections, while single-call rounds tripped at round 2. Distinct-argument fan-out is the dominant shape in parallel-tool-calling agents, so this was the common case going unseen. A round's identity for one tool is now the set of fingerprints it was called with, compared across rounds. The set completes only once every call has arrived, so a fan-out scores on the call that completes the match and the round's earlier calls report the pre-match streak — a partial fan-out is not yet a repeat. Unchanged: single-call rounds, in-round duplicate collapsing (one decision per (tool, fingerprint) per round), resumed streaks, persisted state shape, and verdict payloads. The new round fields are run-local and never serialized. Verified: 7 new tests covering accumulation, order-insensitivity within a round, reset on changed membership, subset-is-not-a-repeat, and the single-call and resume controls. 3 of them fail without this fix. Full suite 753 pass, typecheck and biome clean. --- .changeset/doom-loop-fanout.md | 16 ++ packages/agent/src/lib/doom-loop.ts | 109 ++++++++- .../agent/tests/unit/doom-loop-fanout.test.ts | 228 ++++++++++++++++++ 3 files changed, 345 insertions(+), 8 deletions(-) create mode 100644 .changeset/doom-loop-fanout.md create mode 100644 packages/agent/tests/unit/doom-loop-fanout.test.ts diff --git a/.changeset/doom-loop-fanout.md b/.changeset/doom-loop-fanout.md new file mode 100644 index 00000000..d3be3193 --- /dev/null +++ b/.changeset/doom-loop-fanout.md @@ -0,0 +1,16 @@ +--- +'@openrouter/agent': patch +--- + +Fix doom-loop detection missing a repeated same-tool fan-out. + +Streaks compared a tool's *last* fingerprint, so `read(a), read(b), read(c)` +reissued verbatim had a different last call every round and each round's first +call reset the streak to 1. Eight identical rounds of a three-call fan-out +produced zero detections, while single-call rounds tripped at round 2 — and +distinct-argument fan-out is the dominant shape in parallel-tool-calling agents. + +A round's identity for one tool is now the *set* of fingerprints it was called +with, compared across rounds. Order within the round does not matter, a changed +member resets the streak, and a strict subset is not a repeat. Single-call +rounds, in-round duplicate collapsing, and resumed streaks are unchanged. diff --git a/packages/agent/src/lib/doom-loop.ts b/packages/agent/src/lib/doom-loop.ts index 9b0f2338..aa9256e2 100644 --- a/packages/agent/src/lib/doom-loop.ts +++ b/packages/agent/src/lib/doom-loop.ts @@ -787,6 +787,23 @@ interface StreakEntry extends DoomLoopStreak { * always increment on its first record, whatever its round numbering. */ round?: number; + /** + * Fingerprints this tool has been called with during {@link round}, in + * sorted order. A round's identity is the whole set, not its last call, so + * a fan-out of *distinct* arguments (`read(a), read(b), read(c)`) reissued + * verbatim is a repeat. Comparing only the last call let each round's first + * call reset the streak, so a repeating fan-out never accumulated evidence. + * Never serialized: it is meaningful only within one round. + */ + roundFingerprints?: readonly string[]; + /** + * The previous round's completed fingerprint set — what this round is being + * compared against — and the streak that round earned. Held so a fan-out can + * be re-evaluated as each of its calls arrives without losing the baseline. + * Never serialized: meaningful only within one run. + */ + priorRoundFingerprints?: readonly string[]; + priorStreak?: number; } /** @@ -890,6 +907,10 @@ export class DoomLoopMonitor { fingerprint: entry.fingerprint, streak: entry.streak, // round intentionally absent: first resumed record increments. + roundFingerprints: [ + entry.fingerprint, + ], + priorStreak: entry.streak, }); } } @@ -944,22 +965,79 @@ export class DoomLoopMonitor { ): Promise { const fingerprint = await fingerprintToolCall(toolName, keyMaterial); const previous = this.tools.get(toolName); + const isSameRound = previous?.round !== undefined && previous.round === round; + + /* + * A round's identity for one tool is the *set* of fingerprints it was + * called with, so the streak compares whole rounds. Within the current + * round we accumulate; across rounds we compare the completed set. The + * `fingerprint` field keeps holding the latest call so persisted state and + * verdict payloads are unchanged. + */ let streak: number; let duplicateInRound = false; - if (previous && previous.fingerprint === fingerprint) { - if (previous.round !== undefined && previous.round === round) { - streak = previous.streak; - duplicateInRound = true; - } else { - streak = previous.streak + 1; - } + let roundFingerprints: readonly string[]; + + if (isSameRound && previous) { + /* + * Still inside the round being compared. Extend its set and re-evaluate: + * a fan-out only matches the previous round once every member has been + * seen, so the streak lands on the call that completes the match. The + * round's earlier calls already reported the pre-match streak, which is + * correct — a partial fan-out is not yet a repeat. + */ + const seen = previous.roundFingerprints ?? [ + previous.fingerprint, + ]; + roundFingerprints = mergeFingerprint(seen, fingerprint); + duplicateInRound = seen.includes(fingerprint); + streak = + previous.priorRoundFingerprints !== undefined && + setsMatch(previous.priorRoundFingerprints, roundFingerprints) + ? (previous.priorStreak ?? 0) + 1 + : 1; } else { - streak = 1; + /* + * A new round opens with one call. It repeats the previous round only if + * that round was also a single call with this fingerprint; a multi-call + * previous round cannot be matched yet and resolves as the fan-out fills + * in above. + */ + roundFingerprints = [ + fingerprint, + ]; + streak = + previous !== undefined && + setsMatch( + previous.roundFingerprints ?? [ + previous.fingerprint, + ], + roundFingerprints, + ) + ? previous.streak + 1 + : 1; } + + /* The completed set this round is measured against, and the streak it earned. */ + const priorRoundFingerprints = isSameRound + ? previous?.priorRoundFingerprints + : (previous?.roundFingerprints ?? + (previous + ? [ + previous.fingerprint, + ] + : undefined)); this.tools.set(toolName, { fingerprint, streak, round, + roundFingerprints, + ...(priorRoundFingerprints !== undefined + ? { + priorRoundFingerprints, + } + : {}), + priorStreak: isSameRound ? (previous?.priorStreak ?? 0) : (previous?.streak ?? 0), }); const allowBlock = options?.allowBlock ?? true; @@ -1086,3 +1164,18 @@ function strongerVerdict( } //#endregion + +/** Adds a fingerprint to a round's sorted set, ignoring duplicates. */ +function mergeFingerprint(existing: readonly string[], fingerprint: string): readonly string[] { + return existing.includes(fingerprint) + ? existing + : [ + ...existing, + fingerprint, + ].sort(); +} + +/** Set equality over two sorted fingerprint lists. */ +function setsMatch(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} diff --git a/packages/agent/tests/unit/doom-loop-fanout.test.ts b/packages/agent/tests/unit/doom-loop-fanout.test.ts new file mode 100644 index 00000000..cca1daed --- /dev/null +++ b/packages/agent/tests/unit/doom-loop-fanout.test.ts @@ -0,0 +1,228 @@ +/** + * Regression suite for the same-tool fan-out gap. + * + * A streak keyed on a tool's *last* fingerprint cannot see a repeating + * fan-out: `read(a), read(b), read(c)` reissued verbatim has a different last + * call every round, so each round's first call reset the streak to 1 and the + * run never accumulated evidence. Measured before the fix: 8 identical rounds + * of a 3-call fan-out produced zero detections, while single-call rounds + * tripped at round 2. + * + * A round's identity for one tool is therefore the *set* of fingerprints it + * was called with. The set is only complete once every call has arrived, so a + * fan-out scores on the call that completes the match — the round's earlier + * calls legitimately report the pre-match streak. + */ +import { describe, expect, it } from 'vitest'; + +import { DoomLoopMonitor, resolveDoomLoopOption } from '../../src/lib/doom-loop.js'; + +type RecordedAction = string; + +const monitor = (): DoomLoopMonitor => new DoomLoopMonitor(resolveDoomLoopOption(true)); + +async function playRounds(fanouts: readonly (readonly string[])[]): Promise { + const detector = monitor(); + const actions: RecordedAction[][] = []; + for (const [round, paths] of fanouts.entries()) { + const roundActions: RecordedAction[] = []; + for (const path of paths) { + const record = await detector.recordToolCall( + 'read', + { + path, + }, + round, + ); + roundActions.push(record.verdict?.action ?? 'none'); + } + actions.push(roundActions); + } + return actions; +} + +describe('same-tool fan-out streaks', () => { + it('accumulates across rounds that repeat a distinct-argument fan-out', async () => { + const actions = await playRounds([ + [ + 'a', + 'b', + 'c', + ], + [ + 'a', + 'b', + 'c', + ], + [ + 'a', + 'b', + 'c', + ], + ]); + + /* Round 0 is the baseline; each later round scores as its set completes. */ + expect(actions[0]).toEqual([ + 'none', + 'none', + 'none', + ]); + expect(actions[1]).toEqual([ + 'none', + 'none', + 'observe', + ]); + expect(actions[2]).toEqual([ + 'none', + 'none', + 'block', + ]); + }); + + it('is order-insensitive within the round', async () => { + const actions = await playRounds([ + [ + 'a', + 'b', + 'c', + ], + [ + 'c', + 'a', + 'b', + ], + [ + 'b', + 'c', + 'a', + ], + ]); + + expect(actions[1]?.at(-1)).toBe('observe'); + expect(actions[2]?.at(-1)).toBe('block'); + }); + + it('resets when the fan-out membership changes', async () => { + const actions = await playRounds([ + [ + 'a', + 'b', + ], + [ + 'a', + 'b', + ], + [ + 'a', + 'z', + ], + [ + 'a', + 'z', + ], + ]); + + expect(actions[1]?.at(-1)).toBe('observe'); + /* Different set: this is progress, not repetition. */ + expect(actions[2]).toEqual([ + 'none', + 'none', + ]); + expect(actions[3]?.at(-1)).toBe('observe'); + }); + + it('does not treat a partial repeat as a repeat', async () => { + const actions = await playRounds([ + [ + 'a', + 'b', + 'c', + ], + [ + 'a', + 'b', + ], + ]); + + /* A strict subset is a different round, so no verdict fires. */ + expect(actions[1]).toEqual([ + 'none', + 'none', + ]); + }); + + it('leaves single-call rounds behaving exactly as before', async () => { + const actions = await playRounds([ + [ + 'a', + ], + [ + 'a', + ], + [ + 'a', + ], + [ + 'a', + ], + ]); + + expect(actions.flat()).toEqual([ + 'none', + 'observe', + 'block', + 'block', + ]); + }); + + it('still counts identical duplicates within one round only once', async () => { + const detector = monitor(); + const first = await detector.recordToolCall( + 'read', + { + path: 'a', + }, + 0, + ); + const second = await detector.recordToolCall( + 'read', + { + path: 'a', + }, + 0, + ); + + expect(first.duplicateInRound).toBe(false); + expect(second.duplicateInRound).toBe(true); + expect(second.streak).toBe(first.streak); + }); + + it('keeps a resumed single-call streak incrementing after restore', async () => { + const detector = monitor(); + await detector.recordToolCall( + 'read', + { + path: 'a', + }, + 0, + ); + await detector.recordToolCall( + 'read', + { + path: 'a', + }, + 1, + ); + + const resumed = new DoomLoopMonitor(resolveDoomLoopOption(true), detector.getState()); + const record = await resumed.recordToolCall( + 'read', + { + path: 'a', + }, + 0, + ); + + expect(record.streak).toBe(3); + }); +}); From eb3b51d7328d1e4b6327b33336df6ea07d52d999 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:42:42 -0500 Subject: [PATCH 02/32] fix(agent): score a doom-loop round's declared set, not a growing prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fan-out fix compared a round's fingerprint set while that set was still filling, so a round that is a strict superset of the previous one transiently equaled it. With r0=[a,b], r1=[a,b], r2=[a,b,c], the `b` of r2 saw [a,b], matched the prior round, and scored streak 3 -> block: a call in a round that had added new work was refused. It was also emission-order dependent — r2 as [c,a,b] never formed the matching prefix and fired nothing — which contradicted the order-insensitivity the previous commit claimed. Expanding fan-outs ([a], [a,b], [a,b,c], …) accumulated streaks the same way. The engine now declares a round's complete set before any of its calls is scored (`declareRound`, called from all three execution-batch boundaries), so the comparison is always whole-round against whole-round. Ordering within a round no longer matters in fact rather than only in intent, and neither a subset nor a superset is a repeat — a round that adds work is progress. Every call in a repeating round now reports that round's streak rather than only the call completing the match. At the block rung a repeating fan-out therefore stops spending, instead of executing N-1 of its calls every round. An undeclared round falls back to per-call sets: exact for single-call rounds, and for a fan-out no stronger than the pre-fix last-call behavior — a test pins that it can only reach the hook-only `observe` rung, never refuse a call. Also: drop `priorStreak` from restore(), which was unreachable (it is only read under `isSameRound`, and restore() intentionally leaves `round` undefined); add the resumed-fan-out test Perry asked for; and correct the docstrings, file-header port spec, and verdict message that still described last-call keying. Unchanged: single-call round timing, in-round duplicate collapsing, resumed single-call streaks, persisted state shape, and verdict payloads. Verified: 758 pass (5 new), typecheck and biome clean. The three new superset/order/expanding tests fail against the previous commit with the buggy values ('block' on the progressing call, and order-dependent outcomes). --- .changeset/doom-loop-fanout.md | 12 +- packages/agent/src/lib/doom-loop.ts | 220 +++++++++----- packages/agent/src/lib/model-result.ts | 68 ++++- .../agent/tests/unit/doom-loop-fanout.test.ts | 280 +++++++++++++++++- 4 files changed, 481 insertions(+), 99 deletions(-) diff --git a/.changeset/doom-loop-fanout.md b/.changeset/doom-loop-fanout.md index d3be3193..f37b246e 100644 --- a/.changeset/doom-loop-fanout.md +++ b/.changeset/doom-loop-fanout.md @@ -11,6 +11,12 @@ produced zero detections, while single-call rounds tripped at round 2 — and distinct-argument fan-out is the dominant shape in parallel-tool-calling agents. A round's identity for one tool is now the *set* of fingerprints it was called -with, compared across rounds. Order within the round does not matter, a changed -member resets the streak, and a strict subset is not a repeat. Single-call -rounds, in-round duplicate collapsing, and resumed streaks are unchanged. +with, compared across rounds. The engine declares a round's complete set before +any of its calls is scored, so ordering within the round does not matter, a +changed member resets the streak, and neither a strict subset nor a superset is +a repeat — a round that adds new work is progress, not repetition. Every call in +a repeating round reports that round's streak, so at the block rung a repeating +fan-out stops spending rather than only its last call being refused. + +Single-call rounds, in-round duplicate collapsing, resumed streaks, persisted +state shape, and verdict payloads are unchanged. diff --git a/packages/agent/src/lib/doom-loop.ts b/packages/agent/src/lib/doom-loop.ts index aa9256e2..8f7fd86f 100644 --- a/packages/agent/src/lib/doom-loop.ts +++ b/packages/agent/src/lib/doom-loop.ts @@ -30,7 +30,14 @@ * - **Round-scoped streaks.** A streak measures the model *re-issuing a call * after seeing its result*, which requires a model round trip. N identical * calls fanned out in ONE round count once; the duplicates share that - * round's decision. + * round's decision. A round's identity for one tool is the *set* of + * fingerprints it was called with, so a fan-out of distinct arguments + * (`read(a), read(b), read(c)`) reissued verbatim accumulates, and every + * call in the round reports the round's streak. Ports must declare a + * round's complete set before scoring any of its calls (see + * `declareRound`): scoring a set as it accumulates makes a superset round + * transiently match the previous one, which both fires on real progress + * and makes the outcome depend on emission order. * - **Graduated response.** Detection feeds a configurable action ladder * (observe → steer → block → stop) rather than killing the run outright. * - **Never the cause of failure.** Detection may only affect a run through @@ -73,10 +80,12 @@ export type DoomLoopDetectorKind = * A doom-loop detection event. * * `streak` is the repetition count that crossed a ladder threshold: for - * fingerprint detectors the consecutive identical-fingerprint round count - * for that tool; for `text-repetition` the number of consecutive repeats of - * a token block within one response; for `text-streak` the number of - * consecutive steps with identical assistant text. + * fingerprint detectors the number of consecutive rounds in which that tool + * was called with the same fingerprint *set* (so a repeated fan-out counts, + * and every call in the round reports the round's count); for + * `text-repetition` the number of consecutive repeats of a token block within + * one response; for `text-streak` the number of consecutive steps with + * identical assistant text. */ export interface DoomLoopVerdict { detector: DoomLoopDetectorKind; @@ -788,22 +797,28 @@ interface StreakEntry extends DoomLoopStreak { */ round?: number; /** - * Fingerprints this tool has been called with during {@link round}, in - * sorted order. A round's identity is the whole set, not its last call, so - * a fan-out of *distinct* arguments (`read(a), read(b), read(c)`) reissued + * The fingerprint set this tool was called with during {@link round}, in + * sorted order. A round's identity is the whole set, not its last call, so a + * fan-out of *distinct* arguments (`read(a), read(b), read(c)`) reissued * verbatim is a repeat. Comparing only the last call let each round's first * call reset the streak, so a repeating fan-out never accumulated evidence. - * Never serialized: it is meaningful only within one round. + * + * This is the round's COMPLETE set, known before its first call is scored — + * see {@link DoomLoopMonitor.declareRound}. Accumulating it incrementally + * instead made a growing round transiently equal the previous round's set, + * so a superset round (`[a,b]`, `[a,b]`, `[a,b,c]`) scored a verdict on the + * call that completed the prefix and blocked real progress — and did so + * order-dependently, since a different emission order never formed the + * matching prefix. Never serialized: meaningful only within one round. */ roundFingerprints?: readonly string[]; /** - * The previous round's completed fingerprint set — what this round is being - * compared against — and the streak that round earned. Held so a fan-out can - * be re-evaluated as each of its calls arrives without losing the baseline. - * Never serialized: meaningful only within one run. + * Fingerprints of {@link round} already recorded, used to collapse in-round + * duplicates (one decision per `(tool, fingerprint)` per round). Distinct + * from {@link roundFingerprints}, which is the round's declared whole. + * Never serialized: meaningful only within one round. */ - priorRoundFingerprints?: readonly string[]; - priorStreak?: number; + seenThisRound?: readonly string[]; } /** @@ -827,6 +842,14 @@ export class DoomLoopMonitor { // Escalation recoveries consumed by this conversation. Persisted (see // getState/restore) so a resumed run cannot reset its budget. private escalationsUsed = 0; + // The current round's declared per-tool fingerprint sets — see + // `declareRound`. Run-local and never serialized. + private declaredRound: + | { + round: number; + fingerprints: Map; + } + | undefined; constructor(config: ResolvedDoomLoopConfig, initialState?: unknown) { this.config = config; @@ -835,6 +858,52 @@ export class DoomLoopMonitor { } } + /** + * Declare the complete set of calls a round will make, before any of them + * is recorded. Each entry is one call's `(toolName, keyMaterial)`; the + * monitor groups them per tool into that tool's round set. + * + * A round's identity is the *set* of fingerprints a tool was called with, + * so that set has to be known up front. Accumulating it call by call meant + * a round that is a strict superset of the previous one transiently equaled + * it while filling — `[a,b]`, `[a,b]`, `[a,b,c]` scored a verdict on the `b` + * of the third round and refused a call that represented real progress. It + * was also emission-order dependent: `[c,a,b]` never formed the matching + * prefix and scored nothing. Declaring the whole set removes both. + * + * Idempotent per round, and safe to skip: an undeclared round falls back to + * per-call sets (exact for single-call rounds, pre-fix last-call behavior + * for a fan-out). Unhashable key material is skipped here — the caller's + * own fallback chain handles it at record time. Never serialized. + */ + async declareRound( + round: number, + calls: readonly { + toolName: string; + keyMaterial: unknown; + }[], + ): Promise { + const fingerprints = new Map(); + for (const call of calls) { + let fingerprint: string; + try { + fingerprint = await fingerprintToolCall(call.toolName, call.keyMaterial); + } catch { + // Unhashable: leave it out of the declared set. recordToolCall's + // fallback chain decides this call's identity on its own. + continue; + } + fingerprints.set( + call.toolName, + mergeFingerprint(fingerprints.get(call.toolName) ?? [], fingerprint), + ); + } + this.declaredRound = { + round, + fingerprints, + }; + } + /** * True when the escalate rung can still fire: a mechanism is configured * and the budget is not exhausted. @@ -906,11 +975,14 @@ export class DoomLoopMonitor { tools.set(name, { fingerprint: entry.fingerprint, streak: entry.streak, - // round intentionally absent: first resumed record increments. + // `round` intentionally absent: the first resumed record is + // always a new round, so it increments whatever the numbering. + // Only the last fingerprint survives serialization, so a resumed + // FAN-OUT streak restarts at 1 while a single-call streak + // continues — the round set is run-local by design. roundFingerprints: [ entry.fingerprint, ], - priorStreak: entry.streak, }); } } @@ -943,7 +1015,14 @@ export class DoomLoopMonitor { * re-issuing a call *after seeing its result*, which requires a round * trip; N parallel identical calls in one turn are one piece of * evidence, not N. - * - A different fingerprint for the same tool resets the streak to 1. + * - A round's identity is the *set* of fingerprints the tool was called + * with, not its last call, so a fan-out of distinct arguments reissued + * verbatim accumulates. A round whose set differs from the previous + * round's — in either direction, including a superset — resets to 1. + * Every call in a round reports that round's streak, so the ladder + * applies to the round as a unit. See {@link declareRound}: the set must + * be declared before the round's first call for this to hold for + * multi-call rounds. * * Blocked calls are recorded like any other — a model re-issuing a * blocked call in a later round is stronger loop evidence, not progress. @@ -969,75 +1048,62 @@ export class DoomLoopMonitor { /* * A round's identity for one tool is the *set* of fingerprints it was - * called with, so the streak compares whole rounds. Within the current - * round we accumulate; across rounds we compare the completed set. The - * `fingerprint` field keeps holding the latest call so persisted state and - * verdict payloads are unchanged. + * called with, so the streak compares whole rounds rather than last calls. + * + * The set must be the round's COMPLETE membership before any of its calls + * is scored. `declareRound` supplies it; absent a declaration (direct + * callers, tests, a path that forgot to declare) we fall back to this + * call alone, which is exact for single-call rounds and degrades to the + * pre-fix last-call behavior for undeclared fan-outs — never to a false + * positive. Scoring an incrementally-growing set instead let a superset + * round transiently match the previous one and block real progress. + * + * The `fingerprint` field keeps holding the latest call so persisted state + * and verdict payloads are unchanged. */ - let streak: number; - let duplicateInRound = false; - let roundFingerprints: readonly string[]; + const declared = + this.declaredRound?.round === round + ? this.declaredRound.fingerprints.get(toolName) + : undefined; + const roundFingerprints = + declared ?? + (isSameRound + ? mergeFingerprint(previous?.roundFingerprints ?? [], fingerprint) + : [ + fingerprint, + ]); + + const seen = isSameRound ? (previous?.seenThisRound ?? []) : []; + const duplicateInRound = seen.includes(fingerprint); + /* + * Compare this round's whole set against the previous round's whole set. + * Every call in the round therefore scores the SAME streak — a repeating + * fan-out is one piece of evidence per round, and the ladder applies to + * the round as a unit instead of only to whichever call happened to + * complete the match. + */ + let streak: number; if (isSameRound && previous) { - /* - * Still inside the round being compared. Extend its set and re-evaluate: - * a fan-out only matches the previous round once every member has been - * seen, so the streak lands on the call that completes the match. The - * round's earlier calls already reported the pre-match streak, which is - * correct — a partial fan-out is not yet a repeat. - */ - const seen = previous.roundFingerprints ?? [ - previous.fingerprint, - ]; - roundFingerprints = mergeFingerprint(seen, fingerprint); - duplicateInRound = seen.includes(fingerprint); - streak = - previous.priorRoundFingerprints !== undefined && - setsMatch(previous.priorRoundFingerprints, roundFingerprints) - ? (previous.priorStreak ?? 0) + 1 - : 1; + streak = previous.streak; } else { - /* - * A new round opens with one call. It repeats the previous round only if - * that round was also a single call with this fingerprint; a multi-call - * previous round cannot be matched yet and resolves as the fan-out fills - * in above. - */ - roundFingerprints = [ - fingerprint, - ]; - streak = - previous !== undefined && - setsMatch( - previous.roundFingerprints ?? [ - previous.fingerprint, - ], - roundFingerprints, - ) - ? previous.streak + 1 - : 1; - } - - /* The completed set this round is measured against, and the streak it earned. */ - const priorRoundFingerprints = isSameRound - ? previous?.priorRoundFingerprints - : (previous?.roundFingerprints ?? + const priorSet = + previous?.roundFingerprints ?? (previous ? [ previous.fingerprint, ] - : undefined)); + : undefined); + streak = + priorSet !== undefined && setsMatch(priorSet, roundFingerprints) ? previous!.streak + 1 : 1; + } + this.tools.set(toolName, { fingerprint, streak, round, roundFingerprints, - ...(priorRoundFingerprints !== undefined - ? { - priorRoundFingerprints, - } - : {}), - priorStreak: isSameRound ? (previous?.priorStreak ?? 0) : (previous?.streak ?? 0), + seenThisRound: mergeFingerprint(seen, fingerprint), }); const allowBlock = options?.allowBlock ?? true; @@ -1064,7 +1130,11 @@ export class DoomLoopMonitor { toolName, message: `Doom loop suspected: tool "${toolName}" was invoked in ${streak} consecutive rounds ` + - `with identical arguments (fingerprint ${fingerprint.slice(0, 16)}…). Repeating the call ` + + `with the same set of arguments${ + roundFingerprints.length > 1 + ? ` (${roundFingerprints.length} parallel calls per round)` + : '' + } (fingerprint ${fingerprint.slice(0, 16)}…). Repeating the call ` + 'will not change the result. Take a different approach, or explain why repetition is required.', }, }; diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index acab9dce..9973bad7 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -1285,17 +1285,59 @@ export class ModelResult< } /** - * Advance the doom-loop round counter. Called at every execution-batch - * boundary (main tool round, auto-approve batch while pausing, approved- - * on-resume batch). Identical calls WITHIN one round are duplicates — - * one piece of loop evidence, one shared decision. + * Advance the doom-loop round counter and declare the round's calls. Called + * at every execution-batch boundary (main tool round, auto-approve batch + * while pausing, approved-on-resume batch). Identical calls WITHIN one round + * are duplicates — one piece of loop evidence, one shared decision. + * + * `batch` is every call the round will make. A round's identity for one tool + * is the *set* of fingerprints it was called with, and that set has to be + * complete before any of the round's calls is scored: accumulating it call + * by call made a round that is a superset of the previous one transiently + * match it, blocking calls that represented real progress, with the outcome + * depending on emission order. Declaration is best-effort — a call whose key + * material is exempt or unhashable is simply left out, and the per-call + * fallback chain in `enqueueDoomLoopEvaluation` still governs identity at + * record time. */ - private beginDoomLoopRound(): void { - if (!this.doomLoopMonitor) { + private async beginDoomLoopRound(batch: readonly ParsedToolCall[] = []): Promise { + const monitor = this.doomLoopMonitor; + if (!monitor) { return; } this.doomLoopRound++; this.doomLoopRoundDecisions.clear(); + + const declared: { + toolName: string; + keyMaterial: unknown; + }[] = []; + for (const toolCall of batch) { + const rawArgs: unknown = toolCall.arguments; + if (typeof rawArgs === 'string') { + // Malformed call: its identity is the raw string (see runToolWithHooks). + declared.push({ + toolName: String(toolCall.name), + keyMaterial: rawArgs, + }); + continue; + } + const tool = this.options.tools?.find( + (t) => isClientTool(t) && t.function.name === toolCall.name, + ); + const resolution = resolveLoopKeyMaterial( + tool !== undefined && isClientTool(tool) ? tool.function.loopKey : undefined, + (toolCall.arguments ?? {}) as Record, + ); + if (resolution.kind === 'exempt') { + continue; + } + declared.push({ + toolName: String(toolCall.name), + keyMaterial: resolution.keyMaterial, + }); + } + await monitor.declareRound(this.doomLoopRound, declared); } /** @@ -2132,7 +2174,7 @@ export class ModelResult< ): Promise[]> { // Auto-approved batch = one doom-loop round: identical parallel calls // count once (see beginDoomLoopRound). - this.beginDoomLoopRound(); + await this.beginDoomLoopRound(toolCalls as ParsedToolCall[]); const toolCallPromises = toolCalls.map(async (tc) => { const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === tc.name); if (!tool || !isAutoResolvableTool(tool)) { @@ -2572,7 +2614,7 @@ export class ModelResult< }> { // One executed batch = one doom-loop round: identical parallel calls in // this batch count as ONE piece of loop evidence and share a decision. - this.beginDoomLoopRound(); + await this.beginDoomLoopRound(toolCalls); const toolCallPromises = toolCalls.map((toolCall) => this.executeSingleToolCall(toolCall, turnContext), ); @@ -3495,7 +3537,15 @@ export class ModelResult< // The approved batch is one doom-loop round: N approved duplicates of // the same call count once (the sequential loop below still evaluates // in order; restored streaks from the persisted state carry forward). - this.beginDoomLoopRound(); + // Declared from the approved calls only — a pending call the user did not + // approve is not part of this round. + await this.beginDoomLoopRound( + [ + ...this.approvedToolCalls, + ] + .map((callId) => pendingCalls.find((tc) => tc.id === callId)) + .filter((tc): tc is ParsedToolCall => tc !== undefined), + ); // Process approvals - execute the approved tools. Route through // runToolWithHooks so PreToolUse/PostToolUse fire even on this path. diff --git a/packages/agent/tests/unit/doom-loop-fanout.test.ts b/packages/agent/tests/unit/doom-loop-fanout.test.ts index cca1daed..c79be0c3 100644 --- a/packages/agent/tests/unit/doom-loop-fanout.test.ts +++ b/packages/agent/tests/unit/doom-loop-fanout.test.ts @@ -9,9 +9,16 @@ * tripped at round 2. * * A round's identity for one tool is therefore the *set* of fingerprints it - * was called with. The set is only complete once every call has arrived, so a - * fan-out scores on the call that completes the match — the round's earlier - * calls legitimately report the pre-match streak. + * was called with. The engine declares that set before scoring any of the + * round's calls, so every call in a round reports the round's streak and the + * comparison is between whole rounds. + * + * Scoring the set as it accumulated instead — the first attempt at this fix — + * made a round that is a strict *superset* of the previous one transiently + * equal it while filling, so `[a,b]`, `[a,b]`, `[a,b,c]` blocked the `b` call + * of a round that had added new work, and did so only for that emission order. + * The superset, order-permutation, and expanding-fan-out cases below guard + * that; the subset case alone did not catch it. */ import { describe, expect, it } from 'vitest'; @@ -21,10 +28,24 @@ type RecordedAction = string; const monitor = (): DoomLoopMonitor => new DoomLoopMonitor(resolveDoomLoopOption(true)); +/** + * Plays each fan-out as one round, declaring the round's calls first — the + * engine does the same at every execution-batch boundary, so the round's + * fingerprint set is complete before any of its calls is scored. + */ async function playRounds(fanouts: readonly (readonly string[])[]): Promise { const detector = monitor(); const actions: RecordedAction[][] = []; for (const [round, paths] of fanouts.entries()) { + await detector.declareRound( + round, + paths.map((path) => ({ + toolName: 'read', + keyMaterial: { + path, + }, + })), + ); const roundActions: RecordedAction[] = []; for (const path of paths) { const record = await detector.recordToolCall( @@ -61,20 +82,26 @@ describe('same-tool fan-out streaks', () => { ], ]); - /* Round 0 is the baseline; each later round scores as its set completes. */ + /* + * Round 0 is the baseline. Every call in a repeating round reports that + * round's streak — the round is the unit of evidence, so the ladder + * applies to the whole fan-out rather than only to whichever call + * happened to complete the match. At the block rung that means the + * repeating fan-out stops spending, not just its last call. + */ expect(actions[0]).toEqual([ 'none', 'none', 'none', ]); expect(actions[1]).toEqual([ - 'none', - 'none', + 'observe', + 'observe', 'observe', ]); expect(actions[2]).toEqual([ - 'none', - 'none', + 'block', + 'block', 'block', ]); }); @@ -98,8 +125,17 @@ describe('same-tool fan-out streaks', () => { ], ]); - expect(actions[1]?.at(-1)).toBe('observe'); - expect(actions[2]?.at(-1)).toBe('block'); + /* Whole round, not just its last call: the set is what matched. */ + expect(actions[1]).toEqual([ + 'observe', + 'observe', + 'observe', + ]); + expect(actions[2]).toEqual([ + 'block', + 'block', + 'block', + ]); }); it('resets when the fan-out membership changes', async () => { @@ -122,13 +158,19 @@ describe('same-tool fan-out streaks', () => { ], ]); - expect(actions[1]?.at(-1)).toBe('observe'); + expect(actions[1]).toEqual([ + 'observe', + 'observe', + ]); /* Different set: this is progress, not repetition. */ expect(actions[2]).toEqual([ 'none', 'none', ]); - expect(actions[3]?.at(-1)).toBe('observe'); + expect(actions[3]).toEqual([ + 'observe', + 'observe', + ]); }); it('does not treat a partial repeat as a repeat', async () => { @@ -151,6 +193,116 @@ describe('same-tool fan-out streaks', () => { ]); }); + it('does not treat a superset round as a repeat', async () => { + const actions = await playRounds([ + [ + 'a', + 'b', + ], + [ + 'a', + 'b', + ], + [ + 'a', + 'b', + 'c', + ], + ]); + + /* + * The third round added new work, so it is progress and nothing fires. + * Scoring the set as it accumulated used to make this round transiently + * equal `[a,b]` on its `b` call and score streak 3 -> block, refusing a + * legitimate call. Guards the direction the subset test above does not. + */ + expect(actions[1]).toEqual([ + 'observe', + 'observe', + ]); + expect(actions[2]).toEqual([ + 'none', + 'none', + 'none', + ]); + }); + + it('scores a superset round the same whatever order it is emitted in', async () => { + const inOrder = await playRounds([ + [ + 'a', + 'b', + ], + [ + 'a', + 'b', + ], + [ + 'a', + 'b', + 'c', + ], + ]); + const permuted = await playRounds([ + [ + 'a', + 'b', + ], + [ + 'a', + 'b', + ], + [ + 'c', + 'a', + 'b', + ], + ]); + + /* + * Emission order must not decide whether a call is refused. While the set + * accumulated, `[a,b,c]` blocked its `b` call and `[c,a,b]` fired nothing + * — same calls, same history, different outcome. + */ + expect(permuted[2]).toEqual(inOrder[2]); + expect(inOrder[2]).toEqual([ + 'none', + 'none', + 'none', + ]); + }); + + it('does not accumulate a streak while a fan-out keeps expanding', async () => { + const actions = await playRounds([ + [ + 'a', + ], + [ + 'a', + 'b', + ], + [ + 'a', + 'b', + 'c', + ], + [ + 'a', + 'b', + 'c', + 'd', + ], + ]); + + /* Every round adds work, so no round repeats its predecessor. */ + expect( + actions + .slice(1) + .flat() + .every((action) => action === 'none'), + ).toBe(true); + }); + it('leaves single-call rounds behaving exactly as before', async () => { const actions = await playRounds([ [ @@ -225,4 +377,108 @@ describe('same-tool fan-out streaks', () => { expect(record.streak).toBe(3); }); + + it('degrades safely when a round is never declared', async () => { + /* + * The engine declares every round (see beginDoomLoopRound), so this is + * the direct-caller / port path. Without a declaration a multi-call round + * falls back to per-call sets, i.e. the pre-fix last-call behavior: a + * growing fan-out can still score the weakest rung. Pinned to bound what + * the fallback may do — `observe` is hook-only, so an undeclared round + * never refuses a call the declared path would have allowed. + */ + const detector = monitor(); + const actions: RecordedAction[][] = []; + for (const [round, paths] of [ + [ + 'a', + ], + [ + 'a', + 'b', + ], + [ + 'a', + 'b', + 'c', + ], + ].entries()) { + const roundActions: RecordedAction[] = []; + for (const path of paths) { + const record = await detector.recordToolCall( + 'read', + { + path, + }, + round, + ); + roundActions.push(record.verdict?.action ?? 'none'); + } + actions.push(roundActions); + } + + expect(actions.flat().every((action) => action === 'none' || action === 'observe')).toBe(true); + }); + + it('restarts a resumed FAN-OUT streak at 1, unlike a single-call streak', async () => { + const detector = monitor(); + for (const round of [ + 0, + 1, + ]) { + const paths = [ + 'a', + 'b', + ]; + await detector.declareRound( + round, + paths.map((path) => ({ + toolName: 'read', + keyMaterial: { + path, + }, + })), + ); + for (const path of paths) { + await detector.recordToolCall( + 'read', + { + path, + }, + round, + ); + } + } + + /* + * Only the last fingerprint survives serialization, so the round SET is + * lost across a resume and the fan-out starts over — a doom loop spanning + * a serialize/resume boundary gets a fresh grace window before it trips + * again. Pinned deliberately: the round set is run-local by design + * (persisting it would change the state shape), and the single-call case + * above shows the contrast. + */ + const resumed = new DoomLoopMonitor(resolveDoomLoopOption(true), detector.getState()); + await resumed.declareRound( + 0, + [ + 'a', + 'b', + ].map((path) => ({ + toolName: 'read', + keyMaterial: { + path, + }, + })), + ); + const record = await resumed.recordToolCall( + 'read', + { + path: 'a', + }, + 0, + ); + + expect(record.streak).toBe(1); + }); }); From 81c2572af9a0d2fa0f76e5f33f8171ad52b800c1 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:57:05 -0500 Subject: [PATCH 03/32] fix(agent): don't share a round streak across an undeclared round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions from the previous commit, both found by Devin's re-review. A brand-new call could be reported as a repeat. Round-scoped scoring had every call in a round reuse the round's streak, which is only sound when the round's membership was declared up front. Server-tool records go through checkDoomLoopForResponse undeclared, so with one web_search in round R-1 (query x) and two in round R (x, then a new y), y inherited x's streak of 2 and emitted a verdict quoting y's own fingerprint and claiming y had been issued in 2 consecutive rounds. Undeclared rounds are now scored per call against the previous round — the pre-fan-out semantics — so a fan-out there goes undetected rather than mis-scored. That required restoring priorRoundFingerprints/priorStreak (dropped last commit as dead) to hold the previous round's baseline for the length of the current one; they are live on this path and still never serialized. The steer rung could inject N duplicate corrections for one round. queueDoomLoopSteer dedupes by exact message text, and every call of a repeating round now emits a verdict, so interpolating the individual call's fingerprint made three strings out of one round of evidence and queued all three. A multi-call round now quotes the round's identity (identical for all its calls) and names the call count; single-call messages are unchanged. Also corrects the fallback comment, which claimed the undeclared path degrades "never to a false positive" — the inheritance bug above was exactly that. Not changed: Devin also notes that a call repeating inside a round whose other members vary ([a,b], [a,c], [a,d]) no longer accumulates, since round identity requires the whole set to match. Confirmed, but it is the previous commit's deliberate "a changed member is progress" trade-off rather than a regression introduced here, and restoring per-call streaks alongside round streaks is a design change. Raised on the thread for the author instead. Verified: 759 pass (2 new regressions, both fail against eb3b51d), typecheck and biome clean. --- packages/agent/src/lib/doom-loop.ts | 127 +++++++++++++----- .../agent/tests/unit/doom-loop-fanout.test.ts | 105 +++++++++++---- 2 files changed, 170 insertions(+), 62 deletions(-) diff --git a/packages/agent/src/lib/doom-loop.ts b/packages/agent/src/lib/doom-loop.ts index 8f7fd86f..d78c88a3 100644 --- a/packages/agent/src/lib/doom-loop.ts +++ b/packages/agent/src/lib/doom-loop.ts @@ -819,6 +819,16 @@ interface StreakEntry extends DoomLoopStreak { * Never serialized: meaningful only within one round. */ seenThisRound?: readonly string[]; + /** + * The set the previous round was called with, and the streak it earned. + * Carried unchanged for the length of {@link round} so that every call in + * an UNDECLARED round is measured against the round before it rather than + * against a set this round is still assembling. Unused once a round is + * declared (those calls share the round's streak directly). + * Never serialized: meaningful only within one run. + */ + priorRoundFingerprints?: readonly string[]; + priorStreak?: number; } /** @@ -871,10 +881,11 @@ export class DoomLoopMonitor { * was also emission-order dependent: `[c,a,b]` never formed the matching * prefix and scored nothing. Declaring the whole set removes both. * - * Idempotent per round, and safe to skip: an undeclared round falls back to - * per-call sets (exact for single-call rounds, pre-fix last-call behavior - * for a fan-out). Unhashable key material is skipped here — the caller's - * own fallback chain handles it at record time. Never serialized. + * Idempotent per round, and safe to skip: an undeclared round is scored + * per call against the previous round (the pre-fan-out semantics), so a + * fan-out simply goes undetected there rather than mis-scored — server-tool + * records take that path. Unhashable key material is skipped here; the + * caller's own fallback chain handles it at record time. Never serialized. */ async declareRound( round: number, @@ -1051,12 +1062,16 @@ export class DoomLoopMonitor { * called with, so the streak compares whole rounds rather than last calls. * * The set must be the round's COMPLETE membership before any of its calls - * is scored. `declareRound` supplies it; absent a declaration (direct - * callers, tests, a path that forgot to declare) we fall back to this - * call alone, which is exact for single-call rounds and degrades to the - * pre-fix last-call behavior for undeclared fan-outs — never to a false - * positive. Scoring an incrementally-growing set instead let a superset - * round transiently match the previous one and block real progress. + * is scored, which only `declareRound` can supply. Without a declaration + * (direct callers, ports, server-tool records) we cannot know the round's + * membership, so we do NOT pretend to: each call is scored on its own + * identity against the previous round, exactly as before the fan-out + * change. Sharing a round streak across an undeclared round would let a + * brand-new call inherit an earlier call's count and report a repeat that + * never happened. + * + * Scoring an incrementally-growing set was the other failure mode: it let + * a superset round transiently match the previous one and block progress. * * The `fingerprint` field keeps holding the latest call so persisted state * and verdict payloads are unchanged. @@ -1065,45 +1080,64 @@ export class DoomLoopMonitor { this.declaredRound?.round === round ? this.declaredRound.fingerprints.get(toolName) : undefined; - const roundFingerprints = - declared ?? - (isSameRound - ? mergeFingerprint(previous?.roundFingerprints ?? [], fingerprint) - : [ - fingerprint, - ]); + const roundFingerprints = declared ?? [ + fingerprint, + ]; const seen = isSameRound ? (previous?.seenThisRound ?? []) : []; const duplicateInRound = seen.includes(fingerprint); /* * Compare this round's whole set against the previous round's whole set. - * Every call in the round therefore scores the SAME streak — a repeating - * fan-out is one piece of evidence per round, and the ladder applies to - * the round as a unit instead of only to whichever call happened to - * complete the match. + * When the round was declared, every call in it scores the SAME streak — + * a repeating fan-out is one piece of evidence per round, and the ladder + * applies to the round as a unit rather than to whichever call happened to + * complete the match. Undeclared rounds have no shared set to inherit, so + * each call is compared individually (per-call semantics, as before). */ let streak: number; - if (isSameRound && previous) { + if (isSameRound && previous && declared !== undefined) { streak = previous.streak; } else { - const priorSet = - previous?.roundFingerprints ?? + const priorSet = isSameRound + ? previous?.priorRoundFingerprints + : (previous?.roundFingerprints ?? + (previous + ? [ + previous.fingerprint, + ] + : undefined)); + streak = + priorSet !== undefined && setsMatch(priorSet, roundFingerprints) + ? (isSameRound ? (previous?.priorStreak ?? 0) : previous!.streak) + 1 + : 1; + } + + /* + * The set the NEXT call of this round must compare against. Within one + * round every call is measured against the round before it, so this is + * carried unchanged across the round rather than overwritten per call. + */ + const priorRoundFingerprints = isSameRound + ? previous?.priorRoundFingerprints + : (previous?.roundFingerprints ?? (previous ? [ previous.fingerprint, ] - : undefined); - streak = - priorSet !== undefined && setsMatch(priorSet, roundFingerprints) ? previous!.streak + 1 : 1; - } - + : undefined)); this.tools.set(toolName, { fingerprint, streak, round, roundFingerprints, seenThisRound: mergeFingerprint(seen, fingerprint), + ...(priorRoundFingerprints !== undefined + ? { + priorRoundFingerprints, + } + : {}), + priorStreak: isSameRound ? (previous?.priorStreak ?? 0) : (previous?.streak ?? 0), }); const allowBlock = options?.allowBlock ?? true; @@ -1128,14 +1162,23 @@ export class DoomLoopMonitor { streak, fingerprint, toolName, + /* + * Identical for every call of a repeating round, deliberately: the + * steer rung dedupes queued guidance by exact message text, so quoting + * the individual call's fingerprint here would queue N near-identical + * corrections for one round of evidence. A multi-call round therefore + * quotes the ROUND's identity (same for all its calls) and names the + * call count instead of one member's hash. + */ message: - `Doom loop suspected: tool "${toolName}" was invoked in ${streak} consecutive rounds ` + - `with the same set of arguments${ - roundFingerprints.length > 1 - ? ` (${roundFingerprints.length} parallel calls per round)` - : '' - } (fingerprint ${fingerprint.slice(0, 16)}…). Repeating the call ` + - 'will not change the result. Take a different approach, or explain why repetition is required.', + roundFingerprints.length > 1 + ? `Doom loop suspected: tool "${toolName}" was invoked in ${streak} consecutive rounds ` + + `with the same set of ${roundFingerprints.length} parallel calls ` + + `(round identity ${summarizeRound(roundFingerprints)}). Reissuing the same fan-out ` + + 'will not change the results. Take a different approach, or explain why repetition is required.' + : `Doom loop suspected: tool "${toolName}" was invoked in ${streak} consecutive rounds ` + + `with identical arguments (fingerprint ${fingerprint.slice(0, 16)}…). Repeating the call ` + + 'will not change the result. Take a different approach, or explain why repetition is required.', }, }; } @@ -1245,6 +1288,18 @@ function mergeFingerprint(existing: readonly string[], fingerprint: string): rea ].sort(); } +/** + * Short, stable identity for a round's fingerprint set: the first members' + * prefixes, so every call of one round produces the same string (see the + * verdict message — steer dedupes on exact text). + */ +function summarizeRound(fingerprints: readonly string[]): string { + const shown = fingerprints.slice(0, 3).map((value) => value.slice(0, 8)); + return fingerprints.length > 3 + ? `${shown.join('+')}+${fingerprints.length - 3} more…` + : `${shown.join('+')}…`; +} + /** Set equality over two sorted fingerprint lists. */ function setsMatch(left: readonly string[], right: readonly string[]): boolean { return left.length === right.length && left.every((value, index) => value === right[index]); diff --git a/packages/agent/tests/unit/doom-loop-fanout.test.ts b/packages/agent/tests/unit/doom-loop-fanout.test.ts index c79be0c3..315b33f8 100644 --- a/packages/agent/tests/unit/doom-loop-fanout.test.ts +++ b/packages/agent/tests/unit/doom-loop-fanout.test.ts @@ -378,32 +378,81 @@ describe('same-tool fan-out streaks', () => { expect(record.streak).toBe(3); }); - it('degrades safely when a round is never declared', async () => { + it('falls back to per-call scoring when a round is never declared', async () => { /* - * The engine declares every round (see beginDoomLoopRound), so this is - * the direct-caller / port path. Without a declaration a multi-call round - * falls back to per-call sets, i.e. the pre-fix last-call behavior: a - * growing fan-out can still score the weakest rung. Pinned to bound what - * the fallback may do — `observe` is hook-only, so an undeclared round - * never refuses a call the declared path would have allowed. + * The engine declares every executed batch, but server-tool records go + * through `checkDoomLoopForResponse` undeclared, as do direct callers and + * ports. A round's membership is unknowable there, so each call is scored + * on its own identity against the previous round — the pre-fan-out + * semantics. Sharing a round streak here instead let a brand-new call + * inherit an earlier call's count: `[a]` then `[a, b]` scored `b` as a + * 2-round repeat and emitted a verdict quoting `b`'s own fingerprint, + * for a call the model had just made for the first time. */ const detector = monitor(); - const actions: RecordedAction[][] = []; - for (const [round, paths] of [ - [ - 'a', - ], - [ - 'a', - 'b', - ], - [ - 'a', - 'b', - 'c', - ], - ].entries()) { - const roundActions: RecordedAction[] = []; + await detector.recordToolCall( + 'server:web_search', + { + q: 'x', + }, + 0, + ); + const repeated = await detector.recordToolCall( + 'server:web_search', + { + q: 'x', + }, + 1, + ); + const fresh = await detector.recordToolCall( + 'server:web_search', + { + q: 'y', + }, + 1, + ); + + /* The genuine repeat still accumulates. */ + expect(repeated.streak).toBe(2); + /* The first-ever call does not inherit it. */ + expect(fresh.streak).toBe(1); + expect(fresh.verdict).toBeUndefined(); + }); + + it('gives every call of a repeating round the SAME message so steer dedupes', async () => { + /* + * `queueDoomLoopSteer` dedupes queued guidance by exact message text. Now + * that every call of a repeating round emits a verdict, quoting the + * individual call's fingerprint would queue one near-identical correction + * per call — three user messages for one round of evidence. A multi-call + * round therefore quotes the ROUND's identity instead. + */ + const detector = new DoomLoopMonitor( + resolveDoomLoopOption({ + ladder: { + steer: 2, + }, + }), + ); + const paths = [ + 'a', + 'b', + 'c', + ]; + const messages: string[] = []; + for (const round of [ + 0, + 1, + ]) { + await detector.declareRound( + round, + paths.map((path) => ({ + toolName: 'read', + keyMaterial: { + path, + }, + })), + ); for (const path of paths) { const record = await detector.recordToolCall( 'read', @@ -412,12 +461,16 @@ describe('same-tool fan-out streaks', () => { }, round, ); - roundActions.push(record.verdict?.action ?? 'none'); + if (round === 1 && record.verdict) { + messages.push(record.verdict.message); + } } - actions.push(roundActions); } - expect(actions.flat().every((action) => action === 'none' || action === 'observe')).toBe(true); + expect(messages).toHaveLength(3); + expect(new Set(messages).size).toBe(1); + /* Names the shape rather than one member's hash. */ + expect(messages[0]).toContain('3 parallel calls'); }); it('restarts a resumed FAN-OUT streak at 1, unlike a single-call streak', async () => { From f44c69d783232ac7c7288f5cbadcc51e41fdd21d Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:12:40 -0500 Subject: [PATCH 04/32] fix(agent): scope round-streak sharing to declared members; call loopKey once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from Devin's third pass. A call outside the declared set could inherit the round's streak. The shared- streak branch keyed on the tool having a declaration, not on this call being a member of it. `declareRound` drops a call whose key material is unhashable (bigint/NaN/circular), but at record time that call still resolves an identity through the engine's fallback chain, so it took the round's accumulated count and could be blocked on its first ever appearance — the same failure mode the declared/undeclared split exists to prevent, reached by a narrower path. Sharing is now gated on set membership. A tool's loopKey ran twice per call. Declaring a round resolves each call's key material, and the per-call checkpoint resolved it again. `loopKey` is user code: one that counts or logs saw double the activity, and one returning a fresh value each time made the declared and recorded identities disagree, hiding that call from detection for the round. The declaration's resolution is now cached per call id and reused. The fallback warning still logs per call. README documented the old last-call semantics. It now describes round-set identity, reset-on-membership-change in both directions, that a repeating fan-out gets a verdict per member with one shared steer message, and that DoomLoopDetected fires once per distinct member rather than once per round. Added two limits to the "does NOT catch" list: a repeat inside a varying round, and fan-outs on paths that cannot declare a round (server tools). Verified: 761 pass (2 new regressions, both fail against 81c2572 with the buggy values — streak 3 instead of 1, and loopKey invoked 4 times instead of 2); typecheck and biome clean. --- .changeset/doom-loop-fanout.md | 11 +++- packages/agent/README.md | 35 ++++++++-- packages/agent/src/lib/doom-loop.ts | 23 +++++-- packages/agent/src/lib/model-result.ts | 29 ++++++++- .../agent/tests/unit/doom-loop-fanout.test.ts | 65 +++++++++++++++++++ .../tests/unit/doom-loop-integration.test.ts | 49 ++++++++++++++ 6 files changed, 197 insertions(+), 15 deletions(-) diff --git a/.changeset/doom-loop-fanout.md b/.changeset/doom-loop-fanout.md index f37b246e..e74a1e06 100644 --- a/.changeset/doom-loop-fanout.md +++ b/.changeset/doom-loop-fanout.md @@ -18,5 +18,14 @@ a repeat — a round that adds new work is progress, not repetition. Every call a repeating round reports that round's streak, so at the block rung a repeating fan-out stops spending rather than only its last call being refused. +Paths that cannot know a round's membership up front — server-tool records, and +direct/port callers — are scored per call against the previous round, the same +as before this change: a fan-out there goes undetected rather than mis-scored. + Single-call rounds, in-round duplicate collapsing, resumed streaks, persisted -state shape, and verdict payloads are unchanged. +state shape, verdict payloads, and the number of times a tool's `loopKey` is +invoked (once per call) are unchanged. + +Known limit, unchanged by this fix: a call that repeats inside a round whose +other members keep varying (`[a,b]`, `[a,c]`, `[a,d]`) does not accumulate, +since round identity requires the whole set to match. diff --git a/packages/agent/README.md b/packages/agent/README.md index 86cdd66d..848a2e42 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -285,11 +285,24 @@ if (verdict) console.warn(verdict.message); Detection is **deterministic** — a verdict is a pure function of the transcript, so the same sequence of calls/text always fires at the same -point. Identical calls in consecutive **rounds** build a per-tool streak: -interleaved calls to *other* tools don't reset it, and N identical calls -fanned out in parallel within ONE round count once (a streak measures the -model re-issuing a call *after seeing its result*, which requires a round -trip). The streak crosses a graduated ladder — strongest crossed rung wins: +point. Repeated **rounds** build a per-tool streak: interleaved calls to +*other* tools don't reset it, and N identical calls fanned out in parallel +within ONE round count once (a streak measures the model re-issuing a call +*after seeing its result*, which requires a round trip). + +A round's identity for one tool is the **set** of calls it made, not its last +call, so a fan-out of *distinct* arguments reissued verbatim counts: +`read(a), read(b), read(c)` every round accumulates a streak. Ordering within +the round is irrelevant, and a round whose membership *changes* — in either +direction — resets the streak to 1, since adding or dropping work is progress +rather than repetition. One consequence worth knowing: a call that repeats +inside a round whose other members keep changing (`[a,b]`, `[a,c]`, `[a,d]`) +does **not** accumulate, because the round differs each time. + +When a repeating fan-out crosses a rung, every call in the round gets the +verdict (so `block` stops the whole fan-out, not just one member) and they +share one message, so the `steer` rung injects a single correction. The streak +crosses a graduated ladder — strongest crossed rung wins: | Action | Effect | |---|---| @@ -411,6 +424,16 @@ block to observe for a known-chatty tool, or escalate straight to stop. - **Manual/client-executed calls** pause the loop for the caller and are not recorded (only executed, blocked, and parse-error calls are evidence). +- **A repeat inside a varying round.** Round identity is the whole set, so + `read(a)` reissued alongside a different second call every round + (`[a,b]`, `[a,c]`, `[a,d]`) never accumulates. This is the flip side of + "a changed member is progress"; a model retrying one failing call while + probing around it is not caught. +- **Fan-outs on paths that can't declare a round up front.** Server-tool + records (web search, advisor) are recorded as the response streams, so + their round membership isn't known in advance and they fall back to + per-call identity — a repeating server-tool *fan-out* goes unseen, though + a repeated single call still trips. ### Tool Approval @@ -519,7 +542,7 @@ const result = callModel(client, { model, input, tools, hooks }); | `SessionStart` | Once per run, before the initial request. `config` summarizes the session (`hasTools`, `hasApproval`, `hasState`) | none (void) | | `SessionEnd` | Once per run, on every exit path — completion, approval pause, interruption, error, and the no-tools streaming paths. `reason` is `'complete' \| 'error' \| 'max_turns' \| 'user' \| 'doom_loop'`. When at least one model call completed, `totalUsage` aggregates tokens/cost across all of them (`modelCalls`, `inputTokens`, `outputTokens`, `totalTokens`, `cachedTokens`, `reasoningTokens`, and `cost` when the server reported it) | none (void) | | `PostModelCall` | Once per completed model response, on **every** request the loop makes — initial, each tool-round follow-up, the empty-final retry, the `allowFinalResponse` final turn, and approval-resume requests. Payload: `responseId` (the OpenRouter generation id), `model`, `durationMs` (dispatch → fully materialized response, including stream consumption), `turnType` (`'initial' \| 'resume' \| 'tool_round' \| 'final' \| 'retry'`), `turnNumber`, and `usage` (`inputTokens`, `outputTokens`, `totalTokens`, `cachedTokens`, `reasoningTokens`, `cost?`) when the server reported usage accounting. Purely observational — the telemetry primitive for tracing/benchmark consumers: one span per model call | none (void) | -| `DoomLoopDetected` | Every time doom-loop detection crosses a ladder rung, once per `(tool, fingerprint)` per round — parallel duplicates in one round share the event (requires the `doomLoop` option). Payload: `detector` (`'tool-fingerprint' \| 'server-tool-fingerprint' \| 'text-repetition' \| 'text-streak'`), the resolved `action` (`'observe' \| 'steer' \| 'escalate' \| 'block' \| 'stop'`), the `streak`, the `fingerprint`, `toolName`/`toolInput` for tool verdicts, and the explanatory `message` | `overrideAction` replaces the engine's resolved action for this event (last handler wins); `block` on a text or server-tool verdict downgrades to `observe`; `escalate` without an `escalation` config or remaining budget downgrades to `observe` | +| `DoomLoopDetected` | Every time doom-loop detection crosses a ladder rung, once per `(tool, fingerprint)` per round — *identical* parallel duplicates in one round share the event, but a repeating fan-out of DISTINCT arguments emits one event per member, since each is its own `(tool, fingerprint)` (requires the `doomLoop` option). Payload: `detector` (`'tool-fingerprint' \| 'server-tool-fingerprint' \| 'text-repetition' \| 'text-streak'`), the resolved `action` (`'observe' \| 'steer' \| 'escalate' \| 'block' \| 'stop'`), the `streak`, the `fingerprint`, `toolName`/`toolInput` for tool verdicts, and the explanatory `message` | `overrideAction` replaces the engine's resolved action for this event (last handler wins); `block` on a text or server-tool verdict downgrades to `observe`; `escalate` without an `escalation` config or remaining budget downgrades to `observe` | Notes on lifecycle pairing: `SessionEnd` only fires when a matching `SessionStart` succeeded, and at most once per run. Pending async hook work is diff --git a/packages/agent/src/lib/doom-loop.ts b/packages/agent/src/lib/doom-loop.ts index d78c88a3..8359c6ea 100644 --- a/packages/agent/src/lib/doom-loop.ts +++ b/packages/agent/src/lib/doom-loop.ts @@ -1080,9 +1080,19 @@ export class DoomLoopMonitor { this.declaredRound?.round === round ? this.declaredRound.fingerprints.get(toolName) : undefined; - const roundFingerprints = declared ?? [ - fingerprint, - ]; + /* + * A declaration only speaks for the calls it actually contains. A call + * whose key material was unhashable at declaration time was dropped from + * the set, but still resolves an identity at record time via the caller's + * fallback chain — it must not be scored as part of the round, or it would + * inherit the round's count and could be blocked on its first appearance. + */ + const declaredMember = declared?.includes(fingerprint) === true; + const roundFingerprints = declaredMember + ? (declared as readonly string[]) + : [ + fingerprint, + ]; const seen = isSameRound ? (previous?.seenThisRound ?? []) : []; const duplicateInRound = seen.includes(fingerprint); @@ -1092,11 +1102,12 @@ export class DoomLoopMonitor { * When the round was declared, every call in it scores the SAME streak — * a repeating fan-out is one piece of evidence per round, and the ladder * applies to the round as a unit rather than to whichever call happened to - * complete the match. Undeclared rounds have no shared set to inherit, so - * each call is compared individually (per-call semantics, as before). + * complete the match. Calls outside a declaration (undeclared rounds, or a + * member dropped as unhashable) have no shared set to inherit, so each is + * compared individually (per-call semantics, as before). */ let streak: number; - if (isSameRound && previous && declared !== undefined) { + if (isSameRound && previous && declaredMember) { streak = previous.streak; } else { const priorSet = isSameRound diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index 9973bad7..6db36c0f 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -22,6 +22,7 @@ import type { DoomLoopOption, DoomLoopSerializedState, DoomLoopVerdict, + LoopKeyResolution, ResolvedEscalationConfig, } from './doom-loop.js'; import { DoomLoopMonitor, resolveDoomLoopOption, resolveLoopKeyMaterial } from './doom-loop.js'; @@ -533,6 +534,12 @@ export class ModelResult< message?: string; } >(); + // Loop-key resolutions computed while declaring the current round, keyed by + // tool-call id. A `loopKey` function is user code that may count, log, or + // return something different each time, so it must run at most once per + // call: the per-call checkpoint reuses what the declaration resolved instead + // of resolving again. Cleared with the round. + private readonly doomLoopRoundKeyMaterial = new Map(); // Serialization chain for doom evaluations: parallel tool executions // append their evaluation here in call order (the .map() over a round's // calls runs synchronously to the first await), so streak recording @@ -1307,6 +1314,7 @@ export class ModelResult< } this.doomLoopRound++; this.doomLoopRoundDecisions.clear(); + this.doomLoopRoundKeyMaterial.clear(); const declared: { toolName: string; @@ -1329,6 +1337,11 @@ export class ModelResult< tool !== undefined && isClientTool(tool) ? tool.function.loopKey : undefined, (toolCall.arguments ?? {}) as Record, ); + // Cache so the per-call checkpoint does not invoke `loopKey` a second + // time; keyed by call id, which is unique within a round. + if (toolCall.id !== undefined) { + this.doomLoopRoundKeyMaterial.set(String(toolCall.id), resolution); + } if (resolution.kind === 'exempt') { continue; } @@ -1501,8 +1514,20 @@ export class ModelResult< // arguments, so what remains is the parsed record (or null/undefined for // no-args calls — coerced to {} the same way the PreToolUse payload is). const callArguments = (toolCall.arguments ?? {}) as Record; - const loopKey = isClientTool(tool) ? tool.function.loopKey : undefined; - const resolution = resolveLoopKeyMaterial(loopKey, callArguments); + /* + * Reuse what `beginDoomLoopRound` resolved for this call when it declared + * the round. `loopKey` is user code — it may count, log, or return a fresh + * value each time — so it must run at most once per call. Resolving here + * as well would double-invoke it and, for a non-repeatable callback, make + * the declared identity and the recorded identity disagree. + */ + const cached = + toolCall.id !== undefined + ? this.doomLoopRoundKeyMaterial.get(String(toolCall.id)) + : undefined; + const resolution = + cached ?? + resolveLoopKeyMaterial(isClientTool(tool) ? tool.function.loopKey : undefined, callArguments); if (resolution.kind === 'exempt') { return { blocked: false, diff --git a/packages/agent/tests/unit/doom-loop-fanout.test.ts b/packages/agent/tests/unit/doom-loop-fanout.test.ts index 315b33f8..72eecfa9 100644 --- a/packages/agent/tests/unit/doom-loop-fanout.test.ts +++ b/packages/agent/tests/unit/doom-loop-fanout.test.ts @@ -419,6 +419,71 @@ describe('same-tool fan-out streaks', () => { expect(fresh.verdict).toBeUndefined(); }); + it('does not let a call outside the declared set inherit the round streak', async () => { + /* + * `declareRound` drops a call whose key material is unhashable (bigint, + * NaN, circular), but at record time that call still resolves an identity + * through the caller's fallback chain. Sharing the round's streak is keyed + * on the call being a MEMBER of the declaration, not merely on the tool + * having one — otherwise the dropped call inherits the round's accumulated + * count and can be blocked on its first ever appearance, which is the + * failure mode declared/undeclared scoring exists to prevent. + */ + const detector = monitor(); + const hashable = { + path: 'a', + }; + /* Establish a streak on the declared member across two rounds. */ + for (const round of [ + 0, + 1, + ]) { + await detector.declareRound(round, [ + { + toolName: 'read', + keyMaterial: hashable, + }, + /* Unhashable: dropped from the declared set. */ + { + toolName: 'read', + keyMaterial: { + size: 1n, + }, + }, + ]); + await detector.recordToolCall('read', hashable, round); + } + + /* Round 2: the declared member repeats, then the dropped call arrives. */ + await detector.declareRound(2, [ + { + toolName: 'read', + keyMaterial: hashable, + }, + { + toolName: 'read', + keyMaterial: { + size: 1n, + }, + }, + ]); + const member = await detector.recordToolCall('read', hashable, 2); + /* Recorded with a fallback identity, as the engine would. */ + const dropped = await detector.recordToolCall( + 'read', + { + size: 'fallback-identity', + }, + 2, + ); + + /* The real repeat accumulates. */ + expect(member.streak).toBe(3); + /* The non-member does not inherit it. */ + expect(dropped.streak).toBe(1); + expect(dropped.verdict).toBeUndefined(); + }); + it('gives every call of a repeating round the SAME message so steer dedupes', async () => { /* * `queueDoomLoopSteer` dedupes queued guidance by exact message text. Now diff --git a/packages/agent/tests/unit/doom-loop-integration.test.ts b/packages/agent/tests/unit/doom-loop-integration.test.ts index fc790e0d..c4235b72 100644 --- a/packages/agent/tests/unit/doom-loop-integration.test.ts +++ b/packages/agent/tests/unit/doom-loop-integration.test.ts @@ -1069,4 +1069,53 @@ describe('doom-loop state persistence', () => { const roundTripped = JSON.parse(JSON.stringify(state)) as ConversationState; expect(roundTripped.doomLoop).toEqual(state.doomLoop); }); + + it('invokes a tool-supplied loopKey exactly once per call', async () => { + /* + * `loopKey` is user code: it may count, log, or return a fresh value each + * time. Declaring a round's identity up front resolves it once for the + * batch, and the per-call checkpoint must reuse that resolution rather + * than invoking the callback a second time — otherwise a callback with + * side effects sees double the activity, and a non-repeatable one makes + * the declared and recorded identities disagree. + */ + const loopKeySpy = vi.fn(({ query }: { query: string }) => query.trim().toLowerCase()); + const countedSearch = tool({ + name: 'web_search', + inputSchema: z.object({ + query: z.string(), + }), + outputSchema: z.object({ + results: z.array(z.string()), + }), + loopKey: loopKeySpy, + execute: async ({ query }) => ({ + results: [ + `result for ${query}`, + ], + }), + }); + + scriptModelTurns( + toolCallTurn('web_search', { + query: 'first', + }), + toolCallTurn('web_search', { + query: 'second', + }), + textTurn('Done.'), + ); + + await callModel(client, { + model: 'test-model', + input: 'Search twice.', + tools: [ + countedSearch, + ] as const, + doomLoop: true, + }).getText(); + + /* Two calls issued, so exactly two invocations — not four. */ + expect(loopKeySpy).toHaveBeenCalledTimes(2); + }); }); From a9d8b9c40d5a44ab3bbab8eb32f57bfc538fdf1e Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:28:16 -0500 Subject: [PATCH 05/32] fix(agent): keep a non-member record from clobbering the declared round set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the previous commit's fix, which gated READING the declared set on membership but not WRITING it. A call the declaration could not include — `declareRound` drops unhashable key material, and the engine still records it via its fallback chain — stored `roundFingerprints: [itsOwnFingerprint]`, replacing the round's declared set. The next round's declared member then compared against that singleton, failed to match, and reset to 1; with the unhashable call recurring every round the member's streak was pinned at 1 permanently. Measured: [1,1,1,1] across four repeating rounds where the control climbs [1,2,3,4]. So a single bigint in one call's arguments disabled doom-loop detection for that tool for the rest of the run — the inverse of the fail-open guarantee, which allows an unhashable value to cost detection for its OWN call only. Devin also noted an order dependence: a non-member recorded before the round's members made them inherit its streak. Both symptoms had one cause — round-level state (the round's identity and score) and per-call state (fingerprint, in-round dedupe) shared one mutable record. They are now written separately: the streak is computed as a pure function of (this round's set, the previous round's set, that round's streak), so arrival order cannot affect it, and a non-member records its own identity while leaving the round's identity and score to its declared members. The round TRANSITION is still recorded by whichever call arrives first, so the baseline advances even when a non-member opens the round — fixing that was what the first attempt at this commit got wrong. Regression test asserts the member streak climbs 1..4 with an unhashable call riding along, in BOTH emission orders. Fails against f44c69d with [1,1,1,1]. Verified: 762 pass, typecheck and biome clean. --- .changeset/doom-loop-fanout.md | 3 + packages/agent/src/lib/doom-loop.ts | 92 ++++++++++++------- .../agent/tests/unit/doom-loop-fanout.test.ts | 75 ++++++++++++++- 3 files changed, 136 insertions(+), 34 deletions(-) diff --git a/.changeset/doom-loop-fanout.md b/.changeset/doom-loop-fanout.md index e74a1e06..2503fd08 100644 --- a/.changeset/doom-loop-fanout.md +++ b/.changeset/doom-loop-fanout.md @@ -21,6 +21,9 @@ fan-out stops spending rather than only its last call being refused. Paths that cannot know a round's membership up front — server-tool records, and direct/port callers — are scored per call against the previous round, the same as before this change: a fan-out there goes undetected rather than mis-scored. +A call that a round's declaration could not include (unhashable key material) +is likewise scored on its own, and cannot move the round's counters — one +unhashable argument costs detection for its own call only, never for the tool. Single-call rounds, in-round duplicate collapsing, resumed streaks, persisted state shape, verdict payloads, and the number of times a tool's `loopKey` is diff --git a/packages/agent/src/lib/doom-loop.ts b/packages/agent/src/lib/doom-loop.ts index 8359c6ea..d11e63eb 100644 --- a/packages/agent/src/lib/doom-loop.ts +++ b/packages/agent/src/lib/doom-loop.ts @@ -1098,36 +1098,10 @@ export class DoomLoopMonitor { const duplicateInRound = seen.includes(fingerprint); /* - * Compare this round's whole set against the previous round's whole set. - * When the round was declared, every call in it scores the SAME streak — - * a repeating fan-out is one piece of evidence per round, and the ladder - * applies to the round as a unit rather than to whichever call happened to - * complete the match. Calls outside a declaration (undeclared rounds, or a - * member dropped as unhashable) have no shared set to inherit, so each is - * compared individually (per-call semantics, as before). - */ - let streak: number; - if (isSameRound && previous && declaredMember) { - streak = previous.streak; - } else { - const priorSet = isSameRound - ? previous?.priorRoundFingerprints - : (previous?.roundFingerprints ?? - (previous - ? [ - previous.fingerprint, - ] - : undefined)); - streak = - priorSet !== undefined && setsMatch(priorSet, roundFingerprints) - ? (isSameRound ? (previous?.priorStreak ?? 0) : previous!.streak) + 1 - : 1; - } - - /* - * The set the NEXT call of this round must compare against. Within one - * round every call is measured against the round before it, so this is - * carried unchanged across the round rather than overwritten per call. + * What this call is measured against: the set the PREVIOUS round was + * called with, and the streak that round earned. Carried unchanged for the + * length of the current round, so every call in a round compares against + * the same baseline regardless of the order the calls arrive in. */ const priorRoundFingerprints = isSameRound ? previous?.priorRoundFingerprints @@ -1137,18 +1111,70 @@ export class DoomLoopMonitor { previous.fingerprint, ] : undefined)); + const priorStreak = isSameRound ? (previous?.priorStreak ?? 0) : (previous?.streak ?? 0); + + /* + * A round's streak is a pure function of (this round's set, the previous + * round's set, the streak that round earned) — never of whichever call of + * this round happened to be recorded first. Every call sharing a declared + * set therefore scores the same streak by construction: a repeating + * fan-out is one piece of evidence per round, and the ladder applies to + * the round as a unit rather than to one member. Calls outside a + * declaration carry their own single-fingerprint set through the same + * formula, which is the per-call comparison. + */ + const streak = + priorRoundFingerprints !== undefined && setsMatch(priorRoundFingerprints, roundFingerprints) + ? priorStreak + 1 + : 1; + + /* + * Round-level bookkeeping belongs to the round's declared set, so a call + * that is NOT part of the declaration must not write it: overwriting + * `roundFingerprints` with its own singleton would leave the next round's + * members comparing against that singleton and resetting to 1 forever, so + * one unhashable argument would disable detection for the tool for the + * rest of the run. Non-members still record `fingerprint`/`seenThisRound` + * (identity for persistence, and in-round dedupe) and still receive their + * own per-call verdict — they just cannot move the round's counters. + */ + const isNonMemberOfDeclaredRound = declared !== undefined && !declaredMember; + /* + * A non-member does not define the round, so the round's own set/streak + * stay unset by it — but the round TRANSITION still has to be recorded, or + * the baseline would never advance. `priorRoundFingerprints`/`priorStreak` + * are therefore written from the same computation every call uses; only + * `roundFingerprints` and `streak` (the round's identity and its score) + * are withheld, to be filled in by the round's first declared member. + */ + const roundState = isNonMemberOfDeclaredRound + ? { + ...(declared !== undefined + ? { + roundFingerprints: declared, + } + : {}), + streak: + priorRoundFingerprints !== undefined && + setsMatch(priorRoundFingerprints, declared ?? []) + ? priorStreak + 1 + : 1, + } + : { + roundFingerprints, + streak, + }; this.tools.set(toolName, { fingerprint, - streak, round, - roundFingerprints, seenThisRound: mergeFingerprint(seen, fingerprint), + ...roundState, ...(priorRoundFingerprints !== undefined ? { priorRoundFingerprints, } : {}), - priorStreak: isSameRound ? (previous?.priorStreak ?? 0) : (previous?.streak ?? 0), + priorStreak, }); const allowBlock = options?.allowBlock ?? true; diff --git a/packages/agent/tests/unit/doom-loop-fanout.test.ts b/packages/agent/tests/unit/doom-loop-fanout.test.ts index 72eecfa9..eafb2314 100644 --- a/packages/agent/tests/unit/doom-loop-fanout.test.ts +++ b/packages/agent/tests/unit/doom-loop-fanout.test.ts @@ -21,7 +21,7 @@ * that; the subset case alone did not catch it. */ import { describe, expect, it } from 'vitest'; - +import type { DoomLoopCallRecord } from '../../src/lib/doom-loop.js'; import { DoomLoopMonitor, resolveDoomLoopOption } from '../../src/lib/doom-loop.js'; type RecordedAction = string; @@ -484,6 +484,79 @@ describe('same-tool fan-out streaks', () => { expect(dropped.verdict).toBeUndefined(); }); + it('keeps accumulating when an unhashable call rides along every round', async () => { + /* + * A non-member must not write the round's identity. It used to store its + * own single-fingerprint set as `roundFingerprints`, so the NEXT round's + * declared member compared against that singleton, failed to match, and + * reset to 1 — permanently, for as long as the unhashable call recurred. + * One unhashable argument therefore disabled detection for that tool for + * the rest of the run, which is the opposite of the fail-open guarantee + * (an unhashable value may only cost detection for ITS OWN call). + * + * Asserted in both emission orders, since which call opens the round is + * up to the model. + */ + const streaksFor = async (droppedFirst: boolean): Promise => { + const detector = monitor(); + const streaks: number[] = []; + for (const round of [ + 0, + 1, + 2, + 3, + ]) { + await detector.declareRound(round, [ + { + toolName: 'read', + keyMaterial: { + path: 'a', + }, + }, + { + toolName: 'read', + keyMaterial: { + size: 1n, + }, + }, + ]); + const recordDropped = (): Promise => + detector.recordToolCall( + 'read', + { + size: 'fallback-identity', + }, + round, + ); + if (droppedFirst) { + await recordDropped(); + } + const member = await detector.recordToolCall( + 'read', + { + path: 'a', + }, + round, + ); + if (!droppedFirst) { + await recordDropped(); + } + streaks.push(member.streak); + } + return streaks; + }; + + const expected = [ + 1, + 2, + 3, + 4, + ]; + expect(await streaksFor(false)).toEqual(expected); + /* Order must not matter: the streak is a function of the sets, not arrival. */ + expect(await streaksFor(true)).toEqual(expected); + }); + it('gives every call of a repeating round the SAME message so steer dedupes', async () => { /* * `queueDoomLoopSteer` dedupes queued guidance by exact message text. Now From 43b88c2a2257ed6ceecd741589c8632bd29eb9c5 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:49:19 -0500 Subject: [PATCH 06/32] fix(agent): don't run loopKey for calls the detector never checks; add changeset example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from Devin's fifth pass. `beginDoomLoopRound` declared every call in the batch, resolving each one's `loopKey` up front — including calls that are never checked. A manual tool (no `execute`, no `onToolCalled`) is handed to the caller and never recorded as evidence, and every execution path skips it via `isAutoResolvableTool`, but the declaration ran its `loopKey` anyway. For user code that counts or logs inside that callback, this was activity for a call the detector never evaluated. The declaration now applies the same `isAutoResolvableTool` predicate, which is also correct on its own terms: a call that is not evidence is not part of the round. Note the reproduction needs a MIXED batch. An all-manual round never reaches `beginDoomLoopRound` at all (`hasExecutableToolCalls` guards it), so the first version of this test passed with and without the fix — it proved nothing. The committed test pairs a manual call with an executable one and asserts the executable call's loopKey runs exactly once while the manual call's never runs; it fails without the guard with "called 1 times". The changeset had no code example, which .agents/skills/public-api-examples requires for behavioral changes to a public option even when the signature is unchanged. Added one to the changeset and an `### API example` section to the PR description, both showing the same before/after: a repeating three-call fan-out that previously never tripped now observes at round 2 and blocks every call of the round at 3, while a round that adds work resets to 1. Verified: 661 unit tests pass, typecheck and biome clean. (One e2e cancellation test failed once on a full run and passed in isolation and on re-run — a live network timing flake, unrelated to this change.) --- .changeset/doom-loop-fanout.md | 32 ++++++ packages/agent/src/lib/doom-loop.ts | 8 ++ packages/agent/src/lib/model-result.ts | 14 ++- .../agent/tests/unit/doom-loop-fanout.test.ts | 105 ++++++++++++++++++ .../tests/unit/doom-loop-integration.test.ts | 104 +++++++++++++++++ 5 files changed, 262 insertions(+), 1 deletion(-) diff --git a/.changeset/doom-loop-fanout.md b/.changeset/doom-loop-fanout.md index 2503fd08..5afefd43 100644 --- a/.changeset/doom-loop-fanout.md +++ b/.changeset/doom-loop-fanout.md @@ -32,3 +32,35 @@ invoked (once per call) are unchanged. Known limit, unchanged by this fix: a call that repeats inside a round whose other members keep varying (`[a,b]`, `[a,c]`, `[a,d]`) does not accumulate, since round identity requires the whole set to match. + +No API surface changed — `doomLoop` is configured exactly as before. What +changed is when it fires: + +```ts +import { callModel } from '@openrouter/agent'; + +const result = callModel(client, { + model: 'z-ai/glm-5.2', + input: 'Summarize these files.', + tools: [readTool], + // Unchanged config; the ladder default is observe@2, block@3, stop@6. + doomLoop: true, +}); + +// Say the model reissues the SAME three-call fan-out every round: +// round 1: read(a), read(b), read(c) +// round 2: read(a), read(b), read(c) <- identical set +// +// was: no detection, ever. Each round's first call reset the streak, so +// a fan-out could spin indefinitely while single calls tripped at +// round 2. +// now: round 2 is streak 2 (observe), round 3 is streak 3 (block) — and +// EVERY call of the round is refused at the block rung, not just one, +// so the fan-out stops spending. +// +// A round that ADDS work is progress and resets to 1: +// round 3: read(a), read(b), read(c), read(d) <- no verdict +// +// `loopKey` still runs exactly once per checked call, and persisted +// `ConversationState.doomLoop` is byte-identical to before. +``` diff --git a/packages/agent/src/lib/doom-loop.ts b/packages/agent/src/lib/doom-loop.ts index d11e63eb..ae76b998 100644 --- a/packages/agent/src/lib/doom-loop.ts +++ b/packages/agent/src/lib/doom-loop.ts @@ -1086,6 +1086,14 @@ export class DoomLoopMonitor { * the set, but still resolves an identity at record time via the caller's * fallback chain — it must not be scored as part of the round, or it would * inherit the round's count and could be blocked on its first appearance. + * + * KNOWN LIMIT: such a call is compared against its tool's declared set, + * which by construction it is not in, so it never matches and holds at + * streak 1 even when reissued verbatim every round. It costs detection for + * itself only — the round's members keep accumulating — which is the + * fail-open contract. (A tool whose calls are ALL unhashable has no + * declared set at all, so those calls fall through to the ordinary + * per-call comparison and do accumulate.) */ const declaredMember = declared?.includes(fingerprint) === true; const roundFingerprints = declaredMember diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index 6db36c0f..206c43f1 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -1306,6 +1306,14 @@ export class ModelResult< * material is exempt or unhashable is simply left out, and the per-call * fallback chain in `enqueueDoomLoopEvaluation` still governs identity at * record time. + * + * Only calls that the round will actually CHECK are declared. A `loopKey` + * function is user code that may count or log, so it must not run for a call + * the detector never evaluates: manual tool calls (no `execute`, no + * `onToolCalled`) are handed to the caller and never recorded, and every + * execution path skips them with the same `isAutoResolvableTool` predicate + * used here. Their absence from the declared set is also correct on its own + * terms — they are not evidence, so they are not part of the round. */ private async beginDoomLoopRound(batch: readonly ParsedToolCall[] = []): Promise { const monitor = this.doomLoopMonitor; @@ -1333,8 +1341,12 @@ export class ModelResult< const tool = this.options.tools?.find( (t) => isClientTool(t) && t.function.name === toolCall.name, ); + // Never checked => never recorded => `loopKey` must not run for it. + if (tool === undefined || !isAutoResolvableTool(tool)) { + continue; + } const resolution = resolveLoopKeyMaterial( - tool !== undefined && isClientTool(tool) ? tool.function.loopKey : undefined, + isClientTool(tool) ? tool.function.loopKey : undefined, (toolCall.arguments ?? {}) as Record, ); // Cache so the per-call checkpoint does not invoke `loopKey` a second diff --git a/packages/agent/tests/unit/doom-loop-fanout.test.ts b/packages/agent/tests/unit/doom-loop-fanout.test.ts index eafb2314..35509d48 100644 --- a/packages/agent/tests/unit/doom-loop-fanout.test.ts +++ b/packages/agent/tests/unit/doom-loop-fanout.test.ts @@ -557,6 +557,111 @@ describe('same-tool fan-out streaks', () => { expect(await streaksFor(true)).toEqual(expected); }); + it('holds an unhashable call at streak 1 without stalling its round-mates', async () => { + /* + * Bounds the cost of an unhashable argument. Such a call is compared + * against its tool's declared set, which it is not a member of, so it + * cannot match and stays at 1 however often it recurs — it is invisible to + * detection. That is the fail-open contract: the price is paid by that call + * alone, and its round-mates keep accumulating normally (the regression + * above covers the case where it used to zero them too). + * + * Also pins the asymmetry: a tool whose calls are ALL unhashable has no + * declared set, so it falls through to the ordinary per-call comparison + * and DOES accumulate. + */ + const detector = monitor(); + const memberStreaks: number[] = []; + const droppedStreaks: number[] = []; + for (const round of [ + 0, + 1, + 2, + ]) { + await detector.declareRound(round, [ + { + toolName: 'read', + keyMaterial: { + path: 'a', + }, + }, + { + toolName: 'read', + keyMaterial: { + size: 1n, + }, + }, + ]); + memberStreaks.push( + ( + await detector.recordToolCall( + 'read', + { + path: 'a', + }, + round, + ) + ).streak, + ); + droppedStreaks.push( + ( + await detector.recordToolCall( + 'read', + { + size: 'fallback-identity', + }, + round, + ) + ).streak, + ); + } + + expect(memberStreaks).toEqual([ + 1, + 2, + 3, + ]); + expect(droppedStreaks).toEqual([ + 1, + 1, + 1, + ]); + + /* No declared set for the tool at all: ordinary per-call accumulation. */ + const allUnhashable = monitor(); + const soloStreaks: number[] = []; + for (const round of [ + 0, + 1, + 2, + ]) { + await allUnhashable.declareRound(round, [ + { + toolName: 'read', + keyMaterial: { + size: 1n, + }, + }, + ]); + soloStreaks.push( + ( + await allUnhashable.recordToolCall( + 'read', + { + size: 'fallback-identity', + }, + round, + ) + ).streak, + ); + } + expect(soloStreaks).toEqual([ + 1, + 2, + 3, + ]); + }); + it('gives every call of a repeating round the SAME message so steer dedupes', async () => { /* * `queueDoomLoopSteer` dedupes queued guidance by exact message text. Now diff --git a/packages/agent/tests/unit/doom-loop-integration.test.ts b/packages/agent/tests/unit/doom-loop-integration.test.ts index c4235b72..ef5a8561 100644 --- a/packages/agent/tests/unit/doom-loop-integration.test.ts +++ b/packages/agent/tests/unit/doom-loop-integration.test.ts @@ -107,6 +107,35 @@ function textTurn(text: string): models.OpenResponsesResult { ]); } +/** A turn with two parallel tool calls, for mixed-batch scenarios. */ +function twoToolCallTurn( + first: [ + string, + unknown, + ], + second: [ + string, + unknown, + ], +): models.OpenResponsesResult { + return baseResponse( + [ + first, + second, + ].map(([name, args]) => { + callCounter++; + return { + type: 'function_call', + id: `fc_${callCounter}`, + callId: `call_${callCounter}`, + name, + arguments: JSON.stringify(args), + status: 'completed', + }; + }), + ); +} + /** Queue scripted turns; the "LLM" plays them back in order. */ function scriptModelTurns(...turns: models.OpenResponsesResult[]) { for (const turn of turns) { @@ -1118,4 +1147,79 @@ describe('doom-loop state persistence', () => { /* Two calls issued, so exactly two invocations — not four. */ expect(loopKeySpy).toHaveBeenCalledTimes(2); }); + + it('never invokes loopKey for a manual call the detector will not check', async () => { + /* + * A manual tool (no `execute`, no `onToolCalled`) is handed to the caller + * to resolve externally and is never recorded as loop evidence. Declaring + * a round must therefore skip it: `loopKey` is user code, and running it + * for a call the detector never evaluates is activity the caller cannot + * account for. Every execution path skips these with the same + * `isAutoResolvableTool` predicate the declaration now uses. + */ + const manualLoopKey = vi.fn(({ query }: { query: string }) => query); + const manualTool = tool({ + name: 'ask_human', + inputSchema: z.object({ + query: z.string(), + }), + outputSchema: z.object({ + answer: z.string(), + }), + loopKey: manualLoopKey, + }); + const executableLoopKey = vi.fn(({ query }: { query: string }) => query); + const executableTool = tool({ + name: 'web_search', + inputSchema: z.object({ + query: z.string(), + }), + outputSchema: z.object({ + results: z.array(z.string()), + }), + loopKey: executableLoopKey, + execute: async ({ query }) => ({ + results: [ + query, + ], + }), + }); + + /* + * MIXED batch: an all-manual round never reaches the doom-loop round at + * all (`hasExecutableToolCalls` guards it), so the manual call has to ride + * alongside an executable one for the declaration to be reached. + */ + scriptModelTurns( + twoToolCallTurn( + [ + 'web_search', + { + query: 'go', + }, + ], + [ + 'ask_human', + { + query: 'what now?', + }, + ], + ), + ); + + await callModel(client, { + model: 'test-model', + input: 'Search, and ask me something.', + tools: [ + executableTool, + manualTool, + ] as const, + doomLoop: true, + }).getText(); + + /* The executable call is checked, so its loopKey runs exactly once. */ + expect(executableLoopKey).toHaveBeenCalledTimes(1); + /* The manual call is never recorded, so its loopKey must not run at all. */ + expect(manualLoopKey).not.toHaveBeenCalled(); + }); }); From ebc553816a2c4393deeed716cc2057650432b187 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:02:55 -0500 Subject: [PATCH 07/32] fix(agent): don't carry a fan-out streak onto a single call across a resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persisted state holds one fingerprint and one count per tool, so it cannot express "this count was earned by the set {a,b,c}". Restoring a fan-out's streak verbatim attached the whole count to whichever member happened to be recorded last, so a resumed round consisting of just that one call matched, inherited the fan-out's evidence, and was BLOCKED on its first appearance — while the model had done strictly less work than before the save. It was also arbitrary: resuming with a different member of the same fan-out scored 1 and passed. `getState` now persists a multi-call round's streak as 1. Under-counting on resume is the safe direction — the round is re-observed and re-accumulates from a correct baseline, which the test asserts so the fix cannot silently become a detection hole. Measured, 3-call fan-out repeated twice then resumed with one call: before this PR (main): saved 1, resumed -> streak 2, observe eb3b51d..43b88c2: saved 2, resumed -> streak 3, BLOCK now: saved 1, resumed -> streak 2, observe So the mechanism predates this PR, but making fan-outs accumulate raised the saved count, which escalated the resumed outcome from a harmless observe to a refused call. That makes it this PR's regression to fix. Also corrects the comment in restore(), which claimed a resumed fan-out streak "restarts at 1" — it did not, and the claim is only true now that getState enforces it. The existing resume test passed either way because it resumed the same multi-call set, which never matched; it never covered the single-call case. Verified: 662 unit tests pass; the new test fails without the getState change. Typecheck and biome clean. --- packages/agent/src/lib/doom-loop.ts | 26 +++- .../agent/tests/unit/doom-loop-fanout.test.ts | 112 ++++++++++++++++++ 2 files changed, 134 insertions(+), 4 deletions(-) diff --git a/packages/agent/src/lib/doom-loop.ts b/packages/agent/src/lib/doom-loop.ts index ae76b998..aa49eb33 100644 --- a/packages/agent/src/lib/doom-loop.ts +++ b/packages/agent/src/lib/doom-loop.ts @@ -951,7 +951,21 @@ export class DoomLoopMonitor { name, { fingerprint: entry.fingerprint, - streak: entry.streak, + /* + * A MULTI-CALL round's streak is persisted as 1, because the shape + * carries one fingerprint and cannot express "this count was earned + * by the set {a,b,c}". Restoring it verbatim would attach the whole + * count to whichever call happened to be recorded last, so a + * resumed round consisting of just that one call would inherit a + * fan-out's evidence and could be refused on its first appearance — + * despite the model doing strictly LESS work than before the save. + * Under-counting on resume is the safe direction: the round is + * re-observed and re-accumulates from a correct baseline. + * + * Single-call rounds are unaffected: their fingerprint fully + * describes the round, so the streak survives intact. + */ + streak: (entry.roundFingerprints?.length ?? 1) > 1 ? 1 : entry.streak, }, ]), ), @@ -988,9 +1002,13 @@ export class DoomLoopMonitor { streak: entry.streak, // `round` intentionally absent: the first resumed record is // always a new round, so it increments whatever the numbering. - // Only the last fingerprint survives serialization, so a resumed - // FAN-OUT streak restarts at 1 while a single-call streak - // continues — the round set is run-local by design. + // + // The round set is run-local, so only one fingerprint survives a + // save. A resumed round therefore compares against that single + // fingerprint: a single-call streak continues seamlessly, and a + // FAN-OUT streak restarts — enforced at save time by `getState`, + // which persists a multi-call round's streak as 1 rather than + // letting its last call carry the whole count into the next run. roundFingerprints: [ entry.fingerprint, ], diff --git a/packages/agent/tests/unit/doom-loop-fanout.test.ts b/packages/agent/tests/unit/doom-loop-fanout.test.ts index 35509d48..bd48ba1c 100644 --- a/packages/agent/tests/unit/doom-loop-fanout.test.ts +++ b/packages/agent/tests/unit/doom-loop-fanout.test.ts @@ -777,4 +777,116 @@ describe('same-tool fan-out streaks', () => { expect(record.streak).toBe(1); }); + + it('does not refuse a resumed SINGLE call that inherits a fan-out streak', async () => { + /* + * The persisted shape holds one fingerprint per tool, so it cannot express + * "this count was earned by the set {a,b,c}". Restoring a fan-out's streak + * verbatim attached the whole count to whichever call was recorded last: + * a resumed round consisting of just that one call then matched, inherited + * the fan-out's evidence, and was BLOCKED on its first appearance — while + * the model had done strictly less work than before the save. It was also + * arbitrary, since it depended on which member happened to be last. + * + * `getState` therefore persists a multi-call round's streak as 1. + * Under-counting on resume is the safe direction; the loop is re-observed + * and re-accumulates from a correct baseline (asserted below). + */ + const detector = monitor(); + const paths = [ + 'a', + 'b', + 'c', + ]; + for (const round of [ + 0, + 1, + ]) { + await detector.declareRound( + round, + paths.map((path) => ({ + toolName: 'read', + keyMaterial: { + path, + }, + })), + ); + for (const path of paths) { + await detector.recordToolCall( + 'read', + { + path, + }, + round, + ); + } + } + /* Round 1 reached observe (streak 2) before the save. */ + expect( + ( + detector.getState() as { + tools: Record< + string, + { + streak: number; + } + >; + } + ).tools.read.streak, + ).toBe(1); + + /* Resume with ONE call — the same one that was recorded last. */ + const resumed = new DoomLoopMonitor(resolveDoomLoopOption(true), detector.getState()); + await resumed.declareRound(0, [ + { + toolName: 'read', + keyMaterial: { + path: 'c', + }, + }, + ]); + const solo = await resumed.recordToolCall( + 'read', + { + path: 'c', + }, + 0, + ); + expect(solo.streak).toBe(2); + expect(solo.verdict?.action).not.toBe('block'); + + /* Detection is not lost: a repeating fan-out trips again after the resume. */ + const afterResume: number[] = []; + for (const round of [ + 1, + 2, + ]) { + await resumed.declareRound( + round, + paths.map((path) => ({ + toolName: 'read', + keyMaterial: { + path, + }, + })), + ); + let last = 0; + for (const path of paths) { + last = ( + await resumed.recordToolCall( + 'read', + { + path, + }, + round, + ) + ).streak; + } + afterResume.push(last); + } + expect(afterResume).toEqual([ + 1, + 2, + ]); + }); }); From 905bc7adba79eea66c160ca6371a37a0538aced3 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:15:12 -0500 Subject: [PATCH 08/32] docs(agent): correct false changeset claims; document the new false-positive class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changeset asserted two things that were not true. "Resumed streaks unchanged" — the previous commit deliberately made a multi-call round's saved streak restart after a resume. The changeset now states that, why (the persisted shape cannot express which set earned a count), and that single-call streaks still continue. "No API surface changed" — `DoomLoopMonitor` is exported from src/index.ts, so `declareRound` is a new public method. Documented as additive, with a note that `callModel` users need not touch it while direct `DoomLoopMonitor` users and SDK ports should, since an undeclared multi-call round's fan-out goes undetected. Bump raised patch -> minor accordingly: .agents/skills/changeset-versioning specifies minor for new exports and features. Also documents a false-positive class this PR newly makes reachable, which is worth a decision before shipping (raised on the thread, not resolved here). Because a round's identity is the whole set, a tool called with a stable set of parallel arguments every round now accumulates where it previously could not. Measured, an agent re-reading three context files every turn: round 1: none none none round 2: observe observe observe round 3: block block block <- all three reads refused, every round after That is a legitimate shape, and it produces N synthesized error outputs per round rather than one. `loopKey: false` is the opt-out; no prior exemption covered this, since the shape was invisible to the detector before. Added to the README next to the `loopKey` exemption guidance and to the changeset. --- .changeset/doom-loop-fanout.md | 33 +++++++++++++++++++++++++++++---- packages/agent/README.md | 12 ++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/.changeset/doom-loop-fanout.md b/.changeset/doom-loop-fanout.md index 5afefd43..62ddd967 100644 --- a/.changeset/doom-loop-fanout.md +++ b/.changeset/doom-loop-fanout.md @@ -1,5 +1,5 @@ --- -'@openrouter/agent': patch +'@openrouter/agent': minor --- Fix doom-loop detection missing a repeated same-tool fan-out. @@ -25,14 +25,39 @@ A call that a round's declaration could not include (unhashable key material) is likewise scored on its own, and cannot move the round's counters — one unhashable argument costs detection for its own call only, never for the tool. -Single-call rounds, in-round duplicate collapsing, resumed streaks, persisted -state shape, verdict payloads, and the number of times a tool's `loopKey` is -invoked (once per call) are unchanged. +**Resumed runs**: a *multi-call* round's streak now restarts after a +save/resume. The persisted shape carries one fingerprint per tool and cannot +express which set earned a count, so keeping it would attach a fan-out's +evidence to whichever member was recorded last — a resumed round of just that +one call would then be refused on its first appearance, despite the model doing +less work than before the save. A repeating fan-out is re-detected from a clean +baseline instead (observe at the second repeat after resume). Single-call +streaks still continue across a resume exactly as before. + +**New API**: `DoomLoopMonitor.declareRound(round, calls)` — declares a round's +complete call set before any of it is scored. `DoomLoopMonitor` is exported, so +this is a new public method, additive only. Callers using `callModel` need not +touch it (the engine calls it); direct `DoomLoopMonitor` users and SDK ports +should, since an undeclared multi-call round falls back to per-call scoring and +its fan-out goes undetected. + +Single-call round timing, in-round duplicate collapsing, persisted state +*shape*, verdict payloads, and the number of times a tool's `loopKey` is invoked +(once per checked call) are unchanged. Known limit, unchanged by this fix: a call that repeats inside a round whose other members keep varying (`[a,b]`, `[a,c]`, `[a,d]`) does not accumulate, since round identity requires the whole set to match. +**Newly reachable false positive.** The detector compares arguments, not +results, so a tool invoked with a stable *set* of parallel arguments every round +now accumulates a streak where it previously could not — an agent re-reading the +same context files each turn, or a fixed fan-out of pollers, is refused at the +default `block` rung from round 3, with one synthesized error per call in the +round. Exempt such tools with `loopKey: false` (or a `loopKey` returning `null` +for the call). This class was invisible to the detector before, so no existing +exemption covered it. + No API surface changed — `doomLoop` is configured exactly as before. What changed is when it fires: diff --git a/packages/agent/README.md b/packages/agent/README.md index 848a2e42..79e2c7ce 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -389,6 +389,18 @@ warning — detection never fails a run. Without any `loopKey`, the full validated arguments object is the identity. MCP-wrapped tools accept a `loopKey` via `markMcp(tool, { loopKey })` (prefer the field-list form). +> **Exempt tools that repeat by design — including repeating *fan-outs*.** +> The detector compares arguments, not results, so a call whose arguments are +> stable while its results change is indistinguishable from a loop. Since a +> round's identity is now the whole *set* of a tool's calls, this covers +> parallel shapes too: an agent that re-reads the same context files at the +> start of every turn, or fans out a fixed set of pollers, accumulates a streak +> and is refused at the default `block` rung from round 3 — and because every +> call in the round gets the verdict, that is N synthesized error outputs per +> round, not one. These shapes were invisible before this behavior existed, so +> `loopKey: false` (or a `loopKey` returning `null`) is the opt-out for any +> tool whose repetition is legitimate. + **Fingerprints are a cross-port contract**: key material is canonicalized per RFC 8785 (JCS) and hashed with SHA-256 over the UTF-8 bytes, so the Python/Go ports produce identical fingerprints — they MUST use an RFC 8785 From de42c4eaf29dcae0db1cbea998c4d921665c2936 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:28:40 -0500 Subject: [PATCH 09/32] fix(agent): don't declare calls the round will never record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `beginDoomLoopRound` declared some calls that never reach the doom-loop checkpoint, so they became phantom members of the round's identity and the sibling that WAS recorded got scored against a set including them. The streak then reset the moment the phantom stopped being emitted, even though the recorded call never changed. Two sources, both closed: - The malformed-arguments branch ran BEFORE the tool-resolvability check, so a raw-string call to an unknown or manual tool was declared despite never being recorded. The tool lookup and `isAutoResolvableTool` gate now precede it. - A call the PermissionRequest hook denied without pausing: `hookDeniedCalls` is populated before the round begins and `runToolWithHooks` synthesizes the rejection before the checkpoint, so those are skipped too. The new test drives the monitor directly with an over-broad declaration to pin the consequence — an identical recorded call scores [1,2,1,2] across four rounds when a phantom member is present for the first two — so the reason for the engine-side filtering is documented rather than implicit. Note on the test: an earlier version of this drove the engine end-to-end with a malformed manual call, and produced ZERO detections — the loop pauses on the manual call before later rounds run, so it asserted nothing. Removed rather than patched; the monitor-level test verifies the actual mechanism. That is the third test this session that would have passed against the bug it claimed to cover, so I am now deriving the expected numbers before writing the assertion instead of after. Verified: 663 unit tests pass, typecheck and biome clean. --- packages/agent/src/lib/model-result.ts | 33 +++++++-- .../agent/tests/unit/doom-loop-fanout.test.ts | 69 +++++++++++++++++++ 2 files changed, 95 insertions(+), 7 deletions(-) diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index 206c43f1..818492a5 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -1329,6 +1329,32 @@ export class ModelResult< keyMaterial: unknown; }[] = []; for (const toolCall of batch) { + const tool = this.options.tools?.find( + (t) => isClientTool(t) && t.function.name === toolCall.name, + ); + /* + * Never checked => never recorded => must not be declared. Declaring a + * call that never arrives inflates the round's identity: a sibling that + * IS recorded gets scored against a set containing a phantom member, and + * the streak resets spuriously once the phantom stops appearing (e.g. the + * model drops a malformed call while still repeating the valid one). + * Keeping `loopKey` from running for such a call is the same check. + * + * This gate deliberately precedes the malformed-arguments branch below: + * a raw-string call to an unknown or manual tool is still never recorded. + */ + if (tool === undefined || !isAutoResolvableTool(tool)) { + continue; + } + /* + * Same reasoning for a call the PermissionRequest hook denied without + * pausing: `hookDeniedCalls` is populated before the round begins, and + * `runToolWithHooks` synthesizes the rejection before reaching the + * doom-loop checkpoint, so the call is never recorded either. + */ + if (toolCall.id !== undefined && this.hookDeniedCalls.has(String(toolCall.id))) { + continue; + } const rawArgs: unknown = toolCall.arguments; if (typeof rawArgs === 'string') { // Malformed call: its identity is the raw string (see runToolWithHooks). @@ -1338,13 +1364,6 @@ export class ModelResult< }); continue; } - const tool = this.options.tools?.find( - (t) => isClientTool(t) && t.function.name === toolCall.name, - ); - // Never checked => never recorded => `loopKey` must not run for it. - if (tool === undefined || !isAutoResolvableTool(tool)) { - continue; - } const resolution = resolveLoopKeyMaterial( isClientTool(tool) ? tool.function.loopKey : undefined, (toolCall.arguments ?? {}) as Record, diff --git a/packages/agent/tests/unit/doom-loop-fanout.test.ts b/packages/agent/tests/unit/doom-loop-fanout.test.ts index bd48ba1c..06c5896d 100644 --- a/packages/agent/tests/unit/doom-loop-fanout.test.ts +++ b/packages/agent/tests/unit/doom-loop-fanout.test.ts @@ -557,6 +557,75 @@ describe('same-tool fan-out streaks', () => { expect(await streaksFor(true)).toEqual(expected); }); + it('resets a streak when a declared-but-never-recorded member disappears', async () => { + /* + * Pins WHY the engine must not declare a call it will never record (a + * manual tool, a PermissionRequest denial, a malformed call to either). + * Such a member is a phantom: it inflates the round's identity, so the + * sibling that IS recorded gets scored against a set it never matches on + * its own — and the streak resets the moment the phantom stops being + * emitted, even though the recorded call never changed. + * + * This test drives the monitor directly with an over-broad declaration to + * show the consequence; `beginDoomLoopRound` is what prevents it, by + * filtering the batch through `isAutoResolvableTool` and `hookDeniedCalls` + * before declaring. + */ + const detector = monitor(); + const streaks: number[] = []; + for (const round of [ + 0, + 1, + 2, + 3, + ]) { + /* Rounds 0-1 declare a phantom alongside the real call; 2-3 do not. */ + const declaredCalls = [ + { + toolName: 'read', + keyMaterial: { + path: 'a', + }, + }, + ...(round < 2 + ? [ + { + toolName: 'read', + keyMaterial: { + path: 'phantom', + }, + }, + ] + : []), + ]; + await detector.declareRound(round, declaredCalls); + /* Only the real call is ever recorded. */ + streaks.push( + ( + await detector.recordToolCall( + 'read', + { + path: 'a', + }, + round, + ) + ).streak, + ); + } + + /* + * The recorded call was identical in all four rounds, yet the streak + * restarts at round 2 when the phantom disappears. An over-broad + * declaration therefore costs real detection — hence the filtering. + */ + expect(streaks).toEqual([ + 1, + 2, + 1, + 2, + ]); + }); + it('holds an unhashable call at streak 1 without stalling its round-mates', async () => { /* * Bounds the cost of an unhashable argument. Such a call is compared From 2c5dbbdb9eaba6a10038334784c0141072aebe87 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:44:08 -0500 Subject: [PATCH 10/32] fix(agent): guard loop-identity resolution; persist the streak against its earner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from Devin's ninth pass. `resolveLoopKeyMaterial` can throw, and both call sites were unguarded. It catches a throwing `loopKey`, but the field-list form does `field in args` and `args[field]`, so a getter or proxy trap on the arguments object escapes it. Declaration resolves the whole batch up front, so an uncaught throw there would fail the round and the run over one odd call — the opposite of the invariant that detection only ever affects a run through a ladder action. Both sites now skip just that call and warn. Severity is narrower than reported, and worth recording: this is NOT reachable through `callModel`. Tool arguments come from `JSON.parse` (stream-transformers), so they are always plain objects, and `PreToolUse` argument mutation happens after the round is declared. It is reachable for direct callers, since both `resolveLoopKeyMaterial` and `DoomLoopMonitor` are exported, and for ports that build key material differently. Guarded regardless. `getState` paired the saved streak with the wrong identity. `fingerprint` is what pairs with `streak` in persisted state, and a non-member recorded LAST in a round overwrote it, so the count was attached to a call that never earned it. Both halves broke on resume: the non-member (a call detection is meant to ignore) matched, inherited the count, and was BLOCKED on its first appearance, while the genuinely repeating call reset to 1 and lost its evidence. Measured, saved streak 2: ignored call -> streak 3 block / real repeat -> streak 1; now -> streak 1 none / streak 3 block. A non-member no longer overwrites the identity; it is still tracked for in-round dedupe via `seenThisRound`. Test-quality note: my first attempt at the resolution-throw test passed WITHOUT the fix, because the end-to-end case I chose (loopKey returning a bigint) is caught by the pre-existing fingerprint fallback and never reaches the new throw path. Rewritten to pin the throw directly and to state in-comment that the engine path cannot reach it. That is the fourth test in this PR that would have passed against its own bug; every assertion here was derived from a measured run and verified to fail with the fix removed. Verified: 665 unit tests pass, typecheck and biome clean. --- packages/agent/src/lib/doom-loop.ts | 16 +++- packages/agent/src/lib/model-result.ts | 58 ++++++++++++-- .../agent/tests/unit/doom-loop-fanout.test.ts | 79 +++++++++++++++++++ .../tests/unit/doom-loop-integration.test.ts | 55 +++++++++++++ 4 files changed, 200 insertions(+), 8 deletions(-) diff --git a/packages/agent/src/lib/doom-loop.ts b/packages/agent/src/lib/doom-loop.ts index aa49eb33..1ca9879b 100644 --- a/packages/agent/src/lib/doom-loop.ts +++ b/packages/agent/src/lib/doom-loop.ts @@ -1190,8 +1190,22 @@ export class DoomLoopMonitor { roundFingerprints, streak, }; + /* + * `fingerprint` is the identity that PAIRS with `streak` in persisted state + * (see `getState`/`restore`), so a non-member must not overwrite it: the + * saved count would then be attached to a call that did not earn it. On + * resume that call matched, inherited the count, and was refused on its + * first appearance — while the genuinely repeating call, no longer the + * saved identity, reset to 1 and lost its evidence. Keep the last DECLARED + * member's fingerprint instead; a non-member is still tracked for in-round + * dedupe via `seenThisRound`. + */ + const identityFingerprint = + isNonMemberOfDeclaredRound && previous?.fingerprint !== undefined + ? previous.fingerprint + : fingerprint; this.tools.set(toolName, { - fingerprint, + fingerprint: identityFingerprint, round, seenThisRound: mergeFingerprint(seen, fingerprint), ...roundState, diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index 818492a5..255c8d9e 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -1364,10 +1364,30 @@ export class ModelResult< }); continue; } - const resolution = resolveLoopKeyMaterial( - isClientTool(tool) ? tool.function.loopKey : undefined, - (toolCall.arguments ?? {}) as Record, - ); + /* + * `resolveLoopKeyMaterial` catches a throwing `loopKey`, but not every + * throw: the field-list form reads `args[field]`, so a getter on the + * arguments object throws out of it uncaught. Declaration runs once for + * the whole batch, so letting that escape would fail the entire round — + * and the run — over one odd call, breaking the invariant that detection + * never affects a run except through its ladder actions. Skip just that + * call; the per-call checkpoint hits the same throw inside its own + * try/catch and applies the documented fallback chain there. + */ + let resolution: LoopKeyResolution; + try { + resolution = resolveLoopKeyMaterial( + isClientTool(tool) ? tool.function.loopKey : undefined, + (toolCall.arguments ?? {}) as Record, + ); + } catch (error) { + console.warn( + `[DoomLoop] could not resolve loop identity for "${toolCall.name}" while declaring the round; ` + + 'excluding it from the round set:', + error, + ); + continue; + } // Cache so the per-call checkpoint does not invoke `loopKey` a second // time; keyed by call id, which is unique within a round. if (toolCall.id !== undefined) { @@ -1556,9 +1576,33 @@ export class ModelResult< toolCall.id !== undefined ? this.doomLoopRoundKeyMaterial.get(String(toolCall.id)) : undefined; - const resolution = - cached ?? - resolveLoopKeyMaterial(isClientTool(tool) ? tool.function.loopKey : undefined, callArguments); + let resolution: LoopKeyResolution; + if (cached !== undefined) { + resolution = cached; + } else { + /* + * Not declared (undeclared round, or the declaration skipped this call). + * `resolveLoopKeyMaterial` can throw despite catching `loopKey` itself — + * the field-list form reads `args[field]`, so a getter on the arguments + * throws out of it. Detection must never reject a call or fail a run + * except through a ladder action, so skip detection for this one call. + */ + try { + resolution = resolveLoopKeyMaterial( + isClientTool(tool) ? tool.function.loopKey : undefined, + callArguments, + ); + } catch (error) { + console.warn( + `[DoomLoop] could not resolve loop identity for "${toolCall.name}"; ` + + 'skipping detection for this call:', + error, + ); + return { + blocked: false, + }; + } + } if (resolution.kind === 'exempt') { return { blocked: false, diff --git a/packages/agent/tests/unit/doom-loop-fanout.test.ts b/packages/agent/tests/unit/doom-loop-fanout.test.ts index 06c5896d..5f6fc4ef 100644 --- a/packages/agent/tests/unit/doom-loop-fanout.test.ts +++ b/packages/agent/tests/unit/doom-loop-fanout.test.ts @@ -557,6 +557,85 @@ describe('same-tool fan-out streaks', () => { expect(await streaksFor(true)).toEqual(expected); }); + it('persists the streak against the member that earned it, not a non-member', async () => { + /* + * `fingerprint` is the identity that pairs with `streak` in persisted + * state. A non-member recorded LAST in the round used to overwrite it, so + * the saved count was attached to a call that never earned it. Both halves + * then went wrong on resume: the non-member (a call detection is meant to + * ignore) matched, inherited the count, and was BLOCKED on its first + * appearance, while the genuinely repeating call was no longer the saved + * identity and reset to 1, losing its evidence. + */ + const detector = monitor(); + for (const round of [ + 0, + 1, + ]) { + await detector.declareRound(round, [ + { + toolName: 'read', + keyMaterial: { + path: 'a', + }, + }, + /* Unhashable: dropped from the declared set. */ + { + toolName: 'read', + keyMaterial: { + size: 1n, + }, + }, + ]); + await detector.recordToolCall( + 'read', + { + path: 'a', + }, + round, + ); + /* Recorded LAST, so it used to become the persisted identity. */ + await detector.recordToolCall( + 'read', + { + size: 'fallback-identity', + }, + round, + ); + } + + /* The ignored call must not be refused the first time it is seen. */ + const resumedNonMember = new DoomLoopMonitor(resolveDoomLoopOption(true), detector.getState()); + const firstSighting = await resumedNonMember.recordToolCall( + 'read', + { + size: 'fallback-identity', + }, + 0, + ); + expect(firstSighting.streak).toBe(1); + expect(firstSighting.verdict).toBeUndefined(); + + /* And the real repeat keeps the evidence it earned. */ + const resumedMember = new DoomLoopMonitor(resolveDoomLoopOption(true), detector.getState()); + await resumedMember.declareRound(0, [ + { + toolName: 'read', + keyMaterial: { + path: 'a', + }, + }, + ]); + const realRepeat = await resumedMember.recordToolCall( + 'read', + { + path: 'a', + }, + 0, + ); + expect(realRepeat.streak).toBe(3); + }); + it('resets a streak when a declared-but-never-recorded member disappears', async () => { /* * Pins WHY the engine must not declare a call it will never record (a diff --git a/packages/agent/tests/unit/doom-loop-integration.test.ts b/packages/agent/tests/unit/doom-loop-integration.test.ts index ef5a8561..2b62a0c9 100644 --- a/packages/agent/tests/unit/doom-loop-integration.test.ts +++ b/packages/agent/tests/unit/doom-loop-integration.test.ts @@ -21,6 +21,7 @@ vi.mock('@openrouter/sdk/funcs/betaResponsesSend', () => ({ })); import { callModel } from '../../src/inner-loop/call-model.js'; +import { resolveLoopKeyMaterial } from '../../src/lib/doom-loop.js'; import { HooksManager } from '../../src/lib/hooks-manager.js'; import type { DoomLoopDetectedPayload } from '../../src/lib/hooks-schemas.js'; import { tool } from '../../src/lib/tool.js'; @@ -905,6 +906,60 @@ describe('tool-declared loopKey', () => { warn.mockRestore(); } }); + + it('resolveLoopKeyMaterial can throw, so its callers must guard it', async () => { + /* + * `resolveLoopKeyMaterial` catches a throwing `loopKey`, but it can still + * throw on its own: the field-list form does `field in args` and + * `args[field]`, so a getter or proxy trap on the arguments object escapes + * it uncaught. Pinned here because `beginDoomLoopRound` resolves the WHOLE + * batch up front — an unguarded throw there fails the round and the run + * over one odd call, instead of costing detection for that call alone, + * which would break the invariant that detection only ever affects a run + * through a ladder action. + * + * NOT reachable through `callModel` today: tool arguments come from + * `JSON.parse` (stream-transformers.ts), so they are always plain objects, + * and `PreToolUse` argument mutation happens after the round is declared. + * It IS reachable for direct callers — `resolveLoopKeyMaterial` and + * `DoomLoopMonitor` are both exported — and for SDK ports that construct + * key material differently. Both call sites are guarded regardless; this + * test pins the throw itself so a future refactor cannot quietly remove + * the need for those guards. + */ + const throwingGetter = {} as Record; + Object.defineProperty(throwingGetter, 'command', { + enumerable: true, + get() { + throw new Error('getter boom'); + }, + }); + expect(() => + resolveLoopKeyMaterial( + [ + 'command', + ], + throwingGetter, + ), + ).toThrow('getter boom'); + + const hostileProxy = new Proxy( + {}, + { + has() { + throw new Error('has boom'); + }, + }, + ) as Record; + expect(() => + resolveLoopKeyMaterial( + [ + 'command', + ], + hostileProxy, + ), + ).toThrow('has boom'); + }); }); // --------------------------------------------------------------------------- From 1afde413053bca7768bc4495e8df9e6cbc8386fb Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:00:29 -0500 Subject: [PATCH 11/32] docs(agent): correct the undeclared-round semantics claim; add a declareRound example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Devin traced that undeclared multi-call rounds do NOT behave "the same as before", and the trace is right. Verified: undeclared [a,b] x6: b -> 1, 2 observe, 3 block, 4, 5, 6 stop order flipped: the verdict moves to the OTHER call [a,b],[c,b],[d,b]: b -> 3 block Each call of an undeclared round overwrites `roundFingerprints` with its own singleton, so the next round's matching call compares against the previous round's LAST recorded fingerprint. A repeating undeclared fan-out therefore does accumulate — on whichever member lands last, order-dependently — and it reaches `stop`. The changeset, the README limit, and a source comment all claimed such fan-outs go undetected. Corrected all three, and added a test pinning the real behavior (including that a repeat inside a VARYING round accumulates here, which the declared path treats as progress). No behavior change: the engine declares every executed batch, so this is the server-tool and direct-caller path only. Also addresses the changeset's missing example for the new public method. While writing it I ran it, and it did not work: `resolveDoomLoopOption` is not exported, so the obvious construction fails at runtime. `DoomLoopMonitor` is exported but a consumer must hand-build the resolved config shape to instantiate it. Rewrote the example to only use exported API and noted the export gap as a follow-up — it predates this PR and is unrelated to `declareRound`. Also dropped the "No API surface changed" line, which was still there from before the bump was raised to minor. Verified: 666 unit tests pass, typecheck and biome clean. --- .changeset/doom-loop-fanout.md | 41 +++++- packages/agent/README.md | 19 ++- packages/agent/src/lib/doom-loop.ts | 15 ++- .../agent/tests/unit/doom-loop-fanout.test.ts | 121 +++++++++++++++++- 4 files changed, 178 insertions(+), 18 deletions(-) diff --git a/.changeset/doom-loop-fanout.md b/.changeset/doom-loop-fanout.md index 62ddd967..714cbfd9 100644 --- a/.changeset/doom-loop-fanout.md +++ b/.changeset/doom-loop-fanout.md @@ -19,8 +19,12 @@ a repeating round reports that round's streak, so at the block rung a repeating fan-out stops spending rather than only its last call being refused. Paths that cannot know a round's membership up front — server-tool records, and -direct/port callers — are scored per call against the previous round, the same -as before this change: a fan-out there goes undetected rather than mis-scored. +direct/port callers — are scored per call against the previous round rather than +as a set. A repeating multi-call round there still accumulates, but only on +whichever call is recorded *last*, so the verdict lands on one member and which +member depends on emission order. Declare the round (see below) for +order-independent, whole-round scoring. + A call that a round's declaration could not include (unhashable key material) is likewise scored on its own, and cannot move the round's counters — one unhashable argument costs detection for its own call only, never for the tool. @@ -58,8 +62,9 @@ round. Exempt such tools with `loopKey: false` (or a `loopKey` returning `null` for the call). This class was invisible to the detector before, so no existing exemption covered it. -No API surface changed — `doomLoop` is configured exactly as before. What -changed is when it fires: +For `callModel` users, nothing to change — `doomLoop` is configured exactly as +before, and the engine declares each round for you. What changed is when it +fires: ```ts import { callModel } from '@openrouter/agent'; @@ -89,3 +94,31 @@ const result = callModel(client, { // `loopKey` still runs exactly once per checked call, and persisted // `ConversationState.doomLoop` is byte-identical to before. ``` + +Driving `DoomLoopMonitor` directly (or porting it) is the case that needs the +new call — declare a round's whole batch before recording any of it: + +```ts +import { DoomLoopMonitor } from '@openrouter/agent'; + +for (const [round, batch] of batches.entries()) { + // NEW: declare the round's complete set BEFORE recording any of its calls. + // Without this a multi-call round is scored per call, so a repeating fan-out + // accumulates only on whichever call is recorded last — and which one that + // is depends on emission order. + await monitor.declareRound( + round, + batch.map((call) => ({ toolName: call.name, keyMaterial: call.arguments })), + ); + + for (const call of batch) { + const { verdict } = await monitor.recordToolCall(call.name, call.arguments, round); + if (verdict?.action === 'block') refuse(call, verdict.message); + } +} +``` + +Note for direct users: `DoomLoopMonitor` is exported but its config resolver is +not, so constructing one outside `callModel` means hand-building the resolved +config shape. That predates this change and is unrelated to `declareRound`; +worth exporting `resolveDoomLoopOption` as a follow-up. diff --git a/packages/agent/README.md b/packages/agent/README.md index 79e2c7ce..a32bd3fe 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -440,12 +440,19 @@ block to observe for a known-chatty tool, or escalate straight to stop. `read(a)` reissued alongside a different second call every round (`[a,b]`, `[a,c]`, `[a,d]`) never accumulates. This is the flip side of "a changed member is progress"; a model retrying one failing call while - probing around it is not caught. -- **Fan-outs on paths that can't declare a round up front.** Server-tool - records (web search, advisor) are recorded as the response streams, so - their round membership isn't known in advance and they fall back to - per-call identity — a repeating server-tool *fan-out* goes unseen, though - a repeated single call still trips. + probing around it is not caught. Applies to *declared* rounds — i.e. + every batch the tool loop executes. +- **Order-dependent scoring where a round can't be declared up front.** + Server-tool records (web search, advisor) arrive as the response streams, + so their membership isn't known in advance and they are scored per call + rather than per set. A repeating multi-call round there still accumulates, + but only on whichever call is recorded *last* — so the verdict lands on one + member, which member depends on emission order, and a repeat inside a + varying round *does* accumulate if it happens to be last + (`[a,b]`, `[c,b]`, `[d,b]` trips on `b`). Server-tool verdicts cannot + `block`, but they can reach `stop`. Client tool calls always go through a + declared round, so this affects server tools and direct + `DoomLoopMonitor` callers only. ### Tool Approval diff --git a/packages/agent/src/lib/doom-loop.ts b/packages/agent/src/lib/doom-loop.ts index 1ca9879b..5a8eef34 100644 --- a/packages/agent/src/lib/doom-loop.ts +++ b/packages/agent/src/lib/doom-loop.ts @@ -1145,9 +1145,18 @@ export class DoomLoopMonitor { * this round happened to be recorded first. Every call sharing a declared * set therefore scores the same streak by construction: a repeating * fan-out is one piece of evidence per round, and the ladder applies to - * the round as a unit rather than to one member. Calls outside a - * declaration carry their own single-fingerprint set through the same - * formula, which is the per-call comparison. + * the round as a unit rather than to one member. + * + * Calls outside a declaration carry their own single-fingerprint set + * through the same formula. For a single-call round that is exactly the + * old per-call comparison. For an UNDECLARED multi-call round it is not: + * each call overwrites `roundFingerprints` with its own singleton, so the + * next round's matching call compares against the previous round's LAST + * recorded fingerprint. A repeating undeclared fan-out therefore does + * accumulate, but only on whichever member lands last, and which member + * that is depends on emission order. That is why the engine declares every + * executed batch; the undeclared path exists for records whose membership + * cannot be known up front (server tools) and for direct callers. */ const streak = priorRoundFingerprints !== undefined && setsMatch(priorRoundFingerprints, roundFingerprints) diff --git a/packages/agent/tests/unit/doom-loop-fanout.test.ts b/packages/agent/tests/unit/doom-loop-fanout.test.ts index 5f6fc4ef..86440a5a 100644 --- a/packages/agent/tests/unit/doom-loop-fanout.test.ts +++ b/packages/agent/tests/unit/doom-loop-fanout.test.ts @@ -383,11 +383,14 @@ describe('same-tool fan-out streaks', () => { * The engine declares every executed batch, but server-tool records go * through `checkDoomLoopForResponse` undeclared, as do direct callers and * ports. A round's membership is unknowable there, so each call is scored - * on its own identity against the previous round — the pre-fan-out - * semantics. Sharing a round streak here instead let a brand-new call - * inherit an earlier call's count: `[a]` then `[a, b]` scored `b` as a - * 2-round repeat and emitted a verdict quoting `b`'s own fingerprint, - * for a call the model had just made for the first time. + * on its own identity rather than as part of a set. Sharing a round streak + * here instead let a brand-new call inherit an earlier call's count: `[a]` + * then `[a, b]` scored `b` as a 2-round repeat and emitted a verdict + * quoting `b`'s own fingerprint, for a call the model had just made for + * the first time. + * + * Note this is NOT identical to the pre-fan-out per-call comparison for + * multi-call rounds — see the order-dependence test below. */ const detector = monitor(); await detector.recordToolCall( @@ -419,6 +422,114 @@ describe('same-tool fan-out streaks', () => { expect(fresh.verdict).toBeUndefined(); }); + it('scores an UNDECLARED multi-call round on its last member, order-dependently', async () => { + /* + * Pins the undeclared path's real semantics, because they are NOT the + * pre-fan-out per-call comparison for a multi-call round, and the docs + * previously claimed they were. + * + * Each call of an undeclared round overwrites `roundFingerprints` with its + * own singleton, so the next round's matching call compares against the + * previous round's LAST recorded fingerprint. A repeating undeclared + * fan-out therefore accumulates on whichever member lands last, and + * flipping the emission order moves the verdict to a different call. + * + * Consequences worth pinning: it reaches `stop` at the default ladder's + * streak 6 (server-tool verdicts cannot `block`, but they can stop a run), + * and a repeat inside a *varying* round accumulates here even though the + * declared path treats that as progress. + */ + const undeclared = async (rounds: readonly (readonly string[])[]): Promise => { + const detector = monitor(); + const out: string[][] = []; + for (const [round, paths] of rounds.entries()) { + const scored: string[] = []; + for (const path of paths) { + const record = await detector.recordToolCall( + 'read', + { + path, + }, + round, + ); + scored.push(`${record.streak}:${record.verdict?.action ?? 'none'}`); + } + out.push(scored); + } + return out; + }; + + /* The last member accumulates; the first does not. */ + expect( + await undeclared([ + [ + 'a', + 'b', + ], + [ + 'a', + 'b', + ], + ]), + ).toEqual([ + [ + '1:none', + '1:none', + ], + [ + '1:none', + '2:observe', + ], + ]); + + /* Flipping the order moves the verdict onto the other call. */ + expect( + await undeclared([ + [ + 'a', + 'b', + ], + [ + 'b', + 'a', + ], + ]), + ).toEqual([ + [ + '1:none', + '1:none', + ], + [ + '2:observe', + '1:none', + ], + ]); + + /* A varying round still accumulates on the stable last member. */ + expect( + ( + await undeclared([ + [ + 'a', + 'b', + ], + [ + 'c', + 'b', + ], + [ + 'd', + 'b', + ], + ]) + ).map((round) => round.at(-1)), + ).toEqual([ + '1:none', + '2:observe', + '3:block', + ]); + }); + it('does not let a call outside the declared set inherit the round streak', async () => { /* * `declareRound` drops a call whose key material is unhashable (bigint, From bb1e57bf4e6c03157aaa0d7e2b3a42350cf92dc9 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:01:14 -0500 Subject: [PATCH 12/32] feat(agent): export resolveDoomLoopOption so DoomLoopMonitor is constructible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DoomLoopMonitor` was exported without its config resolver, so the natural construction — `new DoomLoopMonitor(resolveDoomLoopOption(true))` — failed at runtime for any consumer: `resolveDoomLoopOption` and `ResolvedDoomLoopConfig` existed only at module level. The class was effectively unusable outside `callModel` short of hand-building the internal resolved-config shape. Found by executing the changeset's usage example instead of eyeballing it. Exports `resolveDoomLoopOption` (value) and `ResolvedDoomLoopConfig` (type) from the package entrypoint, and adds a consumer-contract test file that imports from `src/index.js` only — construction with defaults, a custom ladder, fan-out detection via declareRound, and a JSON state round-trip across a simulated process boundary. Changeset example updated to match and the follow-up note removed, since this was that follow-up. Already covered by the existing minor bump. Verified: 669 unit tests pass (3 new), typecheck and biome clean. --- .changeset/doom-loop-fanout.md | 14 +- packages/agent/src/index.ts | 2 + .../tests/unit/doom-loop-public-api.test.ts | 124 ++++++++++++++++++ 3 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 packages/agent/tests/unit/doom-loop-public-api.test.ts diff --git a/.changeset/doom-loop-fanout.md b/.changeset/doom-loop-fanout.md index 714cbfd9..8f571cd6 100644 --- a/.changeset/doom-loop-fanout.md +++ b/.changeset/doom-loop-fanout.md @@ -96,10 +96,15 @@ const result = callModel(client, { ``` Driving `DoomLoopMonitor` directly (or porting it) is the case that needs the -new call — declare a round's whole batch before recording any of it: +new call — declare a round's whole batch before recording any of it. +`resolveDoomLoopOption` and `ResolvedDoomLoopConfig` are now exported too: +`DoomLoopMonitor` was previously exported without its config resolver, so it +could not actually be constructed from the public API. ```ts -import { DoomLoopMonitor } from '@openrouter/agent'; +import { DoomLoopMonitor, resolveDoomLoopOption } from '@openrouter/agent'; + +const monitor = new DoomLoopMonitor(resolveDoomLoopOption(true)); for (const [round, batch] of batches.entries()) { // NEW: declare the round's complete set BEFORE recording any of its calls. @@ -117,8 +122,3 @@ for (const [round, batch] of batches.entries()) { } } ``` - -Note for direct users: `DoomLoopMonitor` is exported but its config resolver is -not, so constructing one outside `callModel` means hand-building the resolved -config shape. That predates this change and is unrelated to `declareRound`; -worth exporting `resolveDoomLoopOption` as a follow-up. diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 6f791d22..9a74ceaf 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -122,6 +122,7 @@ export type { DoomLoopTextOptions, DoomLoopVerdict, LoopKeyResolution, + ResolvedDoomLoopConfig, TextRepetitionResult, } from './lib/doom-loop.js'; export { @@ -133,6 +134,7 @@ export { fingerprintKeyMaterial, fingerprintToolCall, MAX_CANONICALIZE_DEPTH, + resolveDoomLoopOption, resolveLoopKeyMaterial, } from './lib/doom-loop.js'; // Lifecycle hooks system (PreToolUse, PostToolUse, Stop, SessionStart, ...). diff --git a/packages/agent/tests/unit/doom-loop-public-api.test.ts b/packages/agent/tests/unit/doom-loop-public-api.test.ts new file mode 100644 index 00000000..9b10f5ca --- /dev/null +++ b/packages/agent/tests/unit/doom-loop-public-api.test.ts @@ -0,0 +1,124 @@ +/** + * Consumer-facing contract for driving `DoomLoopMonitor` directly: everything + * here imports from the package entrypoint ONLY, exactly as an npm consumer + * or an SDK port would. `DoomLoopMonitor` was exported without + * `resolveDoomLoopOption`, so the documented construction failed at runtime + * and the class was unusable outside `callModel` — the changeset example was + * only caught because it was executed rather than eyeballed. + */ +import { describe, expect, it } from 'vitest'; + +import { + DEFAULT_DOOM_LOOP_LADDER, + DoomLoopMonitor, + resolveDoomLoopOption, +} from '../../src/index.js'; + +describe('DoomLoopMonitor via the public entrypoint', () => { + it('constructs with defaults, detects a repeating fan-out, and honors the exported ladder', async () => { + const monitor = new DoomLoopMonitor(resolveDoomLoopOption(true)); + const paths = [ + 'a', + 'b', + 'c', + ]; + + let verdictAction: string | undefined; + let verdictStreak: number | undefined; + for (const round of [ + 0, + 1, + 2, + ]) { + await monitor.declareRound( + round, + paths.map((path) => ({ + toolName: 'read', + keyMaterial: { + path, + }, + })), + ); + for (const path of paths) { + const record = await monitor.recordToolCall( + 'read', + { + path, + }, + round, + ); + if (record.verdict) { + verdictAction = record.verdict.action; + verdictStreak = record.verdict.streak; + } + } + } + + /* Third identical round crosses the exported default block threshold. */ + expect(verdictStreak).toBe(DEFAULT_DOOM_LOOP_LADDER.block); + expect(verdictAction).toBe('block'); + }); + + it('accepts a config object and honors a custom ladder', async () => { + const monitor = new DoomLoopMonitor( + resolveDoomLoopOption({ + ladder: { + observe: 2, + block: false, + stop: 4, + }, + }), + ); + + let last: string | undefined; + for (const round of [ + 0, + 1, + 2, + 3, + ]) { + const record = await monitor.recordToolCall( + 'search', + { + q: 'same', + }, + round, + ); + last = record.verdict?.action; + } + /* block disabled; the streak of 4 reaches the custom stop rung. */ + expect(last).toBe('stop'); + }); + + it('round-trips state across a process boundary via plain JSON', async () => { + const first = new DoomLoopMonitor(resolveDoomLoopOption(true)); + await first.recordToolCall( + 'search', + { + q: 'same', + }, + 0, + ); + await first.recordToolCall( + 'search', + { + q: 'same', + }, + 1, + ); + + /* Simulate the serverless pattern: serialize, "new process", restore. */ + const wire = JSON.stringify(first.getState()); + const second = new DoomLoopMonitor(resolveDoomLoopOption(true), JSON.parse(wire)); + const resumed = await second.recordToolCall( + 'search', + { + q: 'same', + }, + 0, + ); + + /* Single-call streak continues across the boundary: 2 -> 3. */ + expect(resumed.streak).toBe(3); + }); +}); From fc79004c1af338176de8165bab563fd6e6d2061f Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:17:40 -0500 Subject: [PATCH 13/32] refactor(agent): collapse recordToolCall scoring to one rule over two identities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behavior-identical simplification of the scoring path — all 669 tests pass unchanged, including the 20 fan-out regressions that were each verified to fail against the bug they cover. The branching had accreted one special case per bug fix: a member/non-member fork for the reported streak, a second fork recomputing the stored round streak, a third choosing the persisted identity, each with its own baseline reads. All of it reduces to a single scoring rule applied to two identities: score(set) = baseline matches set ? priorStreak + 1 : 1 reported streak = score(callSet) callSet = declared set if member, else the call's singleton stored round state = score(roundSet) roundSet = declared set if one exists, else the call's singleton The baseline (previous round's set + streak) is fixed at the round transition and read once. Every non-member rule from the last several commits falls out of the callSet/roundSet distinction instead of being its own branch: a non-member scores 1 because its singleton is not the baseline; it cannot clobber the round because roundSet prefers the declaration; the round transition still advances because the baseline write is unconditional; the persisted identity guard is the one remaining explicit special case. Net -70 lines in the hot path. The verdict message now derives from callSet, which is what the call was actually scored with (same value as before in every reachable case). Also spot-checked beyond the suite: non-member-first ordering across four rounds, in-round duplicate handling, and the resume identity pairing all produce byte-identical traces to the pre-refactor code. --- packages/agent/src/lib/doom-loop.ts | 168 ++++++++-------------------- 1 file changed, 49 insertions(+), 119 deletions(-) diff --git a/packages/agent/src/lib/doom-loop.ts b/packages/agent/src/lib/doom-loop.ts index 5a8eef34..dc79faee 100644 --- a/packages/agent/src/lib/doom-loop.ts +++ b/packages/agent/src/lib/doom-loop.ts @@ -1076,60 +1076,28 @@ export class DoomLoopMonitor { const isSameRound = previous?.round !== undefined && previous.round === round; /* - * A round's identity for one tool is the *set* of fingerprints it was - * called with, so the streak compares whole rounds rather than last calls. - * - * The set must be the round's COMPLETE membership before any of its calls - * is scored, which only `declareRound` can supply. Without a declaration - * (direct callers, ports, server-tool records) we cannot know the round's - * membership, so we do NOT pretend to: each call is scored on its own - * identity against the previous round, exactly as before the fan-out - * change. Sharing a round streak across an undeclared round would let a - * brand-new call inherit an earlier call's count and report a repeat that - * never happened. - * - * Scoring an incrementally-growing set was the other failure mode: it let - * a superset round transiently match the previous one and block progress. - * - * The `fingerprint` field keeps holding the latest call so persisted state - * and verdict payloads are unchanged. + * The round's declared membership, when the engine announced it (see + * `declareRound`). A declaration only speaks for the calls it contains: a + * call dropped as unhashable still records here via the caller's fallback + * chain, but as a NON-member — it is scored on its own identity and holds + * at streak 1, costing detection for itself only (the fail-open contract). */ const declared = this.declaredRound?.round === round ? this.declaredRound.fingerprints.get(toolName) : undefined; - /* - * A declaration only speaks for the calls it actually contains. A call - * whose key material was unhashable at declaration time was dropped from - * the set, but still resolves an identity at record time via the caller's - * fallback chain — it must not be scored as part of the round, or it would - * inherit the round's count and could be blocked on its first appearance. - * - * KNOWN LIMIT: such a call is compared against its tool's declared set, - * which by construction it is not in, so it never matches and holds at - * streak 1 even when reissued verbatim every round. It costs detection for - * itself only — the round's members keep accumulating — which is the - * fail-open contract. (A tool whose calls are ALL unhashable has no - * declared set at all, so those calls fall through to the ordinary - * per-call comparison and do accumulate.) - */ const declaredMember = declared?.includes(fingerprint) === true; - const roundFingerprints = declaredMember - ? (declared as readonly string[]) - : [ - fingerprint, - ]; const seen = isSameRound ? (previous?.seenThisRound ?? []) : []; const duplicateInRound = seen.includes(fingerprint); /* - * What this call is measured against: the set the PREVIOUS round was - * called with, and the streak that round earned. Carried unchanged for the - * length of the current round, so every call in a round compares against - * the same baseline regardless of the order the calls arrive in. + * The baseline every call of this round is measured against: the set the + * PREVIOUS round was called with and the streak it earned. Fixed at the + * round transition and carried unchanged for the round's length, so + * arrival order within a round can never affect a score. */ - const priorRoundFingerprints = isSameRound + const priorSet = isSameRound ? previous?.priorRoundFingerprints : (previous?.roundFingerprints ?? (previous @@ -1138,89 +1106,51 @@ export class DoomLoopMonitor { ] : undefined)); const priorStreak = isSameRound ? (previous?.priorStreak ?? 0) : (previous?.streak ?? 0); + const score = (set: readonly string[]): number => + priorSet !== undefined && setsMatch(priorSet, set) ? priorStreak + 1 : 1; /* - * A round's streak is a pure function of (this round's set, the previous - * round's set, the streak that round earned) — never of whichever call of - * this round happened to be recorded first. Every call sharing a declared - * set therefore scores the same streak by construction: a repeating - * fan-out is one piece of evidence per round, and the ladder applies to - * the round as a unit rather than to one member. + * Two identities, one scoring rule. * - * Calls outside a declaration carry their own single-fingerprint set - * through the same formula. For a single-call round that is exactly the - * old per-call comparison. For an UNDECLARED multi-call round it is not: - * each call overwrites `roundFingerprints` with its own singleton, so the - * next round's matching call compares against the previous round's LAST - * recorded fingerprint. A repeating undeclared fan-out therefore does - * accumulate, but only on whichever member lands last, and which member - * that is depends on emission order. That is why the engine declares every - * executed batch; the undeclared path exists for records whose membership - * cannot be known up front (server tools) and for direct callers. + * What this CALL reports: the declared set when it is a member (every + * member of a repeating fan-out shares the round's streak, so the ladder + * applies to the round as a unit), its own singleton otherwise. On the + * undeclared path the singleton comparison is the pre-fan-out per-call + * semantics for single-call rounds; for undeclared MULTI-call rounds it + * accumulates only on whichever call lands last (see the README limit). + * + * What the ROUND stores: the declared set when there is one — a + * non-member must not overwrite the round's identity with its singleton, + * or the next round's members would compare against it and reset forever. */ - const streak = - priorRoundFingerprints !== undefined && setsMatch(priorRoundFingerprints, roundFingerprints) - ? priorStreak + 1 - : 1; + const callSet = declaredMember + ? (declared as readonly string[]) + : [ + fingerprint, + ]; + const streak = score(callSet); + const roundSet = declared ?? [ + fingerprint, + ]; - /* - * Round-level bookkeeping belongs to the round's declared set, so a call - * that is NOT part of the declaration must not write it: overwriting - * `roundFingerprints` with its own singleton would leave the next round's - * members comparing against that singleton and resetting to 1 forever, so - * one unhashable argument would disable detection for the tool for the - * rest of the run. Non-members still record `fingerprint`/`seenThisRound` - * (identity for persistence, and in-round dedupe) and still receive their - * own per-call verdict — they just cannot move the round's counters. - */ - const isNonMemberOfDeclaredRound = declared !== undefined && !declaredMember; - /* - * A non-member does not define the round, so the round's own set/streak - * stay unset by it — but the round TRANSITION still has to be recorded, or - * the baseline would never advance. `priorRoundFingerprints`/`priorStreak` - * are therefore written from the same computation every call uses; only - * `roundFingerprints` and `streak` (the round's identity and its score) - * are withheld, to be filled in by the round's first declared member. - */ - const roundState = isNonMemberOfDeclaredRound - ? { - ...(declared !== undefined - ? { - roundFingerprints: declared, - } - : {}), - streak: - priorRoundFingerprints !== undefined && - setsMatch(priorRoundFingerprints, declared ?? []) - ? priorStreak + 1 - : 1, - } - : { - roundFingerprints, - streak, - }; - /* - * `fingerprint` is the identity that PAIRS with `streak` in persisted state - * (see `getState`/`restore`), so a non-member must not overwrite it: the - * saved count would then be attached to a call that did not earn it. On - * resume that call matched, inherited the count, and was refused on its - * first appearance — while the genuinely repeating call, no longer the - * saved identity, reset to 1 and lost its evidence. Keep the last DECLARED - * member's fingerprint instead; a non-member is still tracked for in-round - * dedupe via `seenThisRound`. - */ - const identityFingerprint = - isNonMemberOfDeclaredRound && previous?.fingerprint !== undefined - ? previous.fingerprint - : fingerprint; this.tools.set(toolName, { - fingerprint: identityFingerprint, + /* + * The identity that pairs with `streak` in persisted state: a non-member + * must not become it, or the saved count would attach to a call that + * never earned it (blocked on first appearance after a resume, while the + * real repeat lost its evidence). + */ + fingerprint: + declared !== undefined && !declaredMember && previous !== undefined + ? previous.fingerprint + : fingerprint, round, seenThisRound: mergeFingerprint(seen, fingerprint), - ...roundState, - ...(priorRoundFingerprints !== undefined + roundFingerprints: roundSet, + streak: score(roundSet), + ...(priorSet !== undefined ? { - priorRoundFingerprints, + priorRoundFingerprints: priorSet, } : {}), priorStreak, @@ -1257,10 +1187,10 @@ export class DoomLoopMonitor { * call count instead of one member's hash. */ message: - roundFingerprints.length > 1 + callSet.length > 1 ? `Doom loop suspected: tool "${toolName}" was invoked in ${streak} consecutive rounds ` + - `with the same set of ${roundFingerprints.length} parallel calls ` + - `(round identity ${summarizeRound(roundFingerprints)}). Reissuing the same fan-out ` + + `with the same set of ${callSet.length} parallel calls ` + + `(round identity ${summarizeRound(callSet)}). Reissuing the same fan-out ` + 'will not change the results. Take a different approach, or explain why repetition is required.' : `Doom loop suspected: tool "${toolName}" was invoked in ${streak} consecutive rounds ` + `with identical arguments (fingerprint ${fingerprint.slice(0, 16)}…). Repeating the call ` + From b864967e71f3668ba76cbe858e567acf32f596e1 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:34:11 -0500 Subject: [PATCH 14/32] feat(agent): persist a fan-out's round set so its streak survives save/resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persisted shape carried one (fingerprint, streak) per tool, which cannot say WHICH set earned a count. That forced a choice between two failure modes, and this PR had cycled through both: persist the streak verbatim and a resumed subset call inherits a fan-out's whole evidence (blocked on first appearance — the I1 false positive); persist 1 and the evidence is discarded at every save. Devin's last two passes showed the second mode is worse than the changeset admitted: saveStateSafely snapshots on every persist, so an approval/HITL pause reset a fan-out sitting at the block rung, and per-turn-resume topologies (one callModel per user turn — the serverless pattern) never accumulated at all. A multi-call round now persists its full fingerprint set (optional `roundFingerprints` on `DoomLoopStreak`, additive). The streak travels with the exact set that earned it, so both failure modes are gone rather than traded: per-turn resume, identical 3-call fan-out: 1 -> 2:observe -> 3:block -> 4 (was 1,1,1,1) pause at block rung, resume, repeat: 4:block (was reset) resume with a SUBSET of the saved set: 1, no verdict (unchanged) Compatibility: single-call rounds omit the field (their fingerprint fully describes the round), pre-existing blobs restore with their old semantics, and a malformed persisted set (non-string entries) degrades to the lone fingerprint instead of dropping the entry. Text streaks never carry it. Also overloads resolveDoomLoopOption so `new DoomLoopMonitor( resolveDoomLoopOption(true))` — the changeset's own example — compiles under strict TS: a `true`/config argument now types as non-null, while the engine's pass-through of a raw caller option keeps the nullable signature. Devin flagged the example as non-compiling; verified with a strict-mode tsc run before and after. Tests: the two resume tests now pin continuation instead of the old downgrade (both fail against the previous commit), a public-API test drives the serverless per-turn pattern end to end through JSON, and a legacy/hostile-blob test pins backward compatibility. 671 unit tests pass, typecheck and biome clean. --- .changeset/doom-loop-fanout.md | 30 +++--- packages/agent/src/lib/doom-loop.ts | 77 ++++++++++----- .../agent/tests/unit/doom-loop-fanout.test.ts | 70 +++++++------ .../tests/unit/doom-loop-public-api.test.ts | 98 +++++++++++++++++++ 4 files changed, 203 insertions(+), 72 deletions(-) diff --git a/.changeset/doom-loop-fanout.md b/.changeset/doom-loop-fanout.md index 8f571cd6..02b76168 100644 --- a/.changeset/doom-loop-fanout.md +++ b/.changeset/doom-loop-fanout.md @@ -29,14 +29,16 @@ A call that a round's declaration could not include (unhashable key material) is likewise scored on its own, and cannot move the round's counters — one unhashable argument costs detection for its own call only, never for the tool. -**Resumed runs**: a *multi-call* round's streak now restarts after a -save/resume. The persisted shape carries one fingerprint per tool and cannot -express which set earned a count, so keeping it would attach a fan-out's -evidence to whichever member was recorded last — a resumed round of just that -one call would then be refused on its first appearance, despite the model doing -less work than before the save. A repeating fan-out is re-detected from a clean -baseline instead (observe at the second repeat after resume). Single-call -streaks still continue across a resume exactly as before. +**Resumed runs**: a multi-call round's fingerprint set is persisted alongside +its streak (a new optional `roundFingerprints` on `DoomLoopStreak` — additive; +pre-existing blobs restore with their old single-call semantics). A repeating +fan-out therefore keeps its evidence across save/resume boundaries: approval +pauses no longer reset a fan-out sitting at the block rung, and per-turn-resume +topologies (one `callModel` per user turn, state persisted between) accumulate +across turns instead of re-baselining on every one. Because the streak travels +with the exact set that earned it, a resumed round containing only a subset of +that set is a different round and starts at 1 — a lesser call can never inherit +a fan-out's evidence. Single-call streaks behave exactly as before. **New API**: `DoomLoopMonitor.declareRound(round, calls)` — declares a round's complete call set before any of it is scored. `DoomLoopMonitor` is exported, so @@ -45,9 +47,10 @@ touch it (the engine calls it); direct `DoomLoopMonitor` users and SDK ports should, since an undeclared multi-call round falls back to per-call scoring and its fan-out goes undetected. -Single-call round timing, in-round duplicate collapsing, persisted state -*shape*, verdict payloads, and the number of times a tool's `loopKey` is invoked -(once per checked call) are unchanged. +Single-call round timing, in-round duplicate collapsing, verdict payloads, and +the number of times a tool's `loopKey` is invoked (once per checked call) are +unchanged. The persisted shape gains one optional field (`roundFingerprints`, +above); everything existing is untouched and old blobs restore cleanly. Known limit, unchanged by this fix: a call that repeats inside a round whose other members keep varying (`[a,b]`, `[a,c]`, `[a,d]`) does not accumulate, @@ -91,8 +94,9 @@ const result = callModel(client, { // A round that ADDS work is progress and resets to 1: // round 3: read(a), read(b), read(c), read(d) <- no verdict // -// `loopKey` still runs exactly once per checked call, and persisted -// `ConversationState.doomLoop` is byte-identical to before. +// `loopKey` still runs exactly once per checked call. Persisted state gains +// one optional field so fan-out streaks survive save/resume; old state +// restores cleanly. ``` Driving `DoomLoopMonitor` directly (or porting it) is the case that needs the diff --git a/packages/agent/src/lib/doom-loop.ts b/packages/agent/src/lib/doom-loop.ts index dc79faee..d1369224 100644 --- a/packages/agent/src/lib/doom-loop.ts +++ b/packages/agent/src/lib/doom-loop.ts @@ -193,6 +193,17 @@ export type DoomLoopOption = boolean | DoomLoopConfig; export interface DoomLoopStreak { fingerprint: string; streak: number; + /** + * The full fingerprint set of the tool's last round, present only when that + * round had more than one distinct call. `fingerprint`+`streak` alone cannot + * say WHICH set earned a count, so without this a fan-out streak either + * attached to one arbitrary member (refusing a lesser resumed call on first + * appearance) or had to be discarded at every save — losing block-level + * evidence across approval pauses and re-baselining on every per-turn + * resume. Absent for single-call rounds and in pre-existing blobs, where + * `fingerprint` fully describes the round. Never present on text streaks. + */ + roundFingerprints?: readonly string[]; } /** @@ -351,7 +362,16 @@ function warnOnLadderHazards(ladder: Required): void { /** * Normalize the `doomLoop` option. `undefined` / `false` → null (detection * off — the SDK's default posture: explicit control over implicit magic). + * + * Overloaded so the direct-construction pattern compiles without a null + * check: `true` or a config object always yields a resolved config, so + * `new DoomLoopMonitor(resolveDoomLoopOption(true))` is well-typed. Only the + * engine's pass-through of a caller's raw option can produce `null`. */ +export function resolveDoomLoopOption(option: DoomLoopConfig | true): ResolvedDoomLoopConfig; +export function resolveDoomLoopOption( + option: DoomLoopOption | undefined, +): ResolvedDoomLoopConfig | null; export function resolveDoomLoopOption( option: DoomLoopOption | undefined, ): ResolvedDoomLoopConfig | null { @@ -951,21 +971,22 @@ export class DoomLoopMonitor { name, { fingerprint: entry.fingerprint, + streak: entry.streak, /* - * A MULTI-CALL round's streak is persisted as 1, because the shape - * carries one fingerprint and cannot express "this count was earned - * by the set {a,b,c}". Restoring it verbatim would attach the whole - * count to whichever call happened to be recorded last, so a - * resumed round consisting of just that one call would inherit a - * fan-out's evidence and could be refused on its first appearance — - * despite the model doing strictly LESS work than before the save. - * Under-counting on resume is the safe direction: the round is - * re-observed and re-accumulates from a correct baseline. - * - * Single-call rounds are unaffected: their fingerprint fully - * describes the round, so the streak survives intact. + * A multi-call round persists its full set, so a resumed run knows + * WHICH set earned the count. Without it, the streak either + * attached to one arbitrary member — a resumed round of just that + * call inherited a fan-out's whole evidence and could be refused + * on its first appearance — or had to be discarded at every save, + * which reset condemned fan-outs across approval pauses and made + * per-turn-resume topologies never accumulate. Single-call rounds + * omit it: their fingerprint fully describes the round. */ - streak: (entry.roundFingerprints?.length ?? 1) > 1 ? 1 : entry.streak, + ...((entry.roundFingerprints?.length ?? 0) > 1 + ? { + roundFingerprints: entry.roundFingerprints, + } + : {}), }, ]), ), @@ -997,21 +1018,31 @@ export class DoomLoopMonitor { if (typeof candidate.tools === 'object' && candidate.tools !== null) { for (const [name, entry] of Object.entries(candidate.tools)) { if (isValidStreak(entry)) { + /* + * The persisted set (when the last round was multi-call) restores + * the round's identity exactly, so a resumed fan-out continues its + * streak and a resumed SUBSET cannot match it — the false-positive + * and false-negative failure modes of a single-fingerprint save. + * Older blobs (and single-call rounds) carry no set; the lone + * fingerprint fully describes those rounds. Entries with a + * malformed set fall back the same way rather than being dropped. + */ + const persistedSet = + Array.isArray(entry.roundFingerprints) && + entry.roundFingerprints.length > 1 && + entry.roundFingerprints.every((value) => typeof value === 'string') + ? [ + ...entry.roundFingerprints, + ].sort() + : [ + entry.fingerprint, + ]; tools.set(name, { fingerprint: entry.fingerprint, streak: entry.streak, // `round` intentionally absent: the first resumed record is // always a new round, so it increments whatever the numbering. - // - // The round set is run-local, so only one fingerprint survives a - // save. A resumed round therefore compares against that single - // fingerprint: a single-call streak continues seamlessly, and a - // FAN-OUT streak restarts — enforced at save time by `getState`, - // which persists a multi-call round's streak as 1 rather than - // letting its last call carry the whole count into the next run. - roundFingerprints: [ - entry.fingerprint, - ], + roundFingerprints: persistedSet, }); } } diff --git a/packages/agent/tests/unit/doom-loop-fanout.test.ts b/packages/agent/tests/unit/doom-loop-fanout.test.ts index 86440a5a..e7829300 100644 --- a/packages/agent/tests/unit/doom-loop-fanout.test.ts +++ b/packages/agent/tests/unit/doom-loop-fanout.test.ts @@ -975,7 +975,14 @@ describe('same-tool fan-out streaks', () => { expect(messages[0]).toContain('3 parallel calls'); }); - it('restarts a resumed FAN-OUT streak at 1, unlike a single-call streak', async () => { + it('continues a resumed FAN-OUT streak, exactly like a single-call streak', async () => { + /* + * The round SET is persisted (when multi-call), so a doom loop spanning a + * serialize/resume boundary is still a doom loop: the resumed identical + * fan-out picks its count back up instead of getting a fresh grace window. + * Losing this across every save meant approval pauses reset condemned + * fan-outs and per-turn-resume topologies never accumulated at all. + */ const detector = monitor(); for (const round of [ 0, @@ -1005,15 +1012,9 @@ describe('same-tool fan-out streaks', () => { } } - /* - * Only the last fingerprint survives serialization, so the round SET is - * lost across a resume and the fan-out starts over — a doom loop spanning - * a serialize/resume boundary gets a fresh grace window before it trips - * again. Pinned deliberately: the round set is run-local by design - * (persisting it would change the state shape), and the single-call case - * above shows the contrast. - */ - const resumed = new DoomLoopMonitor(resolveDoomLoopOption(true), detector.getState()); + /* JSON round-trip: the set must survive real serialization. */ + const wire = JSON.parse(JSON.stringify(detector.getState())) as unknown; + const resumed = new DoomLoopMonitor(resolveDoomLoopOption(true), wire); await resumed.declareRound( 0, [ @@ -1034,22 +1035,19 @@ describe('same-tool fan-out streaks', () => { 0, ); - expect(record.streak).toBe(1); + /* Streak was 2 at save; the identical resumed fan-out continues to 3. */ + expect(record.streak).toBe(3); + expect(record.verdict?.action).toBe('block'); }); it('does not refuse a resumed SINGLE call that inherits a fan-out streak', async () => { /* - * The persisted shape holds one fingerprint per tool, so it cannot express - * "this count was earned by the set {a,b,c}". Restoring a fan-out's streak - * verbatim attached the whole count to whichever call was recorded last: - * a resumed round consisting of just that one call then matched, inherited - * the fan-out's evidence, and was BLOCKED on its first appearance — while - * the model had done strictly less work than before the save. It was also - * arbitrary, since it depended on which member happened to be last. - * - * `getState` therefore persists a multi-call round's streak as 1. - * Under-counting on resume is the safe direction; the loop is re-observed - * and re-accumulates from a correct baseline (asserted below). + * The streak persists together with the SET that earned it, so a resumed + * round consisting of only one member of that set is a different round — + * it cannot match the persisted identity and scores 1. Persisting the + * count against a single fingerprint instead attached a fan-out's whole + * evidence to whichever call was recorded last: a lesser resumed call was + * BLOCKED on its first appearance, arbitrarily by emission order. */ const detector = monitor(); const paths = [ @@ -1080,19 +1078,18 @@ describe('same-tool fan-out streaks', () => { ); } } - /* Round 1 reached observe (streak 2) before the save. */ - expect( - ( - detector.getState() as { - tools: Record< - string, - { - streak: number; - } - >; + /* The streak survives the save, paired with its full set. */ + const saved = detector.getState() as { + tools: Record< + string, + { + streak: number; + roundFingerprints?: string[]; } - ).tools.read.streak, - ).toBe(1); + >; + }; + expect(saved.tools.read.streak).toBe(2); + expect(saved.tools.read.roundFingerprints).toHaveLength(3); /* Resume with ONE call — the same one that was recorded last. */ const resumed = new DoomLoopMonitor(resolveDoomLoopOption(true), detector.getState()); @@ -1111,8 +1108,9 @@ describe('same-tool fan-out streaks', () => { }, 0, ); - expect(solo.streak).toBe(2); - expect(solo.verdict?.action).not.toBe('block'); + /* A subset is a different round: no inherited evidence, no refusal. */ + expect(solo.streak).toBe(1); + expect(solo.verdict).toBeUndefined(); /* Detection is not lost: a repeating fan-out trips again after the resume. */ const afterResume: number[] = []; diff --git a/packages/agent/tests/unit/doom-loop-public-api.test.ts b/packages/agent/tests/unit/doom-loop-public-api.test.ts index 9b10f5ca..d4a570a3 100644 --- a/packages/agent/tests/unit/doom-loop-public-api.test.ts +++ b/packages/agent/tests/unit/doom-loop-public-api.test.ts @@ -90,6 +90,104 @@ describe('DoomLoopMonitor via the public entrypoint', () => { expect(last).toBe('stop'); }); + it('accumulates a fan-out streak across per-turn process boundaries', async () => { + /* + * The serverless pattern: one round per callModel run, state persisted + * between turns. A repeating fan-out must accumulate across those + * boundaries — this is why the round's fingerprint SET is persisted. + * Before that, each resume re-baselined the fan-out and a loop that + * repeated once per turn never tripped anything. + */ + const paths = [ + 'a', + 'b', + 'c', + ]; + let wire: unknown; + const perTurn: string[] = []; + for (let turn = 0; turn < 4; turn++) { + const monitor = new DoomLoopMonitor(resolveDoomLoopOption(true), wire); + await monitor.declareRound( + 0, + paths.map((path) => ({ + toolName: 'read', + keyMaterial: { + path, + }, + })), + ); + let last = ''; + for (const path of paths) { + const record = await monitor.recordToolCall( + 'read', + { + path, + }, + 0, + ); + last = `${record.streak}:${record.verdict?.action ?? 'none'}`; + } + perTurn.push(last); + wire = JSON.parse(JSON.stringify(monitor.getState())); + } + + expect(perTurn).toEqual([ + '1:none', + '2:observe', + '3:block', + '4:block', + ]); + }); + + it('restores a legacy blob (no roundFingerprints) with single-call semantics', async () => { + /* + * Pre-existing persisted state has no `roundFingerprints` field. It must + * restore exactly as before: the lone fingerprint describes the round, a + * different call resets to 1, the same call continues. Malformed sets + * (non-string entries) degrade the same way instead of being dropped. + */ + const legacy = { + tools: { + read: { + fingerprint: 'not-a-real-fingerprint', + streak: 2, + }, + }, + }; + const monitor = new DoomLoopMonitor(resolveDoomLoopOption(true), legacy); + const different = await monitor.recordToolCall( + 'read', + { + path: 'x', + }, + 0, + ); + expect(different.streak).toBe(1); + + const hostile = { + tools: { + read: { + fingerprint: 'ab', + streak: 2, + roundFingerprints: [ + 1, + {}, + null, + ], + }, + }, + }; + const survives = new DoomLoopMonitor(resolveDoomLoopOption(true), hostile); + const record = await survives.recordToolCall( + 'read', + { + path: 'x', + }, + 0, + ); + expect(record.streak).toBe(1); + }); + it('round-trips state across a process boundary via plain JSON', async () => { const first = new DoomLoopMonitor(resolveDoomLoopOption(true)); await first.recordToolCall( From 5860b26fef536a32620ecda4c6f79afa7209291f Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:35:44 -0500 Subject: [PATCH 15/32] docs(agent): state the declared-must-be-recorded invariant at the declaration site Devin verified all three record-skip paths are mirrored by the declaration's filters (isAutoResolvableTool, hookDeniedCalls, loopKey exemption) and flagged the coupling as fragile: a future short-circuit added between declaration and the checkpoint would silently degrade fan-out detection for that tool. The invariant, its failure mode, and the mirror list are now stated where the next edit will happen, pointing at the regression test that pins the consequence. --- packages/agent/src/lib/model-result.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index 255c8d9e..81bf974f 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -1314,6 +1314,21 @@ export class ModelResult< * execution path skips them with the same `isAutoResolvableTool` predicate * used here. Their absence from the declared set is also correct on its own * terms — they are not evidence, so they are not part of the round. + * + * INVARIANT — every declared member must eventually be recorded. A call + * that is declared but never reaches the doom-loop checkpoint is a phantom + * member of the round's identity: the tool's streak silently resets the + * moment the phantom stops being emitted (pinned by the "declared-but- + * never-recorded member" test in doom-loop-fanout.test.ts). The filters + * below therefore MIRROR every path that skips recording — unknown/manual + * tools (`isAutoResolvableTool`, also gating executeAutoApproveTools and + * the approval-resume loop), `hookDeniedCalls` (consumed before the + * checkpoint in executeSingleToolCall), and `loopKey`-exempt calls (early + * return in checkDoomLoopBeforeExecution; the same resolution is cached + * here so both sides agree). If you add a new short-circuit between + * declaration and the checkpoint — a pre-execution gate, a batch filter — + * it MUST be reflected here, or fan-out detection degrades silently for + * that tool rather than failing loudly. */ private async beginDoomLoopRound(batch: readonly ParsedToolCall[] = []): Promise { const monitor = this.doomLoopMonitor; From fb8ea281791826561e5561d5ea7d755f063b0d2c Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:48:39 -0500 Subject: [PATCH 16/32] fix(agent): copy the round set into getState snapshots instead of aliasing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persisted `roundFingerprints` was the live array — shared with the running streak entry and, for declared rounds, with the round declaration itself — while every other field in the snapshot is a primitive copy and the caller's StateAccessor receives the blob directly. Measured: pushing one element into the saved snapshot silently corrupted the running detector, and the next identical fan-out scored 1:none instead of 3:block for the rest of the run. Copy on write-out. restore() already copied (and sorted) its input, and declareRound's sets are built internally, so getState was the only aliased surface. Regression test mutates a saved snapshot and asserts the live detector still blocks; fails against the previous commit with 1:none. Verified: 672 unit tests pass, typecheck and biome clean. --- packages/agent/src/lib/doom-loop.ts | 10 ++- .../tests/unit/doom-loop-public-api.test.ts | 82 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/packages/agent/src/lib/doom-loop.ts b/packages/agent/src/lib/doom-loop.ts index d1369224..3211424f 100644 --- a/packages/agent/src/lib/doom-loop.ts +++ b/packages/agent/src/lib/doom-loop.ts @@ -981,10 +981,18 @@ export class DoomLoopMonitor { * which reset condemned fan-outs across approval pauses and made * per-turn-resume topologies never accumulate. Single-call rounds * omit it: their fingerprint fully describes the round. + * + * COPIED, not aliased: the in-memory array is shared with the live + * streak entry (and, for declared rounds, with `declaredRound` + * itself), and the snapshot is handed to the caller's + * StateAccessor. An in-place mutation of the saved blob must not + * corrupt the detector still running against it. */ ...((entry.roundFingerprints?.length ?? 0) > 1 ? { - roundFingerprints: entry.roundFingerprints, + roundFingerprints: [ + ...(entry.roundFingerprints as readonly string[]), + ], } : {}), }, diff --git a/packages/agent/tests/unit/doom-loop-public-api.test.ts b/packages/agent/tests/unit/doom-loop-public-api.test.ts index d4a570a3..afb1f078 100644 --- a/packages/agent/tests/unit/doom-loop-public-api.test.ts +++ b/packages/agent/tests/unit/doom-loop-public-api.test.ts @@ -188,6 +188,88 @@ describe('DoomLoopMonitor via the public entrypoint', () => { expect(record.streak).toBe(1); }); + it('isolates the live detector from mutations of a saved snapshot', async () => { + /* + * `getState()` snapshots are handed to the caller's StateAccessor, and the + * multi-call round set used to be the live array — shared with the running + * streak entry and the round declaration. A caller that pushed into (or + * sorted, or spliced) the saved blob silently corrupted the detector for + * the rest of the run: the next identical fan-out compared against the + * mutated set, never matched, and scored 1 instead of block. + */ + const paths = [ + 'a', + 'b', + ]; + const monitor = new DoomLoopMonitor(resolveDoomLoopOption(true)); + for (const round of [ + 0, + 1, + ]) { + await monitor.declareRound( + round, + paths.map((path) => ({ + toolName: 'read', + keyMaterial: { + path, + }, + })), + ); + for (const path of paths) { + await monitor.recordToolCall( + 'read', + { + path, + }, + round, + ); + } + } + + /* A careless (or hostile) caller mutates the saved snapshot in place. */ + const saved = monitor.getState() as { + tools: Record< + string, + { + roundFingerprints?: string[]; + } + >; + }; + saved.tools.read.roundFingerprints?.push('INJECTED'); + + /* The live detector must be unaffected: round 3 still blocks. */ + await monitor.declareRound( + 2, + paths.map((path) => ({ + toolName: 'read', + keyMaterial: { + path, + }, + })), + ); + let last: { + streak: number; + action?: string; + } = { + streak: 0, + }; + for (const path of paths) { + const record = await monitor.recordToolCall( + 'read', + { + path, + }, + 2, + ); + last = { + streak: record.streak, + action: record.verdict?.action, + }; + } + expect(last.streak).toBe(3); + expect(last.action).toBe('block'); + }); + it('round-trips state across a process boundary via plain JSON', async () => { const first = new DoomLoopMonitor(resolveDoomLoopOption(true)); await first.recordToolCall( From f8584f532a91cd59459f6fd611656b24e93ac804 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:00:32 -0500 Subject: [PATCH 17/32] docs(agent): document the fixed-baseline mid-round match on the undeclared path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Devin traced that undeclared rounds no longer reproduce the old per-call comparison exactly, and the trace is right. Verified against pre-PR main: round0=[a], round1=[b,a] undeclared — old code reset a to 1 (b had just overwritten the last fingerprint); the baseline is now fixed at the round transition, so a scores 2. Strictly more detection (every increment still requires the fingerprint to have been genuinely issued in the previous round), no new false-positive path, and declared rounds are unaffected — but the README bullet described only the last-member shape, so the mid-round match is now spelled out there too. --- packages/agent/README.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/agent/README.md b/packages/agent/README.md index a32bd3fe..8355e526 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -445,14 +445,17 @@ block to observe for a known-chatty tool, or escalate straight to stop. - **Order-dependent scoring where a round can't be declared up front.** Server-tool records (web search, advisor) arrive as the response streams, so their membership isn't known in advance and they are scored per call - rather than per set. A repeating multi-call round there still accumulates, - but only on whichever call is recorded *last* — so the verdict lands on one - member, which member depends on emission order, and a repeat inside a - varying round *does* accumulate if it happens to be last - (`[a,b]`, `[c,b]`, `[d,b]` trips on `b`). Server-tool verdicts cannot - `block`, but they can reach `stop`. Client tool calls always go through a - declared round, so this affects server tools and direct - `DoomLoopMonitor` callers only. + rather than per set. Each call compares against a baseline FIXED at the + round transition — the previous round's last-recorded fingerprint — so a + repeating multi-call round accumulates only on whichever call is recorded + *last*; which member that is depends on emission order, and a repeat + inside a varying round *does* accumulate if it happens to be last + (`[a,b]`, `[c,b]`, `[d,b]` trips on `b`). The fixed baseline also means a + match ANYWHERE in the next round counts, not only in its final position: + `[a]` then `[b, a]` scores `a` as a repeat even though `b` arrived first. + Server-tool verdicts cannot `block`, but they can reach `stop`. Client + tool calls always go through a declared round, so this affects server + tools and direct `DoomLoopMonitor` callers only. ### Tool Approval From 6fcc74a93a28e98647b5a79d81944c9b61a176c1 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:08:44 -0500 Subject: [PATCH 18/32] =?UTF-8?q?feat(agent):=20per-call=20streaks=20?= =?UTF-8?q?=E2=80=94=20flag=20a=20repeated=20call=20whose=20round-mates=20?= =?UTF-8?q?change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-set identity treats any membership change as progress, which is right for the fan-out as a unit but blind to one call repeating inside varying company: [a,b], [a,c], [a,d] never accumulated on `a`, a paused HITL member granted the resumed round a fresh grace window, and undeclared multi-call rounds (server tools) accumulated only on the last-recorded member, order-dependently. Each (tool, fingerprint) identity now counts its own consecutive rounds alongside the round-set streak, and the stronger evidence decides: [a,b],[a,c],[a,d] a -> 1, 2:observe, 3:block; b/c/d run free HITL member drops on resume the repeat keeps counting: 1, 2:observe, 3:block undeclared [a,b] x2 both members 2:observe (was: last-recorded only) undeclared [b,a] flipped identical outcomes (was: verdict moved calls) [a,b,c] x2 then [a,b,c,d] a,b,c 3:block (each genuinely re-read), d executes exactly-repeating round both counts equal — nothing double-fires The verdict message follows the evidence: a per-call-only verdict quotes that call's own identity (dedupe-safe — no other call carries the same text), while round verdicts keep the shared round-identity message for the steer rung. Persistence gains `callStreaks` (optional, additive) next to `roundFingerprints` so per-call evidence survives approval pauses and per-turn resumes; old blobs restore with their old semantics, and a fresh-after-resume call still inherits nothing (the count follows who EARNED it — any member of a saved fan-out resumed alone continues its own 2 -> 3, a never-recorded call starts at 1). Semantics deliberately changed from the previous commits, with the failing expectations updated rather than preserved: a membership change now flags the calls that DID repeat instead of resetting everything (subset rounds observe their re-issued calls; superset rounds block the repeated members while the new call always executes — order-independently, unlike the original superset bug, which blocked the NEW work). Three known limits this closes were documented in the README/changeset as recently as yesterday; those entries are replaced by the new semantics, and the remaining honest limit (cross-tool alternation, measured: an every-other-round repeat still accumulates, slowly) is documented instead. 11 of the 21 fan-out tests fail without the feature (verified by stashing the implementation); the engine-level suites pass unchanged. 673 unit tests, typecheck and biome clean. --- .changeset/doom-loop-fanout.md | 60 +- packages/agent/README.md | 52 +- packages/agent/src/lib/doom-loop.ts | 151 ++++- .../agent/tests/unit/doom-loop-fanout.test.ts | 561 ++++++++++-------- 4 files changed, 500 insertions(+), 324 deletions(-) diff --git a/.changeset/doom-loop-fanout.md b/.changeset/doom-loop-fanout.md index 02b76168..f18432c0 100644 --- a/.changeset/doom-loop-fanout.md +++ b/.changeset/doom-loop-fanout.md @@ -18,20 +18,28 @@ a repeat — a round that adds new work is progress, not repetition. Every call a repeating round reports that round's streak, so at the block rung a repeating fan-out stops spending rather than only its last call being refused. -Paths that cannot know a round's membership up front — server-tool records, and -direct/port callers — are scored per call against the previous round rather than -as a set. A repeating multi-call round there still accumulates, but only on -whichever call is recorded *last*, so the verdict lands on one member and which -member depends on emission order. Declare the round (see below) for -order-independent, whole-round scoring. +**Per-call streaks** accumulate alongside the round-set streak, and the +stronger evidence decides. Each `(tool, arguments)` identity counts its own +consecutive rounds, whatever its round-mates did — so a call repeating inside +varying company (`[a,b]`, `[a,c]`, `[a,d]`: `a` is a 3-peat) is flagged even +though every round's set differs, a repeat keeps counting when a paused HITL +member drops from the resumed round, and undeclared paths (server-tool records, +direct callers) get order-independent per-call detection without a declaration. +When the per-call count alone crosses a rung, only that call is refused and its +verdict quotes its own identity; genuinely new round-mates run free. For an +exactly-repeating round both counts are equal, so nothing double-fires. A +partial repeat (`[a,b,c]` then `[a,b]`) flags the re-issued calls at the +observe rung rather than being invisible; a superset round (`[a,b]`, `[a,b]`, +`[a,b,c]`) flags the repeated members while the new call always executes. A call that a round's declaration could not include (unhashable key material) -is likewise scored on its own, and cannot move the round's counters — one -unhashable argument costs detection for its own call only, never for the tool. +cannot inherit or move the round's counters; its own verbatim repetition still +accumulates per-call evidence like any other repeat. -**Resumed runs**: a multi-call round's fingerprint set is persisted alongside -its streak (a new optional `roundFingerprints` on `DoomLoopStreak` — additive; -pre-existing blobs restore with their old single-call semantics). A repeating +**Resumed runs**: a multi-call round's fingerprint set and per-call counts are +persisted alongside its streak (new optional `roundFingerprints` and +`callStreaks` on `DoomLoopStreak` — additive; pre-existing blobs restore with +their old single-call semantics). A repeating fan-out therefore keeps its evidence across save/resume boundaries: approval pauses no longer reset a fan-out sitting at the block rung, and per-turn-resume topologies (one `callModel` per user turn, state persisted between) accumulate @@ -44,17 +52,14 @@ a fan-out's evidence. Single-call streaks behave exactly as before. complete call set before any of it is scored. `DoomLoopMonitor` is exported, so this is a new public method, additive only. Callers using `callModel` need not touch it (the engine calls it); direct `DoomLoopMonitor` users and SDK ports -should, since an undeclared multi-call round falls back to per-call scoring and -its fan-out goes undetected. +should, so a repeating fan-out is flagged as one unit (shared verdict, shared +steer message) rather than only via each member's individual per-call count. Single-call round timing, in-round duplicate collapsing, verdict payloads, and the number of times a tool's `loopKey` is invoked (once per checked call) are -unchanged. The persisted shape gains one optional field (`roundFingerprints`, -above); everything existing is untouched and old blobs restore cleanly. - -Known limit, unchanged by this fix: a call that repeats inside a round whose -other members keep varying (`[a,b]`, `[a,c]`, `[a,d]`) does not accumulate, -since round identity requires the whole set to match. +unchanged. The persisted shape gains two optional fields (`roundFingerprints` +and `callStreaks`, both above); everything existing is untouched and old blobs +restore cleanly with their old semantics. **Newly reachable false positive.** The detector compares arguments, not results, so a tool invoked with a stable *set* of parallel arguments every round @@ -91,12 +96,14 @@ const result = callModel(client, { // EVERY call of the round is refused at the block rung, not just one, // so the fan-out stops spending. // -// A round that ADDS work is progress and resets to 1: -// round 3: read(a), read(b), read(c), read(d) <- no verdict +// A round that ADDS work resets the ROUND streak, but each repeated call +// keeps its own count — the model re-read a, b, c a third time: +// round 3: read(a), read(b), read(c), read(d) +// -> a, b, c blocked (3rd consecutive round each); d executes. // // `loopKey` still runs exactly once per checked call. Persisted state gains -// one optional field so fan-out streaks survive save/resume; old state -// restores cleanly. +// two optional fields so fan-out and per-call evidence survive save/resume; +// old state restores cleanly. ``` Driving `DoomLoopMonitor` directly (or porting it) is the case that needs the @@ -111,10 +118,9 @@ import { DoomLoopMonitor, resolveDoomLoopOption } from '@openrouter/agent'; const monitor = new DoomLoopMonitor(resolveDoomLoopOption(true)); for (const [round, batch] of batches.entries()) { - // NEW: declare the round's complete set BEFORE recording any of its calls. - // Without this a multi-call round is scored per call, so a repeating fan-out - // accumulates only on whichever call is recorded last — and which one that - // is depends on emission order. + // NEW: declare the round's complete set BEFORE recording any of its calls, + // so a repeating fan-out is scored as one unit. (Per-call repetition is + // detected either way; the declaration adds whole-round identity.) await monitor.declareRound( round, batch.map((call) => ({ toolName: call.name, keyMaterial: call.arguments })), diff --git a/packages/agent/README.md b/packages/agent/README.md index 8355e526..a37b2af8 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -290,18 +290,27 @@ point. Repeated **rounds** build a per-tool streak: interleaved calls to within ONE round count once (a streak measures the model re-issuing a call *after seeing its result*, which requires a round trip). -A round's identity for one tool is the **set** of calls it made, not its last -call, so a fan-out of *distinct* arguments reissued verbatim counts: -`read(a), read(b), read(c)` every round accumulates a streak. Ordering within -the round is irrelevant, and a round whose membership *changes* — in either -direction — resets the streak to 1, since adding or dropping work is progress -rather than repetition. One consequence worth knowing: a call that repeats -inside a round whose other members keep changing (`[a,b]`, `[a,c]`, `[a,d]`) -does **not** accumulate, because the round differs each time. +Two kinds of evidence accumulate side by side, and the stronger one decides: + +- **Round-set streaks.** A round's identity for one tool is the **set** of + calls it made, so a fan-out of *distinct* arguments reissued verbatim + counts: `read(a), read(b), read(c)` every round accumulates. Ordering + within the round is irrelevant, and a round whose membership changes — in + either direction — resets this streak, since adding or dropping work is + progress for the round as a unit. +- **Per-call streaks.** Each `(tool, arguments)` identity also counts its own + consecutive rounds, whatever its round-mates did. A call repeating inside + varying company (`[a,b]`, `[a,c]`, `[a,d]` — `a` is a 3-peat) is flagged + even though every round's set differs, and a repeat spanning an approval + pause keeps counting when the paused member drops from the resumed round. + For an exactly-repeating round both counts are equal, so nothing + double-fires. When a repeating fan-out crosses a rung, every call in the round gets the verdict (so `block` stops the whole fan-out, not just one member) and they -share one message, so the `steer` rung injects a single correction. The streak +share one message, so the `steer` rung injects a single correction. When the +per-call count alone crosses a rung, only that call is refused — its verdict +quotes its own identity, and genuinely new round-mates run free. The streak crosses a graduated ladder — strongest crossed rung wins: | Action | Effect | @@ -436,26 +445,11 @@ block to observe for a known-chatty tool, or escalate straight to stop. - **Manual/client-executed calls** pause the loop for the caller and are not recorded (only executed, blocked, and parse-error calls are evidence). -- **A repeat inside a varying round.** Round identity is the whole set, so - `read(a)` reissued alongside a different second call every round - (`[a,b]`, `[a,c]`, `[a,d]`) never accumulates. This is the flip side of - "a changed member is progress"; a model retrying one failing call while - probing around it is not caught. Applies to *declared* rounds — i.e. - every batch the tool loop executes. -- **Order-dependent scoring where a round can't be declared up front.** - Server-tool records (web search, advisor) arrive as the response streams, - so their membership isn't known in advance and they are scored per call - rather than per set. Each call compares against a baseline FIXED at the - round transition — the previous round's last-recorded fingerprint — so a - repeating multi-call round accumulates only on whichever call is recorded - *last*; which member that is depends on emission order, and a repeat - inside a varying round *does* accumulate if it happens to be last - (`[a,b]`, `[c,b]`, `[d,b]` trips on `b`). The fixed baseline also means a - match ANYWHERE in the next round counts, not only in its final position: - `[a]` then `[b, a]` scores `a` as a repeat even though `b` arrived first. - Server-tool verdicts cannot `block`, but they can reach `stop`. Client - tool calls always go through a declared round, so this affects server - tools and direct `DoomLoopMonitor` callers only. +- **Cross-tool round patterns.** Streaks are per tool: a loop alternating + BETWEEN tools with no per-tool repetition (`read(a)` one round, `grep(a)` + the next, forever) shows each tool a sparse pattern its own evidence + cannot condemn. Interleaved calls to other tools never *reset* a tool's + streak, so an every-other-round repeat still accumulates — slowly. ### Tool Approval diff --git a/packages/agent/src/lib/doom-loop.ts b/packages/agent/src/lib/doom-loop.ts index 3211424f..b8f9119e 100644 --- a/packages/agent/src/lib/doom-loop.ts +++ b/packages/agent/src/lib/doom-loop.ts @@ -204,6 +204,18 @@ export interface DoomLoopStreak { * `fingerprint` fully describes the round. Never present on text streaks. */ roundFingerprints?: readonly string[]; + /** + * Per-call streaks for the tool's last round: fingerprint → number of + * consecutive rounds that exact call has been issued in. This is the + * evidence for the PER-CALL detector, which catches a call repeating inside + * rounds whose other members keep changing (`[a,b]`, `[a,c]`, `[a,d]` — the + * round set differs every time, but `a` is a 3-peat). Persisted so a repeat + * spanning a save/resume boundary (approval pause, per-turn resume) keeps + * counting. Bounded by the width of one round; absent in pre-existing + * blobs, where per-call evidence simply restarts. Never present on text + * streaks. + */ + callStreaks?: Record; } /** @@ -849,6 +861,17 @@ interface StreakEntry extends DoomLoopStreak { */ priorRoundFingerprints?: readonly string[]; priorStreak?: number; + /** + * The PREVIOUS round's per-call streaks (fingerprint → consecutive-round + * count), fixed at the round transition like {@link priorRoundFingerprints} + * so arrival order within the current round cannot affect a per-call score. + * A call present here repeated from last round and extends its own count; a + * call absent here starts at 1. The CURRENT round's counts live in the + * inherited {@link DoomLoopStreak.callStreaks}, accumulate as calls arrive + * (only calls actually recorded — a declared-but-skipped call must not + * carry per-call evidence), and become this field at the next transition. + */ + priorCallStreaks?: Record; } /** @@ -995,6 +1018,20 @@ export class DoomLoopMonitor { ], } : {}), + /* + * Per-call counts persist so a repeat spanning a save/resume + * boundary keeps counting. Copied for the same isolation reason + * as the set above. `fromEntries` for the same "__proto__" safety + * as the outer record. Omitted when there is nothing beyond the + * single-call case `fingerprint`+`streak` already covers. + */ + ...(entry.callStreaks !== undefined && + (Object.keys(entry.callStreaks).length > 1 || + (entry.roundFingerprints?.length ?? 0) > 1) + ? { + callStreaks: Object.fromEntries(Object.entries(entry.callStreaks)), + } + : {}), }, ]), ), @@ -1045,12 +1082,38 @@ export class DoomLoopMonitor { : [ entry.fingerprint, ]; + /* + * Per-call counts restore into the CURRENT-round slot: the first + * resumed record is a new round, so `recordToolCall` rolls them into + * its baseline (`priorCallStreaks`) exactly as a live round + * transition would. Validated entry-by-entry — the blob is + * caller-writable JSON, and one malformed count must not poison the + * rest. `Object.create(null)`+direct assignment so a persisted + * "__proto__" key is inert data, mirroring the Map rationale above. + */ + const persistedCallStreaks: Record = Object.create(null) as Record< + string, + number + >; + if (typeof entry.callStreaks === 'object' && entry.callStreaks !== null) { + for (const [callFingerprint, count] of Object.entries(entry.callStreaks)) { + if (typeof count === 'number' && Number.isFinite(count) && count >= 1) { + persistedCallStreaks[callFingerprint] = Math.floor(count); + } + } + } tools.set(name, { fingerprint: entry.fingerprint, streak: entry.streak, // `round` intentionally absent: the first resumed record is // always a new round, so it increments whatever the numbering. roundFingerprints: persistedSet, + callStreaks: + Object.keys(persistedCallStreaks).length > 0 + ? persistedCallStreaks + : { + [entry.fingerprint]: entry.streak, + }, }); } } @@ -1118,8 +1181,10 @@ export class DoomLoopMonitor { * The round's declared membership, when the engine announced it (see * `declareRound`). A declaration only speaks for the calls it contains: a * call dropped as unhashable still records here via the caller's fallback - * chain, but as a NON-member — it is scored on its own identity and holds - * at streak 1, costing detection for itself only (the fail-open contract). + * chain, but as a NON-member — it cannot inherit or move the ROUND's + * counters. Its own repetition still counts: the per-call detector below + * needs no declaration, so a fallback-identity call reissued verbatim + * every round accumulates like any other repeat. */ const declared = this.declaredRound?.round === round @@ -1148,6 +1213,25 @@ export class DoomLoopMonitor { const score = (set: readonly string[]): number => priorSet !== undefined && setsMatch(priorSet, set) ? priorStreak + 1 : 1; + /* + * PER-CALL evidence, alongside the round-set streak: the number of + * consecutive rounds THIS exact fingerprint has been issued in, whatever + * its round-mates did. Round identity treats any membership change as + * progress, which is right for the fan-out as a unit but blind to one + * call repeating inside varying company (`[a,b]`, `[a,c]`, `[a,d]` — the + * set differs every round, yet `a` is a 3-peat) and to a paused HITL + * member changing the resumed round's identity. The per-call count sees + * exactly those. Baseline fixed at the round transition, like the set + * baseline, so arrival order cannot affect it. + */ + const priorCallStreaks = isSameRound + ? (previous?.priorCallStreaks ?? {}) + : (previous?.callStreaks ?? {}); + const callStreak = + (Object.hasOwn(priorCallStreaks, fingerprint) + ? (priorCallStreaks[fingerprint] as number) + : 0) + 1; + /* * Two identities, one scoring rule. * @@ -1193,47 +1277,68 @@ export class DoomLoopMonitor { } : {}), priorStreak, + priorCallStreaks, + callStreaks: { + ...(isSameRound ? (previous?.callStreaks ?? {}) : {}), + [fingerprint]: callStreak, + }, }); + /* + * The stronger of the two detectors decides. The round streak covers the + * reissued fan-out as a unit; the per-call streak covers a repeat whose + * round-mates keep changing. For an exactly-repeating round both counts + * are equal, so nothing double-fires — the counts only diverge when one + * detector sees something the other cannot. + */ + const effectiveStreak = Math.max(streak, callStreak); const allowBlock = options?.allowBlock ?? true; - const action = resolveLadderAction(this.config.ladder, streak, { + const action = resolveLadderAction(this.config.ladder, effectiveStreak, { allowBlock, allowEscalate: this.canEscalate(), }); if (!action) { return { fingerprint, - streak, + streak: effectiveStreak, duplicateInRound, }; } + /* + * The message names what actually repeated. When the round streak decides + * (or ties), a multi-call round quotes the ROUND's identity — identical + * text for every call, deliberately, because the steer rung dedupes queued + * guidance by exact message text and one round of evidence must not queue + * N near-identical corrections. When the PER-CALL streak alone decides + * (its count exceeds the round's), only this one call is the evidence, so + * quoting its own fingerprint is both accurate and dedupe-safe: no other + * call of the round carries the same verdict. + */ + const message = + callStreak > streak + ? `Doom loop suspected: tool "${toolName}" was invoked with the same arguments ` + + `(fingerprint ${fingerprint.slice(0, 16)}…) in ${callStreak} consecutive rounds, even as ` + + 'its other calls changed. Repeating the call will not change the result. ' + + 'Take a different approach, or explain why repetition is required.' + : callSet.length > 1 + ? `Doom loop suspected: tool "${toolName}" was invoked in ${streak} consecutive rounds ` + + `with the same set of ${callSet.length} parallel calls ` + + `(round identity ${summarizeRound(callSet)}). Reissuing the same fan-out ` + + 'will not change the results. Take a different approach, or explain why repetition is required.' + : `Doom loop suspected: tool "${toolName}" was invoked in ${streak} consecutive rounds ` + + `with identical arguments (fingerprint ${fingerprint.slice(0, 16)}…). Repeating the call ` + + 'will not change the result. Take a different approach, or explain why repetition is required.'; return { fingerprint, - streak, + streak: effectiveStreak, duplicateInRound, verdict: { detector: options?.detector ?? 'tool-fingerprint', action, - streak, + streak: effectiveStreak, fingerprint, toolName, - /* - * Identical for every call of a repeating round, deliberately: the - * steer rung dedupes queued guidance by exact message text, so quoting - * the individual call's fingerprint here would queue N near-identical - * corrections for one round of evidence. A multi-call round therefore - * quotes the ROUND's identity (same for all its calls) and names the - * call count instead of one member's hash. - */ - message: - callSet.length > 1 - ? `Doom loop suspected: tool "${toolName}" was invoked in ${streak} consecutive rounds ` + - `with the same set of ${callSet.length} parallel calls ` + - `(round identity ${summarizeRound(callSet)}). Reissuing the same fan-out ` + - 'will not change the results. Take a different approach, or explain why repetition is required.' - : `Doom loop suspected: tool "${toolName}" was invoked in ${streak} consecutive rounds ` + - `with identical arguments (fingerprint ${fingerprint.slice(0, 16)}…). Repeating the call ` + - 'will not change the result. Take a different approach, or explain why repetition is required.', + message, }, }; } diff --git a/packages/agent/tests/unit/doom-loop-fanout.test.ts b/packages/agent/tests/unit/doom-loop-fanout.test.ts index e7829300..8fecf26e 100644 --- a/packages/agent/tests/unit/doom-loop-fanout.test.ts +++ b/packages/agent/tests/unit/doom-loop-fanout.test.ts @@ -138,7 +138,7 @@ describe('same-tool fan-out streaks', () => { ]); }); - it('resets when the fan-out membership changes', async () => { + it('scores each call individually when the fan-out membership changes', async () => { const actions = await playRounds([ [ 'a', @@ -162,18 +162,23 @@ describe('same-tool fan-out streaks', () => { 'observe', 'observe', ]); - /* Different set: this is progress, not repetition. */ + /* + * Round 3 changes a member: the ROUND identity resets (progress), but `a` + * itself is on its third consecutive round — the per-call detector flags + * it while the genuinely new `z` runs free. Swapping one argument while + * re-issuing the rest is not a loop escape. + */ expect(actions[2]).toEqual([ - 'none', + 'block', 'none', ]); expect(actions[3]).toEqual([ - 'observe', + 'block', 'observe', ]); }); - it('does not treat a partial repeat as a repeat', async () => { + it('flags the calls of a partial repeat that actually repeated', async () => { const actions = await playRounds([ [ 'a', @@ -186,14 +191,19 @@ describe('same-tool fan-out streaks', () => { ], ]); - /* A strict subset is a different round, so no verdict fires. */ + /* + * A strict subset is a different ROUND, but `a` and `b` are each on their + * second consecutive round: doing strictly less work does not make the + * re-issued calls progress. Observe only — dropping work never escalates + * faster than repeating it. + */ expect(actions[1]).toEqual([ - 'none', - 'none', + 'observe', + 'observe', ]); }); - it('does not treat a superset round as a repeat', async () => { + it('flags the repeated members of a superset round, never the new one', async () => { const actions = await playRounds([ [ 'a', @@ -211,18 +221,20 @@ describe('same-tool fan-out streaks', () => { ]); /* - * The third round added new work, so it is progress and nothing fires. - * Scoring the set as it accumulated used to make this round transiently - * equal `[a,b]` on its `b` call and score streak 3 -> block, refusing a - * legitimate call. Guards the direction the subset test above does not. + * Round 3 adds `c`: the ROUND is progress, and `c` must run — the original + * superset bug blocked it via a transient identity match, order- + * dependently. Under per-call scoring `a` and `b` are flagged because each + * genuinely IS on its third consecutive round (the model re-read both + * while adding one file), while `c` executes untouched. Unlike the old + * bug, this is order-independent — see the permutation test below. */ expect(actions[1]).toEqual([ 'observe', 'observe', ]); expect(actions[2]).toEqual([ - 'none', - 'none', + 'block', + 'block', 'none', ]); }); @@ -260,19 +272,25 @@ describe('same-tool fan-out streaks', () => { ]); /* - * Emission order must not decide whether a call is refused. While the set + * Emission order must not decide any call's outcome. While the set * accumulated, `[a,b,c]` blocked its `b` call and `[c,a,b]` fired nothing - * — same calls, same history, different outcome. + * — same calls, same history, different outcome. Per-call verdicts follow + * each call's own identity, so a permutation reorders the verdicts with + * the calls but never changes what any call receives. */ - expect(permuted[2]).toEqual(inOrder[2]); expect(inOrder[2]).toEqual([ + 'block', + 'block', 'none', + ]); + expect(permuted[2]).toEqual([ 'none', - 'none', + 'block', + 'block', ]); }); - it('does not accumulate a streak while a fan-out keeps expanding', async () => { + it('scores an expanding fan-out per call: repeats climb, each new call runs free', async () => { const actions = await playRounds([ [ 'a', @@ -294,13 +312,33 @@ describe('same-tool fan-out streaks', () => { ], ]); - /* Every round adds work, so no round repeats its predecessor. */ - expect( - actions - .slice(1) - .flat() - .every((action) => action === 'none'), - ).toBe(true); + /* + * Every round adds work, so no ROUND repeats its predecessor — but `a` is + * re-issued in all four rounds and `b` in three. Per-call evidence tracks + * each: the newest call is always clean, the oldest climbs the ladder. + * (Genuine incremental exploration re-reads nothing and stays silent; a + * tool that legitimately re-reads its anchors opts out via `loopKey`.) + */ + expect(actions).toEqual([ + [ + 'none', + ], + [ + 'observe', + 'none', + ], + [ + 'block', + 'observe', + 'none', + ], + [ + 'block', + 'block', + 'observe', + 'none', + ], + ]); }); it('leaves single-call rounds behaving exactly as before', async () => { @@ -422,22 +460,19 @@ describe('same-tool fan-out streaks', () => { expect(fresh.verdict).toBeUndefined(); }); - it('scores an UNDECLARED multi-call round on its last member, order-dependently', async () => { + it('scores an UNDECLARED multi-call round per call, order-independently', async () => { /* - * Pins the undeclared path's real semantics, because they are NOT the - * pre-fan-out per-call comparison for a multi-call round, and the docs - * previously claimed they were. - * - * Each call of an undeclared round overwrites `roundFingerprints` with its - * own singleton, so the next round's matching call compares against the - * previous round's LAST recorded fingerprint. A repeating undeclared - * fan-out therefore accumulates on whichever member lands last, and - * flipping the emission order moves the verdict to a different call. + * Pins the undeclared path (server-tool records, direct callers). The + * round SET is unknowable there, but per-call evidence needs no + * declaration: every repeated fingerprint accumulates its own count, so a + * repeating undeclared fan-out now flags EVERY repeated member instead of + * only whichever happened to be recorded last, and flipping the emission + * order no longer changes any call's outcome. * - * Consequences worth pinning: it reaches `stop` at the default ladder's - * streak 6 (server-tool verdicts cannot `block`, but they can stop a run), - * and a repeat inside a *varying* round accumulates here even though the - * declared path treats that as progress. + * Still worth pinning: verdicts here can reach `stop` at the default + * ladder's streak 6 (server-tool verdicts cannot `block`, but they can + * stop a run), and a repeat inside a *varying* round accumulates on the + * repeated member exactly as on the declared path. */ const undeclared = async (rounds: readonly (readonly string[])[]): Promise => { const detector = monitor(); @@ -459,7 +494,7 @@ describe('same-tool fan-out streaks', () => { return out; }; - /* The last member accumulates; the first does not. */ + /* Both repeated members accumulate, not just the last-recorded one. */ expect( await undeclared([ [ @@ -477,12 +512,12 @@ describe('same-tool fan-out streaks', () => { '1:none', ], [ - '1:none', + '2:observe', '2:observe', ], ]); - /* Flipping the order moves the verdict onto the other call. */ + /* Flipping the order changes nothing about any call's outcome. */ expect( await undeclared([ [ @@ -501,32 +536,39 @@ describe('same-tool fan-out streaks', () => { ], [ '2:observe', - '1:none', + '2:observe', ], ]); - /* A varying round still accumulates on the stable last member. */ + /* A varying round accumulates on the repeated member wherever it sits. */ expect( - ( - await undeclared([ - [ - 'a', - 'b', - ], - [ - 'c', - 'b', - ], - [ - 'd', - 'b', - ], - ]) - ).map((round) => round.at(-1)), + await undeclared([ + [ + 'b', + 'a', + ], + [ + 'b', + 'c', + ], + [ + 'b', + 'd', + ], + ]), ).toEqual([ - '1:none', - '2:observe', - '3:block', + [ + '1:none', + '1:none', + ], + [ + '2:observe', + '1:none', + ], + [ + '3:block', + '1:none', + ], ]); }); @@ -579,7 +621,8 @@ describe('same-tool fan-out streaks', () => { }, ]); const member = await detector.recordToolCall('read', hashable, 2); - /* Recorded with a fallback identity, as the engine would. */ + /* Recorded with a fallback identity, as the engine would — for the + * FIRST time; earlier rounds never recorded it. */ const dropped = await detector.recordToolCall( 'read', { @@ -590,11 +633,80 @@ describe('same-tool fan-out streaks', () => { /* The real repeat accumulates. */ expect(member.streak).toBe(3); - /* The non-member does not inherit it. */ + /* The non-member's first-ever appearance inherits nothing — not the + * round streak, and (never having been recorded) no per-call count. */ expect(dropped.streak).toBe(1); expect(dropped.verdict).toBeUndefined(); }); + it('accumulates per-call evidence for an unhashable call reissued verbatim', async () => { + /* + * A call dropped from the declaration (unhashable key material) records + * under a fallback identity as a NON-member: it can never inherit or move + * the round's counters. Its OWN repetition is still evidence — before + * per-call streaks it was pinned at 1 forever, a documented detection + * loss ("costs detection for its own call only"). Now the fallback + * identity accumulates like any repeat, while the round members are + * unaffected either way. + */ + const detector = monitor(); + const droppedStreaks: number[] = []; + const memberStreaks: number[] = []; + for (const round of [ + 0, + 1, + 2, + ]) { + await detector.declareRound(round, [ + { + toolName: 'read', + keyMaterial: { + path: 'a', + }, + }, + { + toolName: 'read', + keyMaterial: { + size: 1n, + }, + }, + ]); + memberStreaks.push( + ( + await detector.recordToolCall( + 'read', + { + path: 'a', + }, + round, + ) + ).streak, + ); + droppedStreaks.push( + ( + await detector.recordToolCall( + 'read', + { + size: 'fallback-identity', + }, + round, + ) + ).streak, + ); + } + + expect(memberStreaks).toEqual([ + 1, + 2, + 3, + ]); + expect(droppedStreaks).toEqual([ + 1, + 2, + 3, + ]); + }); + it('keeps accumulating when an unhashable call rides along every round', async () => { /* * A non-member must not write the round's identity. It used to store its @@ -715,17 +827,35 @@ describe('same-tool fan-out streaks', () => { ); } - /* The ignored call must not be refused the first time it is seen. */ + /* + * The non-member was genuinely recorded in both rounds, so on resume its + * OWN per-call count continues (2 -> 3) — earned evidence, not the round + * streak leaking. The K2 bug this test pins was different: the saved + * ROUND count attached to the non-member's fingerprint, so it inherited + * evidence it never earned while the real repeat lost its own. The guard + * for that is the identity pairing, asserted below via the member. + */ const resumedNonMember = new DoomLoopMonitor(resolveDoomLoopOption(true), detector.getState()); - const firstSighting = await resumedNonMember.recordToolCall( + const ownRepeat = await resumedNonMember.recordToolCall( 'read', { size: 'fallback-identity', }, 0, ); - expect(firstSighting.streak).toBe(1); - expect(firstSighting.verdict).toBeUndefined(); + expect(ownRepeat.streak).toBe(3); + + /* A call NEVER recorded before the save inherits nothing on resume. */ + const resumedFresh = new DoomLoopMonitor(resolveDoomLoopOption(true), detector.getState()); + const fresh = await resumedFresh.recordToolCall( + 'read', + { + size: 'never-seen-before', + }, + 0, + ); + expect(fresh.streak).toBe(1); + expect(fresh.verdict).toBeUndefined(); /* And the real repeat keeps the evidence it earned. */ const resumedMember = new DoomLoopMonitor(resolveDoomLoopOption(true), detector.getState()); @@ -747,19 +877,16 @@ describe('same-tool fan-out streaks', () => { expect(realRepeat.streak).toBe(3); }); - it('resets a streak when a declared-but-never-recorded member disappears', async () => { + it('keeps counting a recorded call when a declared-but-never-recorded member disappears', async () => { /* - * Pins WHY the engine must not declare a call it will never record (a - * manual tool, a PermissionRequest denial, a malformed call to either). - * Such a member is a phantom: it inflates the round's identity, so the - * sibling that IS recorded gets scored against a set it never matches on - * its own — and the streak resets the moment the phantom stops being - * emitted, even though the recorded call never changed. - * - * This test drives the monitor directly with an over-broad declaration to - * show the consequence; `beginDoomLoopRound` is what prevents it, by - * filtering the batch through `isAutoResolvableTool` and `hookDeniedCalls` - * before declaring. + * A phantom member — declared but never recorded (a manual tool, a + * PermissionRequest denial) — inflates the ROUND's identity, so the round + * streak resets when the phantom stops being emitted. That used to zero + * detection for the sibling that WAS recorded every round; per-call + * evidence is immune, because it follows the call's own fingerprint + * rather than the round set. The engine still filters phantoms out of + * declarations (`isAutoResolvableTool`, `hookDeniedCalls` in + * `beginDoomLoopRound`) so the ROUND streak stays meaningful too. */ const detector = monitor(); const streaks: number[] = []; @@ -803,121 +930,12 @@ describe('same-tool fan-out streaks', () => { ); } - /* - * The recorded call was identical in all four rounds, yet the streak - * restarts at round 2 when the phantom disappears. An over-broad - * declaration therefore costs real detection — hence the filtering. - */ + /* Identical call, four consecutive rounds: uninterrupted evidence. */ expect(streaks).toEqual([ - 1, - 2, - 1, - 2, - ]); - }); - - it('holds an unhashable call at streak 1 without stalling its round-mates', async () => { - /* - * Bounds the cost of an unhashable argument. Such a call is compared - * against its tool's declared set, which it is not a member of, so it - * cannot match and stays at 1 however often it recurs — it is invisible to - * detection. That is the fail-open contract: the price is paid by that call - * alone, and its round-mates keep accumulating normally (the regression - * above covers the case where it used to zero them too). - * - * Also pins the asymmetry: a tool whose calls are ALL unhashable has no - * declared set, so it falls through to the ordinary per-call comparison - * and DOES accumulate. - */ - const detector = monitor(); - const memberStreaks: number[] = []; - const droppedStreaks: number[] = []; - for (const round of [ - 0, - 1, - 2, - ]) { - await detector.declareRound(round, [ - { - toolName: 'read', - keyMaterial: { - path: 'a', - }, - }, - { - toolName: 'read', - keyMaterial: { - size: 1n, - }, - }, - ]); - memberStreaks.push( - ( - await detector.recordToolCall( - 'read', - { - path: 'a', - }, - round, - ) - ).streak, - ); - droppedStreaks.push( - ( - await detector.recordToolCall( - 'read', - { - size: 'fallback-identity', - }, - round, - ) - ).streak, - ); - } - - expect(memberStreaks).toEqual([ - 1, - 2, - 3, - ]); - expect(droppedStreaks).toEqual([ - 1, - 1, - 1, - ]); - - /* No declared set for the tool at all: ordinary per-call accumulation. */ - const allUnhashable = monitor(); - const soloStreaks: number[] = []; - for (const round of [ - 0, - 1, - 2, - ]) { - await allUnhashable.declareRound(round, [ - { - toolName: 'read', - keyMaterial: { - size: 1n, - }, - }, - ]); - soloStreaks.push( - ( - await allUnhashable.recordToolCall( - 'read', - { - size: 'fallback-identity', - }, - round, - ) - ).streak, - ); - } - expect(soloStreaks).toEqual([ 1, 2, 3, + 4, ]); }); @@ -1040,14 +1058,69 @@ describe('same-tool fan-out streaks', () => { expect(record.verdict?.action).toBe('block'); }); - it('does not refuse a resumed SINGLE call that inherits a fan-out streak', async () => { + it('keeps counting a repeat when a paused HITL member drops from the resumed round', async () => { /* - * The streak persists together with the SET that earned it, so a resumed - * round consisting of only one member of that set is a different round — - * it cannot match the persisted identity and scores 1. Persisting the - * count against a single fingerprint instead attached a fan-out's whole - * evidence to whichever call was recorded last: a lesser resumed call was - * BLOCKED on its first appearance, arbitrarily by emission order. + * A HITL member that pauses is recorded in the round where it pauses, but + * the resumed batch no longer contains it — so the tool's ROUND identity + * differs and the round streak resets. Before per-call evidence, that + * granted the loop a fresh grace window on every pause. The repeated + * working call now carries its own count through the membership change. + */ + const detector = monitor(); + const workStreaks: string[] = []; + for (const round of [ + 0, + 1, + 2, + ]) { + /* Round 0 includes the gated call; the resumed rounds do not. */ + const members = + round === 0 + ? [ + 'work', + 'gated', + ] + : [ + 'work', + ]; + await detector.declareRound( + round, + members.map((path) => ({ + toolName: 'deploy', + keyMaterial: { + path, + }, + })), + ); + for (const path of members) { + const record = await detector.recordToolCall( + 'deploy', + { + path, + }, + round, + ); + if (path === 'work') { + workStreaks.push(`${record.streak}:${record.verdict?.action ?? 'none'}`); + } + } + } + + expect(workStreaks).toEqual([ + '1:none', + '2:observe', + '3:block', + ]); + }); + + it('scores a resumed SINGLE call on its own earned evidence, never inherited', async () => { + /* + * Both the round set and the per-call counts persist, so evidence follows + * whoever EARNED it. A member of the saved fan-out resumed alone continues + * its own count — it genuinely appeared in consecutive rounds, and which + * member it is no longer matters (the original bug attached the whole + * fan-out count to whichever call was recorded last, arbitrarily by + * emission order). A call never recorded before the save inherits nothing. */ const detector = monitor(); const paths = [ @@ -1078,72 +1151,70 @@ describe('same-tool fan-out streaks', () => { ); } } - /* The streak survives the save, paired with its full set. */ + /* The streak survives the save, paired with its set and per-call counts. */ const saved = detector.getState() as { tools: Record< string, { streak: number; roundFingerprints?: string[]; + callStreaks?: Record; } >; }; expect(saved.tools.read.streak).toBe(2); expect(saved.tools.read.roundFingerprints).toHaveLength(3); + expect(Object.values(saved.tools.read.callStreaks ?? {})).toEqual([ + 2, + 2, + 2, + ]); - /* Resume with ONE call — the same one that was recorded last. */ - const resumed = new DoomLoopMonitor(resolveDoomLoopOption(true), detector.getState()); - await resumed.declareRound(0, [ + /* + * ANY member resumed alone continues its own earned count (2 -> 3): a + * third consecutive re-read of the same file is a repeat regardless of + * what happened to its former round-mates. Emission order is irrelevant — + * every member carries the same earned evidence. + */ + for (const path of paths) { + const resumed = new DoomLoopMonitor(resolveDoomLoopOption(true), detector.getState()); + await resumed.declareRound(0, [ + { + toolName: 'read', + keyMaterial: { + path, + }, + }, + ]); + const solo = await resumed.recordToolCall( + 'read', + { + path, + }, + 0, + ); + expect(solo.streak).toBe(3); + expect(solo.verdict?.action).toBe('block'); + } + + /* A call never recorded before the save inherits nothing. */ + const resumedFresh = new DoomLoopMonitor(resolveDoomLoopOption(true), detector.getState()); + await resumedFresh.declareRound(0, [ { toolName: 'read', keyMaterial: { - path: 'c', + path: 'never-before', }, }, ]); - const solo = await resumed.recordToolCall( + const fresh = await resumedFresh.recordToolCall( 'read', { - path: 'c', + path: 'never-before', }, 0, ); - /* A subset is a different round: no inherited evidence, no refusal. */ - expect(solo.streak).toBe(1); - expect(solo.verdict).toBeUndefined(); - - /* Detection is not lost: a repeating fan-out trips again after the resume. */ - const afterResume: number[] = []; - for (const round of [ - 1, - 2, - ]) { - await resumed.declareRound( - round, - paths.map((path) => ({ - toolName: 'read', - keyMaterial: { - path, - }, - })), - ); - let last = 0; - for (const path of paths) { - last = ( - await resumed.recordToolCall( - 'read', - { - path, - }, - round, - ) - ).streak; - } - afterResume.push(last); - } - expect(afterResume).toEqual([ - 1, - 2, - ]); + expect(fresh.streak).toBe(1); + expect(fresh.verdict).toBeUndefined(); }); }); From 7ab473101191c62cf1fe6cfe023369a7017bc950 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:23:23 -0500 Subject: [PATCH 19/32] refactor(agent): extract the verdict-message builder to satisfy the structural gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-call-streak commit tripped the structural gate ("complex functions 9 -> 10"): the three-way message ternary pushed recordToolCall past the complexity threshold. The message construction is self-contained — five inputs, no state — so it moves to a named builder, buildToolVerdictMessage, which also gives the steer-dedupe rationale a proper docstring instead of an inline comment. recordToolCall's branch count returns to its pre-feature level; behavior is byte-identical (673 unit tests pass unchanged, message strings not touched). The e2e failure on the same run is unrelated: multi-turn-tool-state's "preserve original user input" timed out against the live API and passes locally in 30s; re-run requested. --- packages/agent/src/lib/doom-loop.ts | 73 +++++++++++++++++++---------- 1 file changed, 48 insertions(+), 25 deletions(-) diff --git a/packages/agent/src/lib/doom-loop.ts b/packages/agent/src/lib/doom-loop.ts index b8f9119e..42438f9f 100644 --- a/packages/agent/src/lib/doom-loop.ts +++ b/packages/agent/src/lib/doom-loop.ts @@ -1304,30 +1304,6 @@ export class DoomLoopMonitor { duplicateInRound, }; } - /* - * The message names what actually repeated. When the round streak decides - * (or ties), a multi-call round quotes the ROUND's identity — identical - * text for every call, deliberately, because the steer rung dedupes queued - * guidance by exact message text and one round of evidence must not queue - * N near-identical corrections. When the PER-CALL streak alone decides - * (its count exceeds the round's), only this one call is the evidence, so - * quoting its own fingerprint is both accurate and dedupe-safe: no other - * call of the round carries the same verdict. - */ - const message = - callStreak > streak - ? `Doom loop suspected: tool "${toolName}" was invoked with the same arguments ` + - `(fingerprint ${fingerprint.slice(0, 16)}…) in ${callStreak} consecutive rounds, even as ` + - 'its other calls changed. Repeating the call will not change the result. ' + - 'Take a different approach, or explain why repetition is required.' - : callSet.length > 1 - ? `Doom loop suspected: tool "${toolName}" was invoked in ${streak} consecutive rounds ` + - `with the same set of ${callSet.length} parallel calls ` + - `(round identity ${summarizeRound(callSet)}). Reissuing the same fan-out ` + - 'will not change the results. Take a different approach, or explain why repetition is required.' - : `Doom loop suspected: tool "${toolName}" was invoked in ${streak} consecutive rounds ` + - `with identical arguments (fingerprint ${fingerprint.slice(0, 16)}…). Repeating the call ` + - 'will not change the result. Take a different approach, or explain why repetition is required.'; return { fingerprint, streak: effectiveStreak, @@ -1338,7 +1314,13 @@ export class DoomLoopMonitor { streak: effectiveStreak, fingerprint, toolName, - message, + message: buildToolVerdictMessage({ + toolName, + fingerprint, + callSet, + roundStreak: streak, + callStreak, + }), }, }; } @@ -1464,3 +1446,44 @@ function summarizeRound(fingerprints: readonly string[]): string { function setsMatch(left: readonly string[], right: readonly string[]): boolean { return left.length === right.length && left.every((value, index) => value === right[index]); } + +/** + * Verdict text for a tool-fingerprint detection, naming what actually + * repeated. When the round streak decides (or ties), a multi-call round quotes + * the ROUND's identity — identical text for every call, deliberately, because + * the steer rung dedupes queued guidance by exact message text and one round + * of evidence must not queue N near-identical corrections. When the PER-CALL + * streak alone decides (its count exceeds the round's), only this one call is + * the evidence, so quoting its own fingerprint is both accurate and + * dedupe-safe: no other call of the round carries the same verdict. + */ +function buildToolVerdictMessage(input: { + toolName: string; + fingerprint: string; + callSet: readonly string[]; + roundStreak: number; + callStreak: number; +}): string { + const { toolName, fingerprint, callSet, roundStreak, callStreak } = input; + if (callStreak > roundStreak) { + return ( + `Doom loop suspected: tool "${toolName}" was invoked with the same arguments ` + + `(fingerprint ${fingerprint.slice(0, 16)}…) in ${callStreak} consecutive rounds, even as ` + + 'its other calls changed. Repeating the call will not change the result. ' + + 'Take a different approach, or explain why repetition is required.' + ); + } + if (callSet.length > 1) { + return ( + `Doom loop suspected: tool "${toolName}" was invoked in ${roundStreak} consecutive rounds ` + + `with the same set of ${callSet.length} parallel calls ` + + `(round identity ${summarizeRound(callSet)}). Reissuing the same fan-out ` + + 'will not change the results. Take a different approach, or explain why repetition is required.' + ); + } + return ( + `Doom loop suspected: tool "${toolName}" was invoked in ${roundStreak} consecutive rounds ` + + `with identical arguments (fingerprint ${fingerprint.slice(0, 16)}…). Repeating the call ` + + 'will not change the result. Take a different approach, or explain why repetition is required.' + ); +} From 3f27fd6faf1cb35f3b958a4281ea8650f0b296d7 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:36:26 -0500 Subject: [PATCH 20/32] =?UTF-8?q?refactor(agent):=20extract=20restoreStrea?= =?UTF-8?q?kEntry=20=E2=80=94=20the=20gate's=2010th=20complex=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous gate fix extracted the wrong function. I estimated complexity with a crude branch-token count over recordToolCall and shipped without running the gate; the actual offender was restore(), which the per-call restore validation had pushed to cc=18. Found by running sentrux locally this time (a darwin binary exists in the same release) — `sentrux check` names the functions, which the gate summary does not. The per-entry restoration moves to restoreStreakEntry: set validation, per-call count validation, and the legacy-blob fallback, with the restore-semantics rationale as its docstring. restore() drops to a guard plus a loop. Verified against the real gate: "No degradation detected", complex functions back at the baseline 9 (all pre-existing, none in doom-loop.ts). Behavior byte-identical: 673 unit tests pass unchanged. --- packages/agent/src/lib/doom-loop.ts | 107 ++++++++++++++-------------- 1 file changed, 55 insertions(+), 52 deletions(-) diff --git a/packages/agent/src/lib/doom-loop.ts b/packages/agent/src/lib/doom-loop.ts index 42438f9f..4a65c4f1 100644 --- a/packages/agent/src/lib/doom-loop.ts +++ b/packages/agent/src/lib/doom-loop.ts @@ -820,6 +820,60 @@ function isValidStreak(value: unknown): value is DoomLoopStreak { ); } +/** + * Rebuild one tool's in-memory streak entry from its persisted shape. + * + * The persisted set (when the last round was multi-call) restores the round's + * identity exactly, so a resumed fan-out continues its streak and a resumed + * SUBSET cannot match it — the false-positive and false-negative failure + * modes of a single-fingerprint save. Older blobs (and single-call rounds) + * carry no set; the lone fingerprint fully describes those rounds. Malformed + * sets fall back the same way rather than dropping the entry. + * + * Per-call counts restore into the CURRENT-round slot: the first resumed + * record is a new round, so `recordToolCall` rolls them into its baseline + * (`priorCallStreaks`) exactly as a live round transition would. Validated + * entry-by-entry — the blob is caller-writable JSON, and one malformed count + * must not poison the rest. `Object.create(null)` + direct assignment so a + * persisted "__proto__" key is inert data, mirroring the Map rationale at the + * `tools` field. `round` is intentionally absent from the result: the first + * resumed record is always a new round, whatever the numbering. + */ +function restoreStreakEntry(entry: DoomLoopStreak): StreakEntry { + const persistedSet = + Array.isArray(entry.roundFingerprints) && + entry.roundFingerprints.length > 1 && + entry.roundFingerprints.every((value) => typeof value === 'string') + ? [ + ...entry.roundFingerprints, + ].sort() + : [ + entry.fingerprint, + ]; + const persistedCallStreaks: Record = Object.create(null) as Record< + string, + number + >; + if (typeof entry.callStreaks === 'object' && entry.callStreaks !== null) { + for (const [callFingerprint, count] of Object.entries(entry.callStreaks)) { + if (typeof count === 'number' && Number.isFinite(count) && count >= 1) { + persistedCallStreaks[callFingerprint] = Math.floor(count); + } + } + } + return { + fingerprint: entry.fingerprint, + streak: entry.streak, + roundFingerprints: persistedSet, + callStreaks: + Object.keys(persistedCallStreaks).length > 0 + ? persistedCallStreaks + : { + [entry.fingerprint]: entry.streak, + }, + }; +} + /** In-memory streak entry: serialized shape + the round of the last record. */ interface StreakEntry extends DoomLoopStreak { /** @@ -1063,58 +1117,7 @@ export class DoomLoopMonitor { if (typeof candidate.tools === 'object' && candidate.tools !== null) { for (const [name, entry] of Object.entries(candidate.tools)) { if (isValidStreak(entry)) { - /* - * The persisted set (when the last round was multi-call) restores - * the round's identity exactly, so a resumed fan-out continues its - * streak and a resumed SUBSET cannot match it — the false-positive - * and false-negative failure modes of a single-fingerprint save. - * Older blobs (and single-call rounds) carry no set; the lone - * fingerprint fully describes those rounds. Entries with a - * malformed set fall back the same way rather than being dropped. - */ - const persistedSet = - Array.isArray(entry.roundFingerprints) && - entry.roundFingerprints.length > 1 && - entry.roundFingerprints.every((value) => typeof value === 'string') - ? [ - ...entry.roundFingerprints, - ].sort() - : [ - entry.fingerprint, - ]; - /* - * Per-call counts restore into the CURRENT-round slot: the first - * resumed record is a new round, so `recordToolCall` rolls them into - * its baseline (`priorCallStreaks`) exactly as a live round - * transition would. Validated entry-by-entry — the blob is - * caller-writable JSON, and one malformed count must not poison the - * rest. `Object.create(null)`+direct assignment so a persisted - * "__proto__" key is inert data, mirroring the Map rationale above. - */ - const persistedCallStreaks: Record = Object.create(null) as Record< - string, - number - >; - if (typeof entry.callStreaks === 'object' && entry.callStreaks !== null) { - for (const [callFingerprint, count] of Object.entries(entry.callStreaks)) { - if (typeof count === 'number' && Number.isFinite(count) && count >= 1) { - persistedCallStreaks[callFingerprint] = Math.floor(count); - } - } - } - tools.set(name, { - fingerprint: entry.fingerprint, - streak: entry.streak, - // `round` intentionally absent: the first resumed record is - // always a new round, so it increments whatever the numbering. - roundFingerprints: persistedSet, - callStreaks: - Object.keys(persistedCallStreaks).length > 0 - ? persistedCallStreaks - : { - [entry.fingerprint]: entry.streak, - }, - }); + tools.set(name, restoreStreakEntry(entry)); } } } From f3776e07740353e97207cae1f91269d7b91294e2 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:54:27 -0500 Subject: [PATCH 21/32] fix(agent): persist lone per-call evidence; collapse per-call steer text; drop dead id guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from Devin's pass over the per-call feature. Per-call evidence was dropped at exactly the moment it was the only evidence. getState omitted `callStreaks` for single-call rounds on the assumption that `fingerprint`+`streak` carry the same information — false when a round SHRINKS: a paused HITL member drops out, the round streak resets to 1, and the per-call count (2) is the only record that the surviving call repeated. Saving then restoring handed the repeat a fresh grace window, reaching block one round later than the in-memory behavior the HITL test pins. The omission now fires only when the per-call map is exactly {fingerprint: streak} — the case restore() reconstructs verbatim. Measured: resumed streak 2 -> 3 (was 2 -> 2). A wide round could queue one steer message per repeated member. Per-call verdict text embedded the call's own fingerprint, so a 20-wide fan-out repeated and then widened produced 20 distinct messages — all queued, all joined into one injected prompt. Per-call messages no longer embed the hash: same tool + same count is byte-identical, and the steer dedupe collapses the round to one correction (measured 20 -> 1). The refused call is still identified by its block output and the verdict payload's `fingerprint`; only the prose lost the hash. The loopKey cache's id guards were dead. ParsedToolCall.id is a required string, so `toolCall.id !== undefined` never gated anything and the "silently falls back when a call carries no id" path was unreachable — but the guards implied it existed. Removed them and keyed the map on the id directly, so the type system now states what the guards obscured. Both behavior fixes have regression tests verified to fail against the previous commit. 675 unit tests, typecheck, biome, and the structural gate all clean. --- packages/agent/src/lib/doom-loop.ts | 31 +++- packages/agent/src/lib/model-result.ts | 13 +- .../agent/tests/unit/doom-loop-fanout.test.ts | 141 ++++++++++++++++++ 3 files changed, 169 insertions(+), 16 deletions(-) diff --git a/packages/agent/src/lib/doom-loop.ts b/packages/agent/src/lib/doom-loop.ts index 4a65c4f1..fd897ba1 100644 --- a/packages/agent/src/lib/doom-loop.ts +++ b/packages/agent/src/lib/doom-loop.ts @@ -1076,12 +1076,21 @@ export class DoomLoopMonitor { * Per-call counts persist so a repeat spanning a save/resume * boundary keeps counting. Copied for the same isolation reason * as the set above. `fromEntries` for the same "__proto__" safety - * as the outer record. Omitted when there is nothing beyond the - * single-call case `fingerprint`+`streak` already covers. + * as the outer record. Omitted only when `fingerprint`+`streak` + * already carry the identical information (a single-call round + * whose per-call count equals its round count — `restore` + * reconstructs exactly that). The count-mismatch check matters: + * when a round SHRINKS to one call, the round streak resets while + * the per-call count keeps climbing, and dropping it would hand + * the repeat a fresh grace window on resume. */ ...(entry.callStreaks !== undefined && (Object.keys(entry.callStreaks).length > 1 || - (entry.roundFingerprints?.length ?? 0) > 1) + (entry.roundFingerprints?.length ?? 0) > 1 || + Object.entries(entry.callStreaks).some( + ([callFingerprint, count]) => + callFingerprint !== entry.fingerprint || count !== entry.streak, + )) ? { callStreaks: Object.fromEntries(Object.entries(entry.callStreaks)), } @@ -1469,11 +1478,19 @@ function buildToolVerdictMessage(input: { }): string { const { toolName, fingerprint, callSet, roundStreak, callStreak } = input; if (callStreak > roundStreak) { + /* + * No per-call hash here, deliberately: a wide round can have MANY members + * whose per-call counts fire at once (a repeated fan-out plus one new + * call), and quoting each call's own fingerprint would queue one steer + * message per member. Same tool + same count -> byte-identical text, so + * the steer dedupe collapses them to one correction. Block outputs are + * attached to the specific refused call anyway, and the verdict payload + * carries the exact `fingerprint` for hooks. + */ return ( - `Doom loop suspected: tool "${toolName}" was invoked with the same arguments ` + - `(fingerprint ${fingerprint.slice(0, 16)}…) in ${callStreak} consecutive rounds, even as ` + - 'its other calls changed. Repeating the call will not change the result. ' + - 'Take a different approach, or explain why repetition is required.' + `Doom loop suspected: this exact "${toolName}" call was repeated in ${callStreak} ` + + 'consecutive rounds, even as its other calls changed. Repeating it will not change ' + + 'the result. Take a different approach, or explain why repetition is required.' ); } if (callSet.length > 1) { diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index 81bf974f..60a1073e 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -1367,7 +1367,7 @@ export class ModelResult< * `runToolWithHooks` synthesizes the rejection before reaching the * doom-loop checkpoint, so the call is never recorded either. */ - if (toolCall.id !== undefined && this.hookDeniedCalls.has(String(toolCall.id))) { + if (this.hookDeniedCalls.has(toolCall.id)) { continue; } const rawArgs: unknown = toolCall.arguments; @@ -1404,10 +1404,8 @@ export class ModelResult< continue; } // Cache so the per-call checkpoint does not invoke `loopKey` a second - // time; keyed by call id, which is unique within a round. - if (toolCall.id !== undefined) { - this.doomLoopRoundKeyMaterial.set(String(toolCall.id), resolution); - } + // time; keyed by call id (required on ParsedToolCall, unique per round). + this.doomLoopRoundKeyMaterial.set(toolCall.id, resolution); if (resolution.kind === 'exempt') { continue; } @@ -1587,10 +1585,7 @@ export class ModelResult< * as well would double-invoke it and, for a non-repeatable callback, make * the declared identity and the recorded identity disagree. */ - const cached = - toolCall.id !== undefined - ? this.doomLoopRoundKeyMaterial.get(String(toolCall.id)) - : undefined; + const cached = this.doomLoopRoundKeyMaterial.get(toolCall.id); let resolution: LoopKeyResolution; if (cached !== undefined) { resolution = cached; diff --git a/packages/agent/tests/unit/doom-loop-fanout.test.ts b/packages/agent/tests/unit/doom-loop-fanout.test.ts index 8fecf26e..0ed2ad4d 100644 --- a/packages/agent/tests/unit/doom-loop-fanout.test.ts +++ b/packages/agent/tests/unit/doom-loop-fanout.test.ts @@ -1113,6 +1113,147 @@ describe('same-tool fan-out streaks', () => { ]); }); + it('collapses per-call steer messages across a wide repeating round', async () => { + /* + * When per-call counts decide for MANY members at once (a wide fan-out + * repeated, then widened by one call), each member gets its own verdict. + * The steer rung dedupes by exact message text, so per-call messages must + * not embed the individual fingerprint — same tool + same count must be + * byte-identical, or a 20-wide round queues 20 near-identical corrections + * into one injected prompt. The refused call is identified by the block + * output's position and the verdict payload's `fingerprint`; the message + * text does not need to repeat it. + */ + const detector = new DoomLoopMonitor( + resolveDoomLoopOption({ + ladder: { + steer: 2, + }, + }), + ); + const wide = Array.from( + { + length: 20, + }, + (_, index) => `f${index}`, + ); + const messages = new Set(); + let verdictCount = 0; + for (const [round, paths] of [ + wide, + wide, + [ + ...wide, + 'new', + ], + ].entries()) { + await detector.declareRound( + round, + paths.map((path) => ({ + toolName: 'read', + keyMaterial: { + path, + }, + })), + ); + for (const path of paths) { + const record = await detector.recordToolCall( + 'read', + { + path, + }, + round, + ); + if (round === 2 && record.verdict) { + verdictCount++; + messages.add(record.verdict.message); + } + } + } + + /* All 20 repeated members fire; the steer queue sees ONE correction. */ + expect(verdictCount).toBe(20); + expect(messages.size).toBe(1); + }); + + it('persists per-call evidence when the round has shrunk to a single call', async () => { + /* + * When a round SHRINKS (a paused HITL member drops out), the round streak + * resets while the per-call count keeps climbing — per-call evidence is + * then the ONLY evidence, held by a single-call round. The save-time + * omission of `callStreaks` for "plain" single-call rounds must not fire + * here: dropping the count handed the repeat a fresh grace window on + * resume, reaching block a full round later than the in-memory behavior. + */ + const detector = monitor(); + await detector.declareRound(0, [ + { + toolName: 'deploy', + keyMaterial: { + path: 'work', + }, + }, + { + toolName: 'deploy', + keyMaterial: { + path: 'gated', + }, + }, + ]); + await detector.recordToolCall( + 'deploy', + { + path: 'work', + }, + 0, + ); + await detector.recordToolCall( + 'deploy', + { + path: 'gated', + }, + 0, + ); + /* The gated call paused; the resumed round is just the working call. */ + await detector.declareRound(1, [ + { + toolName: 'deploy', + keyMaterial: { + path: 'work', + }, + }, + ]); + const beforeSave = await detector.recordToolCall( + 'deploy', + { + path: 'work', + }, + 1, + ); + expect(beforeSave.streak).toBe(2); + + /* Save/resume mid-loop (approval pause): the count must survive. */ + const wire = JSON.parse(JSON.stringify(detector.getState())) as unknown; + const resumed = new DoomLoopMonitor(resolveDoomLoopOption(true), wire); + await resumed.declareRound(0, [ + { + toolName: 'deploy', + keyMaterial: { + path: 'work', + }, + }, + ]); + const afterResume = await resumed.recordToolCall( + 'deploy', + { + path: 'work', + }, + 0, + ); + expect(afterResume.streak).toBe(3); + expect(afterResume.verdict?.action).toBe('block'); + }); + it('scores a resumed SINGLE call on its own earned evidence, never inherited', async () => { /* * Both the round set and the per-call counts persist, so evidence follows From aaa2d83bc33b67520a20f86dbe02849cd0f21eae Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:52:49 -0500 Subject: [PATCH 22/32] fix(agent): one verdict text per undeclared multi-call round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The steer rung dedupes queued guidance on exact message text, so a round of evidence must render one string. On the undeclared path (server-tool records, direct DoomLoopMonitor consumers, the SDK ports) each call is recorded alone, so callSet holds only that call however wide the round was. The last-recorded call tied at roundStreak == callStreak and took the fingerprint-bearing single-call branch while its round-mates took the per-call branch — two near-identical corrections queued for one round. buildToolVerdictMessage now takes roundDeclared: only a declared round's callSet describes the round, so only then may the text name a set or quote an argument fingerprint. Declared fan-outs and declared single-call rounds are unchanged. Not reachable via callModel, where every executed batch is declared. --- packages/agent/src/lib/doom-loop.ts | 33 +++++++--- .../agent/tests/unit/doom-loop-fanout.test.ts | 62 +++++++++++++++++++ 2 files changed, 85 insertions(+), 10 deletions(-) diff --git a/packages/agent/src/lib/doom-loop.ts b/packages/agent/src/lib/doom-loop.ts index fd897ba1..5921e050 100644 --- a/packages/agent/src/lib/doom-loop.ts +++ b/packages/agent/src/lib/doom-loop.ts @@ -1332,6 +1332,10 @@ export class DoomLoopMonitor { callSet, roundStreak: streak, callStreak, + // An undeclared record's `callSet` is just this one call, which says + // nothing about the round's real width — so it may not be described + // as a set, nor have its fingerprint quoted as the round's identity. + roundDeclared: declaredMember, }), }, }; @@ -1468,6 +1472,16 @@ function setsMatch(left: readonly string[], right: readonly string[]): boolean { * streak alone decides (its count exceeds the round's), only this one call is * the evidence, so quoting its own fingerprint is both accurate and * dedupe-safe: no other call of the round carries the same verdict. + * + * `roundDeclared` says whether the detector was told the round's true + * membership. Only then does `callSet` describe the round, so only then may the + * text name a set or quote an argument fingerprint. On the UNDECLARED path + * (server-tool records, direct `DoomLoopMonitor` consumers, the SDK ports) each + * call is recorded alone, so `callSet` holds just that call however wide the + * round really was: the last-recorded call tied at `roundStreak == callStreak` + * and rendered the fingerprint-bearing single-call text while its round-mates + * rendered the per-call text — two strings for one round, which defeats the + * exact-text steer dedupe this shaping exists to preserve. */ function buildToolVerdictMessage(input: { toolName: string; @@ -1475,20 +1489,19 @@ function buildToolVerdictMessage(input: { callSet: readonly string[]; roundStreak: number; callStreak: number; + roundDeclared: boolean; }): string { - const { toolName, fingerprint, callSet, roundStreak, callStreak } = input; - if (callStreak > roundStreak) { + const { toolName, fingerprint, callSet, roundStreak, callStreak, roundDeclared } = input; + if (callStreak > roundStreak || !roundDeclared) { /* - * No per-call hash here, deliberately: a wide round can have MANY members - * whose per-call counts fire at once (a repeated fan-out plus one new - * call), and quoting each call's own fingerprint would queue one steer - * message per member. Same tool + same count -> byte-identical text, so - * the steer dedupe collapses them to one correction. Block outputs are - * attached to the specific refused call anyway, and the verdict payload - * carries the exact `fingerprint` for hooks. + * Fingerprint-free, so every member of the round renders identically and + * the steer dedupe collapses them to one correction. Reached when the + * per-call streak decides, and when the round's true membership is unknown + * (undeclared path) — in both cases quoting one call's hash would either + * be inaccurate for the round or diverge between its members. */ return ( - `Doom loop suspected: this exact "${toolName}" call was repeated in ${callStreak} ` + + `Doom loop suspected: this exact "${toolName}" call was repeated in ${Math.max(roundStreak, callStreak)} ` + 'consecutive rounds, even as its other calls changed. Repeating it will not change ' + 'the result. Take a different approach, or explain why repetition is required.' ); diff --git a/packages/agent/tests/unit/doom-loop-fanout.test.ts b/packages/agent/tests/unit/doom-loop-fanout.test.ts index 0ed2ad4d..4d677826 100644 --- a/packages/agent/tests/unit/doom-loop-fanout.test.ts +++ b/packages/agent/tests/unit/doom-loop-fanout.test.ts @@ -1358,4 +1358,66 @@ describe('same-tool fan-out streaks', () => { expect(fresh.streak).toBe(1); expect(fresh.verdict).toBeUndefined(); }); + + /* + * The steer rung dedupes queued guidance on exact message text, so one round + * of evidence must render ONE string. On the undeclared path (server-tool + * records, direct monitor consumers, the SDK ports) each call is recorded + * alone, so a round's `callSet` holds only that call: the last-recorded call + * tied at `roundStreak == callStreak` and rendered the fingerprint-bearing + * single-call text, while its round-mates rendered the fingerprint-free + * per-call text — two strings for one round, defeating the dedupe. + */ + it('renders one verdict text per undeclared multi-call round', async () => { + const detector = new DoomLoopMonitor(resolveDoomLoopOption(true)); + for (let round = 1; round <= 4; round++) { + const texts: string[] = []; + for (const cmd of [ + 'a', + 'b', + ]) { + const result = await detector.recordToolCall( + 'sh', + { + cmd, + }, + round, + { + allowBlock: false, + }, + ); + if (result.verdict) { + texts.push(result.verdict.message); + } + } + // Either the round produced no verdict yet, or every member of it + // produced byte-identical text. + expect(new Set(texts).size).toBeLessThanOrEqual(1); + } + }); + + /* A DECLARED single-call round still names the argument fingerprint. */ + it('keeps the fingerprint-bearing text for a genuine single-call round', async () => { + const detector = new DoomLoopMonitor(resolveDoomLoopOption(true)); + let last: string | undefined; + for (let round = 1; round <= 3; round++) { + await detector.declareRound(round, [ + { + toolName: 'read', + keyMaterial: { + path: 'same', + }, + }, + ]); + const result = await detector.recordToolCall( + 'read', + { + path: 'same', + }, + round, + ); + last = result.verdict?.message ?? last; + } + expect(last).toContain('identical arguments (fingerprint'); + }); }); From 15a7a9f7f9301cccfc07de6c4bb0a2e968272b18 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:43:13 -0500 Subject: [PATCH 23/32] test(agent): end-to-end fan-out detection through callModel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Perry's fresh review asked for the integration test bridging the monitor-level fan-out suite and the engine: a scripted repeating distinct-argument fan-out through callModel, asserting the observe -> block escalation and that the block rung stops the whole fan-out (only rounds 1-2 execute). The interesting part is what the obvious assertions do NOT catch, found by injecting the two declaration regressions the INVARIANT comment warns about (a phantom declared member, and a member wrongly filtered out): for an exactly-repeating fan-out the per-call detector produces identical actions and streaks, so corrupted round identity is invisible to action/streak assertions — the per-call counts mask it. Both injected regressions passed the first version of this test. The discriminator is the verdict message form: round verdicts name the set ("same set of 3 parallel calls"), per-call verdicts name a single repeated call. Asserting the round form on every detection pins the beginDoomLoopRound declaration end to end; both injected regressions now fail the test. 685 unit tests, structural gate, typecheck, and biome all clean. --- .../tests/unit/doom-loop-integration.test.ts | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/packages/agent/tests/unit/doom-loop-integration.test.ts b/packages/agent/tests/unit/doom-loop-integration.test.ts index 2b62a0c9..8c547371 100644 --- a/packages/agent/tests/unit/doom-loop-integration.test.ts +++ b/packages/agent/tests/unit/doom-loop-integration.test.ts @@ -108,6 +108,23 @@ function textTurn(text: string): models.OpenResponsesResult { ]); } +/** A turn fanning one tool out over several argument sets in parallel. */ +function fanOutTurn(name: string, argsList: readonly unknown[]): models.OpenResponsesResult { + return baseResponse( + argsList.map((args) => { + callCounter++; + return { + type: 'function_call', + id: `fc_${callCounter}`, + callId: `call_${callCounter}`, + name, + arguments: JSON.stringify(args), + status: 'completed', + }; + }), + ); +} + /** A turn with two parallel tool calls, for mixed-batch scenarios. */ function twoToolCallTurn( first: [ @@ -217,6 +234,145 @@ beforeEach(() => { // Scenario 1: identical tool calls, turn after turn // --------------------------------------------------------------------------- +describe('simulated LLM repeating a distinct-argument fan-out', () => { + it('escalates observe -> block through callModel and stops the whole fan-out', async () => { + /* + * End to end through the engine, not the monitor: exercises the + * `beginDoomLoopRound` declaration filter chain that must mirror every + * record-skip path (see the INVARIANT comment there). A regression in + * that mirroring degrades fan-out detection silently; only a scripted + * multi-round fan-out through `callModel` catches it loudly. + */ + const executed: string[] = []; + const readTool = tool({ + name: 'read', + inputSchema: z.object({ + path: z.string(), + }), + outputSchema: z.object({ + content: z.string(), + }), + execute: async ({ path }) => { + executed.push(path); + return { + content: `contents of ${path}`, + }; + }, + }); + + const detections: DoomLoopDetectedPayload[] = []; + const hooks = new HooksManager(); + hooks.on('DoomLoopDetected', { + handler: (payload) => { + detections.push(payload); + }, + }); + + /* The same 3-call fan-out, four rounds running, then recovery. */ + const paths = [ + { + path: 'a', + }, + { + path: 'b', + }, + { + path: 'c', + }, + ]; + scriptModelTurns( + fanOutTurn('read', paths), + fanOutTurn('read', paths), + fanOutTurn('read', paths), + fanOutTurn('read', paths), + textTurn('recovered'), + ); + + const text = await callModel(client, { + model: 'test-model', + input: 'Summarize these files.', + tools: [ + readTool, + ] as const, + doomLoop: true, + hooks, + }).getText(); + + /* The run recovers once the model changes course. */ + expect(text).toBe('recovered'); + + /* + * Round 2 observes all three calls; rounds 3-4 block all three. The + * block rung stops the WHOLE fan-out spending: only rounds 1-2 execute. + */ + expect( + detections.map((d) => [ + d.action, + d.streak, + ]), + ).toEqual([ + [ + 'observe', + 2, + ], + [ + 'observe', + 2, + ], + [ + 'observe', + 2, + ], + [ + 'block', + 3, + ], + [ + 'block', + 3, + ], + [ + 'block', + 3, + ], + [ + 'block', + 4, + ], + [ + 'block', + 4, + ], + [ + 'block', + 4, + ], + ]); + expect(executed).toEqual([ + 'a', + 'b', + 'c', + 'a', + 'b', + 'c', + ]); + + /* + * The verdicts must come from the ROUND detector, not merely per-call + * counts. For an exactly-repeating fan-out both detectors produce the + * same actions and streaks, so a corrupted declaration (a phantom member, + * or a member wrongly filtered out) is invisible to the assertions above + * — the per-call counts mask it. The message form is the discriminator: + * round verdicts name the set ("same set of 3 parallel calls"), per-call + * verdicts name a single repeated call. Asserting the round form pins the + * `beginDoomLoopRound` declaration end to end. + */ + for (const detection of detections) { + expect(detection.message).toContain('same set of 3 parallel calls'); + } + }); +}); + describe('simulated LLM repeating the same tool call', () => { it('observes at 2, blocks at 3 with an explanatory tool error, and lets the model recover', async () => { const executeSpy = vi.fn(async ({ query }: { query: string }) => ({ From 9fe31d2c563b3173f2368ae770c7427b6d15307d Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:51:52 -0500 Subject: [PATCH 24/32] fix(agent): validate restored round streak; honest undeclared verdict text; warn on declaration drops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from Cortex's fresh full review. The restored round streak now gets the same range validation as the per-call counts. isValidStreak only checks "finite number", so a tampered/corrupt blob carrying streak: -1e6 would hold that tool's guardrail below every rung (thresholds compare with >=), and the legacy fallback seeded the per-call detector with the same unvalidated value — the new sibling field had exactly the validation this one lacked. Clamped to [1, 1e6] with Math.floor rather than rejected: a corrupt streak degrades the entry, never drops the tool's evidence (fail-open, like every other restore path). The undeclared-path verdict text claimed "even as its other calls changed" for every server-tool and direct-monitor record — including a plain one-call-per- round repeat with no other calls, where the transcript contradicts the message. The text now asserts only what per-call evidence shows: this exact call repeated N consecutive rounds. declareRound's unhashable-member drop now warns with the tool name and cause, like every other fail-open path (the engine's own declaration already warned; the public method silently continued). Its docstring also no longer claims an undeclared fan-out "simply goes undetected" — false since per-call streaks: every repeated member accumulates without a declaration; what declaration adds is round-set evidence (shared verdict, one steer message). 685 unit tests, structural gate, typecheck, biome all clean. --- packages/agent/src/lib/doom-loop.ts | 54 ++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/packages/agent/src/lib/doom-loop.ts b/packages/agent/src/lib/doom-loop.ts index 86fa88e2..7f935220 100644 --- a/packages/agent/src/lib/doom-loop.ts +++ b/packages/agent/src/lib/doom-loop.ts @@ -889,15 +889,27 @@ function restoreStreakEntry(entry: DoomLoopStreak): StreakEntry { } } } + /* + * The round streak gets the same range validation as the per-call counts — + * the blob is caller-writable JSON, and `isValidStreak` only checks "finite + * number". A negative count would hold the guardrail below every rung for + * that tool (thresholds compare with `>=`); a fractional or absurd one + * would feed the ladder garbage. Clamped to a sane count rather than + * rejected: a corrupt streak must degrade the entry, not drop the tool's + * evidence entirely (fail-open, like every other restore path). The cap is + * generous — real streaks stop the run at single digits — and exists only + * to bound arithmetic, not to change semantics. + */ + const streak = Math.min(Math.max(Math.floor(entry.streak), 1), 1_000_000); return { fingerprint: entry.fingerprint, - streak: entry.streak, + streak, roundFingerprints: persistedSet, callStreaks: Object.keys(persistedCallStreaks).length > 0 ? persistedCallStreaks : { - [entry.fingerprint]: entry.streak, + [entry.fingerprint]: streak, }, }; } @@ -1006,11 +1018,15 @@ export class DoomLoopMonitor { * was also emission-order dependent: `[c,a,b]` never formed the matching * prefix and scored nothing. Declaring the whole set removes both. * - * Idempotent per round, and safe to skip: an undeclared round is scored - * per call against the previous round (the pre-fan-out semantics), so a - * fan-out simply goes undetected there rather than mis-scored — server-tool - * records take that path. Unhashable key material is skipped here; the - * caller's own fallback chain handles it at record time. Never serialized. + * Idempotent per round, and safe to skip: PER-CALL detection needs no + * declaration — every repeated `(tool, arguments)` identity accumulates its + * own consecutive-round count regardless, so an undeclared repeating + * fan-out still flags each repeated member (server-tool records take that + * path). What the declaration adds is round-set evidence: the fan-out + * scored as one unit, with a shared verdict and one steer message, instead + * of member-by-member. Unhashable key material is skipped here with a + * warning; the caller's own fallback chain handles it at record time. + * Never serialized. */ async declareRound( round: number, @@ -1024,9 +1040,17 @@ export class DoomLoopMonitor { let fingerprint: string; try { fingerprint = await fingerprintToolCall(call.toolName, call.keyMaterial); - } catch { - // Unhashable: leave it out of the declared set. recordToolCall's - // fallback chain decides this call's identity on its own. + } catch (error) { + // Unhashable: leave it out of the declared set — recordToolCall's + // fallback chain decides this call's identity on its own — but say + // so, like every other fail-open path: a direct consumer debugging + // an unexpectedly incomplete declaration needs the tool name and + // cause. (The engine warns separately on its own declaration path.) + console.warn( + `[DoomLoop] could not fingerprint a "${call.toolName}" call while declaring round ` + + `${round}; excluding it from the round set:`, + error, + ); continue; } fingerprints.set( @@ -1526,12 +1550,16 @@ function buildToolVerdictMessage(input: { * the steer dedupe collapses them to one correction. Reached when the * per-call streak decides, and when the round's true membership is unknown * (undeclared path) — in both cases quoting one call's hash would either - * be inaccurate for the round or diverge between its members. + * be inaccurate for the round or diverge between its members. The text + * asserts only what the per-call evidence actually shows — this one call + * repeated — because on the undeclared path the round's other calls (if + * any) are unknown, and a plain single-call repeat has no "other calls" + * at all. */ return ( `Doom loop suspected: this exact "${toolName}" call was repeated in ${Math.max(roundStreak, callStreak)} ` + - 'consecutive rounds, even as its other calls changed. Repeating it will not change ' + - 'the result. Take a different approach, or explain why repetition is required.' + 'consecutive rounds. Repeating it will not change the result. ' + + 'Take a different approach, or explain why repetition is required.' ); } if (callSet.length > 1) { From 6afab0dbe1789f710cdbc0dc7504f62978dba92e Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:24:09 -0500 Subject: [PATCH 25/32] perf(agent): parallel declaration digests, per-object fingerprint memo, sort-once sets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cortex's fresh review escalated three perf findings to a request-changes verdict. Two of the three are fixed here; measured before and after: width-100 fan-out, 3 rounds (declare + record each): before: 18.2ms (0.061 ms/call) after: 5.1ms (0.017 ms/call) — 3.6x - declareRound hashes independent digests concurrently (Promise.all) instead of awaiting each serially; declaration gates dispatch of the whole batch, so the serial awaits added one hash latency per call to every round. - A per-monitor WeakMap memoizes (keyMaterial object, toolName) -> fingerprint, so the declare-time and record-time hashes of the same resolved key material compute once. The engine hands the same object through its per-call resolution cache, which is what makes the memo hit; a fresh object with equal VALUE still hashes and still matches by fingerprint (covered by a probe that feeds fresh objects at declare and record and asserts streaks 1,2,3). Keyed by tool name too, since the tool participates in the hash. WeakMap so arguments are not retained beyond their natural lifetime; primitive key material (raw-string malformed args) skips the memo. - Declaration accumulates per-tool sets in a Set and sorts once per tool, replacing the per-member copy-and-sort. Record-time bookkeeping keeps its immutable copies deliberately — that discipline is what ended this PR's aliasing bugs, and record-time width is 1 object per call. The third finding (persisted-blob compaction) stays deferred: the persisted shape is the cross-port conformance contract, and indices-into-keys is a shape redesign for all ports in lockstep, not a patch. Measurement on the thread (13.75KB at width 100, linear, on a state that already carries the transcript). 685 unit tests, structural gate, typecheck, biome all clean. --- packages/agent/src/lib/doom-loop.ts | 93 +++++++++++++++++++++++------ 1 file changed, 74 insertions(+), 19 deletions(-) diff --git a/packages/agent/src/lib/doom-loop.ts b/packages/agent/src/lib/doom-loop.ts index 7f935220..dd7b05e3 100644 --- a/packages/agent/src/lib/doom-loop.ts +++ b/packages/agent/src/lib/doom-loop.ts @@ -1035,27 +1035,51 @@ export class DoomLoopMonitor { keyMaterial: unknown; }[], ): Promise { - const fingerprints = new Map(); - for (const call of calls) { - let fingerprint: string; - try { - fingerprint = await fingerprintToolCall(call.toolName, call.keyMaterial); - } catch (error) { - // Unhashable: leave it out of the declared set — recordToolCall's - // fallback chain decides this call's identity on its own — but say - // so, like every other fail-open path: a direct consumer debugging - // an unexpectedly incomplete declaration needs the tool name and - // cause. (The engine warns separately on its own declaration path.) - console.warn( - `[DoomLoop] could not fingerprint a "${call.toolName}" call while declaring round ` + - `${round}; excluding it from the round set:`, - error, - ); + /* + * Independent digests run concurrently — declaration gates dispatch of + * the whole batch, so serial awaits would add one hash latency per call + * to every round. Failures are per-call and fail open, as before. + */ + const hashed = await Promise.all( + calls.map(async (call) => { + try { + return { + call, + fingerprint: await this.fingerprintOnce(call.toolName, call.keyMaterial), + }; + } catch (error) { + // Unhashable: leave it out of the declared set — recordToolCall's + // fallback chain decides this call's identity on its own — but say + // so, like every other fail-open path: a direct consumer debugging + // an unexpectedly incomplete declaration needs the tool name and + // cause. (The engine warns separately on its own declaration path.) + console.warn( + `[DoomLoop] could not fingerprint a "${call.toolName}" call while declaring round ` + + `${round}; excluding it from the round set:`, + error, + ); + return null; + } + }), + ); + + /* Accumulate per tool, dedupe and sort ONCE per set — not per member. */ + const collected = new Map>(); + for (const entry of hashed) { + if (entry === null) { continue; } + const set = collected.get(entry.call.toolName) ?? new Set(); + set.add(entry.fingerprint); + collected.set(entry.call.toolName, set); + } + const fingerprints = new Map(); + for (const [toolName, set] of collected) { fingerprints.set( - call.toolName, - mergeFingerprint(fingerprints.get(call.toolName) ?? [], fingerprint), + toolName, + [ + ...set, + ].sort(), ); } this.declaredRound = { @@ -1064,6 +1088,37 @@ export class DoomLoopMonitor { }; } + /** + * Fingerprint with a per-object memo. The engine hands the SAME key-material + * object to `declareRound` and then to `recordToolCall` (the resolved + * `loopKey` output is cached per call id), so without this every checked + * call is canonicalized and SHA-256'd twice per round. WeakMap keeps the + * memo from retaining arguments beyond their natural lifetime; primitive + * key material (raw-string malformed args) skips the memo — it cannot key a + * WeakMap, and such calls are rare and small. Callers that mutate key + * material between declare and record get the declare-time identity, which + * is the identity the round was declared with — the consistent choice. + */ + private readonly fingerprintMemo = new WeakMap>(); + private async fingerprintOnce(toolName: string, keyMaterial: unknown): Promise { + const memoizable = typeof keyMaterial === 'object' && keyMaterial !== null; + if (memoizable) { + // Keyed by object AND tool name: the tool name participates in the + // hash, so one arguments object used by two tools must not collide. + const hit = this.fingerprintMemo.get(keyMaterial as object)?.get(toolName); + if (hit !== undefined) { + return hit; + } + } + const fingerprint = await fingerprintToolCall(toolName, keyMaterial); + if (memoizable) { + const perTool = this.fingerprintMemo.get(keyMaterial as object) ?? new Map(); + perTool.set(toolName, fingerprint); + this.fingerprintMemo.set(keyMaterial as object, perTool); + } + return fingerprint; + } + /** * True when the escalate rung can still fire: a mechanism is configured * and the budget is not exhausted. @@ -1237,7 +1292,7 @@ export class DoomLoopMonitor { detector?: Extract; }, ): Promise { - const fingerprint = await fingerprintToolCall(toolName, keyMaterial); + const fingerprint = await this.fingerprintOnce(toolName, keyMaterial); const previous = this.tools.get(toolName); const isSameRound = previous?.round !== undefined && previous.round === round; From af91a6c11a71975537114f6301173be04928342a Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:39:36 -0500 Subject: [PATCH 26/32] fix(agent): poison the loop-key cache on duplicate call ids instead of aliasing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cortex caught a false assumption my own comment asserted: "keyed by call id (required on ParsedToolCall, unique per round)". The id is required by the TYPE, but ids are model-emitted strings and nothing upstream enforces uniqueness within a batch. Last-write-wins meant a colliding id aliased the second call's key material onto the first call's checkpoint — the first call's true fingerprint was never recorded, so a model emitting (id=X, read a), (id=X, read b_i) each round evaded the per-call detector for `a` entirely. Measured: zero detections across four such rounds. An id seen twice in one batch now maps to a DUPLICATE_CALL_ID sentinel, and the checkpoint treats a poisoned entry as a cache miss: both colliding calls fall through to per-call resolution. Cost is at most a duplicate loopKey invocation for protocol-malformed calls only — correctness over the single-invocation economy exactly where the input is already out of spec. Well-formed batches are unaffected. Regression test drives four dup-id rounds through callModel and asserts the repeated call still reaches observe and block; fails against the previous commit with zero detections. 686 unit tests, structural gate, typecheck, biome all clean. --- packages/agent/src/lib/model-result.ts | 42 +++++++++-- .../tests/unit/doom-loop-integration.test.ts | 74 +++++++++++++++++++ 2 files changed, 110 insertions(+), 6 deletions(-) diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index 60a1073e..3cf7498f 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -157,6 +157,16 @@ function extractServerToolIdentity(item: ServerToolResultItem): Record(); + // of resolving again. Cleared with the round. An id seen twice in one batch + // maps to DUPLICATE_CALL_ID — see the write site. + private readonly doomLoopRoundKeyMaterial = new Map< + string, + LoopKeyResolution | typeof DUPLICATE_CALL_ID + >(); // Serialization chain for doom evaluations: parallel tool executions // append their evaluation here in call order (the .map() over a round's // calls runs synchronously to the first await), so streak recording @@ -1403,9 +1417,25 @@ export class ModelResult< ); continue; } - // Cache so the per-call checkpoint does not invoke `loopKey` a second - // time; keyed by call id (required on ParsedToolCall, unique per round). - this.doomLoopRoundKeyMaterial.set(toolCall.id, resolution); + /* + * Cache so the per-call checkpoint does not invoke `loopKey` a second + * time; keyed by call id. Ids are MODEL-emitted strings and nothing + * upstream enforces uniqueness, so a duplicate id must not alias one + * call's identity onto another: overwriting here meant the first call's + * checkpoint read the second call's key material, its true fingerprint + * was never recorded, and a model emitting `(id=X, read a), (id=X, + * read b_i)` each round evaded the per-call detector for `a` entirely + * (measured: zero detections across four such rounds). On a duplicate + * id the cache poisons that id instead: both calls fall through to + * per-call resolution at the checkpoint, costing at most a duplicate + * `loopKey` invocation for the colliding calls — correctness over the + * single-invocation economy, for protocol-malformed input only. + */ + if (this.doomLoopRoundKeyMaterial.has(toolCall.id)) { + this.doomLoopRoundKeyMaterial.set(toolCall.id, DUPLICATE_CALL_ID); + } else { + this.doomLoopRoundKeyMaterial.set(toolCall.id, resolution); + } if (resolution.kind === 'exempt') { continue; } @@ -1587,7 +1617,7 @@ export class ModelResult< */ const cached = this.doomLoopRoundKeyMaterial.get(toolCall.id); let resolution: LoopKeyResolution; - if (cached !== undefined) { + if (cached !== undefined && cached !== DUPLICATE_CALL_ID) { resolution = cached; } else { /* diff --git a/packages/agent/tests/unit/doom-loop-integration.test.ts b/packages/agent/tests/unit/doom-loop-integration.test.ts index 8c547371..b78301ab 100644 --- a/packages/agent/tests/unit/doom-loop-integration.test.ts +++ b/packages/agent/tests/unit/doom-loop-integration.test.ts @@ -373,6 +373,80 @@ describe('simulated LLM repeating a distinct-argument fan-out', () => { }); }); +describe('simulated LLM emitting duplicate call ids', () => { + it('a repeat sharing its id with a varying sibling is still detected', async () => { + /* + * Call ids are model-emitted; nothing upstream enforces uniqueness. The + * loop-key cache is keyed by id, and last-write-wins aliasing meant the + * first call's checkpoint read the SECOND call's key material — its true + * fingerprint was never recorded, so `(id=X, read a), (id=X, read b_i)` + * each round evaded the per-call detector for `a` entirely (measured: + * zero detections in four rounds). A colliding id now poisons its cache + * entry and both calls fall through to per-call resolution. + */ + const readTool = tool({ + name: 'read', + inputSchema: z.object({ + path: z.string(), + }), + outputSchema: z.object({ + content: z.string(), + }), + execute: async ({ path }) => ({ + content: path, + }), + }); + const detections: DoomLoopDetectedPayload[] = []; + const hooks = new HooksManager(); + hooks.on('DoomLoopDetected', { + handler: (payload) => { + detections.push(payload); + }, + }); + + /* Every round: repeated `a` and a fresh `b`, BOTH with callId X. */ + const dupIdTurn = (round: number): models.OpenResponsesResult => + baseResponse( + [ + { + path: 'a', + }, + { + path: `b${round}`, + }, + ].map((args, index) => { + callCounter++; + return { + type: 'function_call', + id: `fc_${callCounter}_${index}`, + callId: 'call_X', + name: 'read', + arguments: JSON.stringify(args), + status: 'completed', + }; + }), + ); + scriptModelTurns(dupIdTurn(0), dupIdTurn(1), dupIdTurn(2), dupIdTurn(3), textTurn('done')); + + await callModel(client, { + model: 'test-model', + input: 'go', + tools: [ + readTool, + ] as const, + doomLoop: true, + hooks, + }) + .getText() + .catch(() => undefined); + + /* The repeated `a` accumulates despite the id collision. */ + const actions = detections.map((detection) => detection.action); + expect(actions).toContain('observe'); + expect(actions).toContain('block'); + }); +}); + describe('simulated LLM repeating the same tool call', () => { it('observes at 2, blocks at 3 with an explanatory tool error, and lets the model recover', async () => { const executeSpy = vi.fn(async ({ query }: { query: string }) => ({ From b10f3fbc050bd3bf6eb5aeef80036b984f50d687 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:47:14 -0500 Subject: [PATCH 27/32] docs(agent): a mixed-evidence round renders one message per distinct fact, not one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Devin traced that one declared round can produce TWO steer messages: [a], [a,b], [a,b] puts `a` on the per-call branch (3-peat call) and `b` on the round-set branch (2-peat set) in the same round. Verified: 2 verdicts, 2 distinct messages. Keeping the behavior, correcting the documentation. The two messages state two DIFFERENT facts — "this exact call repeated 3 rounds" and "this 2-call set repeated 2 rounds" — and both are true and independently actionable; collapsing them (deciding the branch once per round, as suggested) would either suppress the stronger per-call fact for `a` or misattribute it to `b`. What the shaping guarantees — and what the README previously overstated as "one message per round" — is one message per distinct piece of evidence: same evidence renders byte-identical text, so the steer queue is bounded at two messages per tool per round (each a distinct fact), never one per call. The wide-round test pins N-collapses-to-1 for single-evidence rounds; the new test pins exactly-2 for the mixed round, with the branch texts asserted. 687 unit tests, structural gate, typecheck, biome all clean. --- packages/agent/README.md | 14 ++-- .../agent/tests/unit/doom-loop-fanout.test.ts | 69 +++++++++++++++++++ 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/packages/agent/README.md b/packages/agent/README.md index a37b2af8..d06fd13a 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -307,11 +307,15 @@ Two kinds of evidence accumulate side by side, and the stronger one decides: double-fires. When a repeating fan-out crosses a rung, every call in the round gets the -verdict (so `block` stops the whole fan-out, not just one member) and they -share one message, so the `steer` rung injects a single correction. When the -per-call count alone crosses a rung, only that call is refused — its verdict -quotes its own identity, and genuinely new round-mates run free. The streak -crosses a graduated ladder — strongest crossed rung wins: +verdict (so `block` stops the whole fan-out, not just one member), and calls +carrying the SAME evidence share byte-identical text — the `steer` rung +dedupes on exact text, so one piece of evidence injects one correction. A +round can carry two pieces of evidence at once (`[a]`, `[a,b]`, `[a,b]`: by +round 3, `a` is a 3-peat call while `{a,b}` is a 2-peat set), in which case +each renders its own message — at most two per tool per round, each stating +a distinct fact. When the per-call count alone crosses a rung, only that +call is refused and genuinely new round-mates run free. The streak crosses +a graduated ladder — strongest crossed rung wins: | Action | Effect | |---|---| diff --git a/packages/agent/tests/unit/doom-loop-fanout.test.ts b/packages/agent/tests/unit/doom-loop-fanout.test.ts index 4d677826..551b17ff 100644 --- a/packages/agent/tests/unit/doom-loop-fanout.test.ts +++ b/packages/agent/tests/unit/doom-loop-fanout.test.ts @@ -1113,6 +1113,75 @@ describe('same-tool fan-out streaks', () => { ]); }); + it('bounds a mixed-evidence round to one message per distinct fact', async () => { + /* + * One round can carry TWO pieces of evidence: `[a]`, `[a,b]`, `[a,b]` — + * by round 3, `a` is a 3-peat call (per-call branch) while `{a,b}` is a + * 2-peat set (round branch), so the round legitimately renders two + * DIFFERENT messages stating two different facts. What must hold is the + * bound: same evidence -> byte-identical text, so the steer queue carries + * at most one message per distinct fact per tool per round — never one + * per call. The wide-round test below pins the N-collapses-to-1 case; + * this pins the two-facts case at exactly 2, with the members of each + * fact sharing text. + */ + const detector = new DoomLoopMonitor( + resolveDoomLoopOption({ + ladder: { + steer: 2, + }, + }), + ); + const rounds = [ + [ + 'a', + ], + [ + 'a', + 'b', + ], + [ + 'a', + 'b', + ], + ]; + const lastRound = rounds.length - 1; + const messages: string[] = []; + for (const [round, paths] of rounds.entries()) { + await detector.declareRound( + round, + paths.map((path) => ({ + toolName: 'read', + keyMaterial: { + path, + }, + })), + ); + for (const path of paths) { + const record = await detector.recordToolCall( + 'read', + { + path, + }, + round, + ); + if (round === lastRound && record.verdict) { + messages.push(record.verdict.message); + } + } + } + + /* + * Final round: `a` is a 3-peat call (per-call branch, count 3) while + * `{a,b}` is a 2-peat set (`b` ties the round streak, round-set branch). + * Two verdicts, two distinct messages — one per fact, not one per call. + */ + expect(messages).toHaveLength(2); + expect(new Set(messages).size).toBe(2); + expect(messages[0]).toContain('3 consecutive rounds'); + expect(messages[1]).toContain('same set of 2 parallel calls'); + }); + it('collapses per-call steer messages across a wide repeating round', async () => { /* * When per-call counts decide for MANY members at once (a wide fan-out From 1d74522fa7fe4ee095540cf477faf73cc2d76e31 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:49:43 -0500 Subject: [PATCH 28/32] docs(agent): catalogue the declared-but-timed-out phantom as a known residual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Devin's invariant audit verified all three mirror paths hold and found one residual the mirroring cannot cover: a per-request timeout/abort can cancel a call after declaration but before its checkpoint, leaving a declared phantom for that round. Documented at the INVARIANT comment with the assessment — bounded (round-set streak resets when the phantom stops recurring), fail-safe (per-call streaks unaffected, so detection degrades to per-call rather than being lost), and not worth un-declaring mid-round, which would reintroduce the order-dependence this design eliminates. --- packages/agent/src/lib/model-result.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index b3e98e72..e11af642 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -1545,6 +1545,15 @@ export class ModelResult< * declaration and the checkpoint — a pre-execution gate, a batch filter — * it MUST be reflected here, or fan-out detection degrades silently for * that tool rather than failing loudly. + * + * KNOWN RESIDUAL (documented, not mirrored): a per-request timeout or + * abort can cancel a call AFTER declaration but BEFORE its checkpoint, + * leaving a declared phantom for that round. Bounded and fail-safe: the + * round-set streak for that tool resets when the phantom stops recurring, + * while per-call streaks are unaffected — detection degrades to per-call + * for the affected tool rather than being lost. Un-declaring mid-round + * would mutate the round's identity while it is being scored, which is the + * incremental-set order-dependence this design exists to prevent. */ private async beginDoomLoopRound(batch: readonly ParsedToolCall[] = []): Promise { const monitor = this.doomLoopMonitor; From 5b9044be98778714397b3b7efd05d8e74685f7ce Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:31:14 -0500 Subject: [PATCH 29/32] perf(agent): O(1) round bookkeeping; omit reconstructible per-call counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on wide fan-outs, both real: recordToolCall did O(W) work per call of a W-wide round — mergeFingerprint spread and SORTED the seen-list on every record (sorting bought nothing: its only consumer is the duplicateInRound membership check, while round identity uses the declared set, deduped and sorted once in declareRound), and the callStreaks accumulator was rebuilt via spread per record. The seen-list is now a run-local Set grown in place, and callStreaks grows in place within a round (growCallStreaks) — a fresh object per round keeps the prior-round baseline aliasing one-directional. Both are never serialized mid-round; getState() copies before persisting. Persisted state stored each wide round's 64-char hashes twice: once in roundFingerprints, once as callStreaks keys (~13 KB per width-100 round, copied on every save). In the steady state of a repeating fan-out every member's count equals the round streak, so the counts carry no information beyond the set: getState() now omits them exactly then (callStreaksReconstructible), and restore() rebuilds {member: streak} for the WHOLE set — not just the last-recorded member, which would have reset W-1 members' evidence on resume. Counts that differ from the round streak (shrunk rounds, non-members) persist verbatim, as before. Mutation-verified: rebuilding only the last fingerprint fails the 'scores a resumed SINGLE call on its own earned evidence' test. 92/92 doom-loop tests pass; full suite green; gate reports no degradation. --- packages/agent/src/lib/doom-loop.ts | 112 +++++++++++++----- .../agent/tests/unit/doom-loop-fanout.test.ts | 13 +- 2 files changed, 93 insertions(+), 32 deletions(-) diff --git a/packages/agent/src/lib/doom-loop.ts b/packages/agent/src/lib/doom-loop.ts index 246e451a..b3a7ced9 100644 --- a/packages/agent/src/lib/doom-loop.ts +++ b/packages/agent/src/lib/doom-loop.ts @@ -847,6 +847,32 @@ function isValidStreak(value: unknown): value is DoomLoopStreak { ); } +/** + * True when an entry's per-call counts carry no information beyond its round + * set and round streak, so getState() may omit them and restore() can rebuild + * them exactly. Holds in the steady state of a repeating fan-out — every + * member has been issued in the same consecutive rounds, so every count equals + * the round streak. Persisting both in that state stored each 64-char hash + * twice (once in the set, once as a count key): ~13 KB per width-100 round, + * copied on every save. The counts must also cover exactly the set members — + * a shrunk round (count > streak) or an extra recorded non-member is real + * evidence and is still persisted verbatim. + */ +function callStreaksReconstructible(entry: StreakEntry): boolean { + const counts = entry.callStreaks; + if (counts === undefined) { + return true; + } + const set = entry.roundFingerprints ?? [ + entry.fingerprint, + ]; + const keys = Object.keys(counts); + return ( + keys.length === set.length && + keys.every((key) => set.includes(key) && counts[key] === entry.streak) + ); +} + /** * Rebuild one tool's in-memory streak entry from its persisted shape. * @@ -904,12 +930,22 @@ function restoreStreakEntry(entry: DoomLoopStreak): StreakEntry { fingerprint: entry.fingerprint, streak, roundFingerprints: persistedSet, + /* + * Absent per-call counts mean getState() proved them reconstructible: + * every member of the round set carried exactly the round streak (the + * steady state of a repeating fan-out — see callStreaksReconstructible). + * Rebuild them for the WHOLE set, not just the last-recorded member, or a + * width-W fan-out would resume with W−1 members' evidence reset. + */ callStreaks: Object.keys(persistedCallStreaks).length > 0 ? persistedCallStreaks - : { - [entry.fingerprint]: streak, - }, + : Object.fromEntries( + persistedSet.map((member) => [ + member, + streak, + ]), + ), }; } @@ -941,9 +977,10 @@ interface StreakEntry extends DoomLoopStreak { * Fingerprints of {@link round} already recorded, used to collapse in-round * duplicates (one decision per `(tool, fingerprint)` per round). Distinct * from {@link roundFingerprints}, which is the round's declared whole. - * Never serialized: meaningful only within one round. + * Never serialized: meaningful only within one round — which is why it can + * be a Set: only serialized state needs a plain-array shape. */ - seenThisRound?: readonly string[]; + seenThisRound?: ReadonlySet; /** * The set the previous round was called with, and the streak it earned. * Carried unchanged for the length of {@link round} so that every call in @@ -1190,13 +1227,7 @@ export class DoomLoopMonitor { * the per-call count keeps climbing, and dropping it would hand * the repeat a fresh grace window on resume. */ - ...(entry.callStreaks !== undefined && - (Object.keys(entry.callStreaks).length > 1 || - (entry.roundFingerprints?.length ?? 0) > 1 || - Object.entries(entry.callStreaks).some( - ([callFingerprint, count]) => - callFingerprint !== entry.fingerprint || count !== entry.streak, - )) + ...(entry.callStreaks !== undefined && !callStreaksReconstructible(entry) ? { callStreaks: Object.fromEntries(Object.entries(entry.callStreaks)), } @@ -1310,8 +1341,8 @@ export class DoomLoopMonitor { : undefined; const declaredMember = declared?.includes(fingerprint) === true; - const seen = isSameRound ? (previous?.seenThisRound ?? []) : []; - const duplicateInRound = seen.includes(fingerprint); + const seen = isSameRound ? (previous?.seenThisRound ?? EMPTY_SEEN) : EMPTY_SEEN; + const duplicateInRound = seen.has(fingerprint); /* * The baseline every call of this round is measured against: the set the @@ -1386,7 +1417,18 @@ export class DoomLoopMonitor { ? previous.fingerprint : fingerprint, round, - seenThisRound: mergeFingerprint(seen, fingerprint), + /* + * Grown in place, O(1) per record: run-local, never serialized, and only + * the latest entry's set is ever read, so sharing the object between the + * previous and next entry is fine. The shared EMPTY_SEEN sentinel is the + * one instance that must never be mutated. + */ + seenThisRound: + seen === EMPTY_SEEN + ? new Set([ + fingerprint, + ]) + : (seen as Set).add(fingerprint), roundFingerprints: roundSet, streak: score(roundSet), ...(priorSet !== undefined @@ -1396,10 +1438,7 @@ export class DoomLoopMonitor { : {}), priorStreak, priorCallStreaks, - callStreaks: { - ...(isSameRound ? (previous?.callStreaks ?? {}) : {}), - [fingerprint]: callStreak, - }, + callStreaks: growCallStreaks(isSameRound ? previous : undefined, fingerprint, callStreak), }); /* @@ -1554,16 +1593,35 @@ function strongerVerdict( //#endregion -/** Adds a fingerprint to a round's sorted set, ignoring duplicates. */ -function mergeFingerprint(existing: readonly string[], fingerprint: string): readonly string[] { - return existing.includes(fingerprint) - ? existing - : [ - ...existing, - fingerprint, - ].sort(); +/** + * This round's per-call accumulator, grown in place — O(1) per record instead + * of an O(W) spread per call of a W-wide round. Safe because only the latest + * entry's object is ever read mid-round, and `priorCallStreaks` aliases the + * PREVIOUS round's object (a fresh object is created at each round + * transition), so mutating this round's accumulator never disturbs the + * baseline. getState() copies before persisting either. + */ +function growCallStreaks( + previous: StreakEntry | undefined, + fingerprint: string, + callStreak: number, +): Record { + if (previous?.callStreaks !== undefined) { + previous.callStreaks[fingerprint] = callStreak; + return previous.callStreaks; + } + return { + [fingerprint]: callStreak, + }; } +/** + * The empty seen-set, shared: `recordToolCall` runs per call, and most rounds + * open with no prior members — allocating a fresh empty Set for each would be + * churn for nothing. Never mutated (the write path copies before adding). + */ +const EMPTY_SEEN: ReadonlySet = new Set(); + /** * Short, stable identity for a round's fingerprint set: the first members' * prefixes, so every call of one round produces the same string (see the diff --git a/packages/agent/tests/unit/doom-loop-fanout.test.ts b/packages/agent/tests/unit/doom-loop-fanout.test.ts index 551b17ff..b5627684 100644 --- a/packages/agent/tests/unit/doom-loop-fanout.test.ts +++ b/packages/agent/tests/unit/doom-loop-fanout.test.ts @@ -1374,11 +1374,14 @@ describe('same-tool fan-out streaks', () => { }; expect(saved.tools.read.streak).toBe(2); expect(saved.tools.read.roundFingerprints).toHaveLength(3); - expect(Object.values(saved.tools.read.callStreaks ?? {})).toEqual([ - 2, - 2, - 2, - ]); + /* + * Steady state — every member's per-call count equals the round streak — + * is exactly the case where `callStreaks` carries no information beyond + * the set, so getState() omits it (persisting it stored each 64-char hash + * twice). restore() rebuilds `{member: streak}` for the whole set; the + * resume assertions below are what actually pin that reconstruction. + */ + expect(saved.tools.read.callStreaks).toBeUndefined(); /* * ANY member resumed alone continues its own earned count (2 -> 3): a From 1851290a4159e4f3c3dc9f46b95afbe82e85639e Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:53:46 -0500 Subject: [PATCH 30/32] fix(agent): make the per-call verdict text count-free so staggered repeats collapse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Devin caught the last diverging element in the per-call message: it interpolated Math.max(roundStreak, callStreak), so members of one round whose repeats started at different times rendered different strings. An expanding fan-out — [a], [a,b], [a,b,c], [a,b,c,d] — carries counts 4, 3, 2 in round 4; measured, that queued 3 near-identical corrections into one injected prompt. The documented "at most two messages per tool per round" bound was really the round's width; the existing test missed it because every member it checks shares one count. The per-call text now names no count (and, as before, no fingerprint), so every per-call verdict of one tool renders byte-identical and the exact-text steer dedupe collapses them. Exact counts still reach consumers via the verdict payload's streak field — asserted in the new test, which pins streaks [4,3,2] alongside exactly one distinct message and fails against the previous text with "expected 3 to be 1". The builder docstring now states the enforced bound explicitly. 792 unit tests, structural gate, typecheck clean. (The one lint warning is the pre-existing unused variable in agent-tool.test.ts from the main merge, not mine.) --- packages/agent/src/lib/doom-loop.ts | 32 ++++---- .../agent/tests/unit/doom-loop-fanout.test.ts | 78 ++++++++++++++++++- 2 files changed, 96 insertions(+), 14 deletions(-) diff --git a/packages/agent/src/lib/doom-loop.ts b/packages/agent/src/lib/doom-loop.ts index b3a7ced9..3005384a 100644 --- a/packages/agent/src/lib/doom-loop.ts +++ b/packages/agent/src/lib/doom-loop.ts @@ -1645,9 +1645,13 @@ function setsMatch(left: readonly string[], right: readonly string[]): boolean { * the ROUND's identity — identical text for every call, deliberately, because * the steer rung dedupes queued guidance by exact message text and one round * of evidence must not queue N near-identical corrections. When the PER-CALL - * streak alone decides (its count exceeds the round's), only this one call is - * the evidence, so quoting its own fingerprint is both accurate and - * dedupe-safe: no other call of the round carries the same verdict. + * streak alone decides (its count exceeds the round's), the text carries + * neither the call's fingerprint nor its exact count: both vary between + * members of one round (an expanding fan-out holds counts 4, 3, 2 at once), + * and either would split one piece of guidance into per-member strings. The + * enforced bound is therefore at most TWO distinct messages per tool per + * round — one per-call text, one round-set text — each stating a distinct + * fact; exact counts live in the verdict payload's `streak`. * * `roundDeclared` says whether the detector was told the round's true * membership. Only then does `callSet` describe the round, so only then may the @@ -1670,18 +1674,20 @@ function buildToolVerdictMessage(input: { const { toolName, fingerprint, callSet, roundStreak, callStreak, roundDeclared } = input; if (callStreak > roundStreak || !roundDeclared) { /* - * Fingerprint-free, so every member of the round renders identically and - * the steer dedupe collapses them to one correction. Reached when the - * per-call streak decides, and when the round's true membership is unknown - * (undeclared path) — in both cases quoting one call's hash would either - * be inaccurate for the round or diverge between its members. The text - * asserts only what the per-call evidence actually shows — this one call - * repeated — because on the undeclared path the round's other calls (if - * any) are unknown, and a plain single-call repeat has no "other calls" - * at all. + * Fingerprint-free AND count-free, so every per-call verdict of one tool + * renders byte-identical text and the steer dedupe collapses them to one + * correction. Reached when the per-call streak decides, and when the + * round's true membership is unknown (undeclared path). Both varying + * elements had to go for the dedupe to hold: quoting one call's hash + * diverged the members of a wide round, and quoting the exact count + * diverged members whose repeats started at different times (an expanding + * fan-out carries counts 4, 3, 2 in one round — three near-identical + * strings for one piece of guidance). The exact count still reaches + * consumers via the verdict payload's `streak`; the prose tells the model + * what to do about it, which does not depend on the number. */ return ( - `Doom loop suspected: this exact "${toolName}" call was repeated in ${Math.max(roundStreak, callStreak)} ` + + `Doom loop suspected: this exact "${toolName}" call has been repeated across ` + 'consecutive rounds. Repeating it will not change the result. ' + 'Take a different approach, or explain why repetition is required.' ); diff --git a/packages/agent/tests/unit/doom-loop-fanout.test.ts b/packages/agent/tests/unit/doom-loop-fanout.test.ts index b5627684..a1810d98 100644 --- a/packages/agent/tests/unit/doom-loop-fanout.test.ts +++ b/packages/agent/tests/unit/doom-loop-fanout.test.ts @@ -1113,6 +1113,82 @@ describe('same-tool fan-out streaks', () => { ]); }); + it('collapses STAGGERED per-call counts in one round to a single message', async () => { + /* + * An expanding fan-out carries different per-call counts in one round — + * `[a]`, `[a,b]`, `[a,b,c]`, `[a,b,c,d]` puts a=4, b=3, c=2 in round 4. + * The per-call message used to interpolate each call's own count, so the + * three verdicts rendered three near-identical strings and the exact-text + * steer dedupe queued all of them: the real bound was the round's WIDTH, + * not the documented two. The per-call text is now count-free (the exact + * count still reaches consumers via the verdict payload's `streak`), so + * every per-call verdict of one tool collapses to one correction. + */ + const detector = new DoomLoopMonitor( + resolveDoomLoopOption({ + ladder: { + steer: 2, + }, + }), + ); + const rounds = [ + [ + 'a', + ], + [ + 'a', + 'b', + ], + [ + 'a', + 'b', + 'c', + ], + [ + 'a', + 'b', + 'c', + 'd', + ], + ]; + const messages: string[] = []; + const streaks: number[] = []; + for (const [round, paths] of rounds.entries()) { + await detector.declareRound( + round, + paths.map((path) => ({ + toolName: 'read', + keyMaterial: { + path, + }, + })), + ); + for (const path of paths) { + const record = await detector.recordToolCall( + 'read', + { + path, + }, + round, + ); + if (round === 3 && record.verdict) { + messages.push(record.verdict.message); + streaks.push(record.verdict.streak); + } + } + } + + /* Three verdicts with distinct counts — the payload keeps the numbers... */ + expect(streaks).toEqual([ + 4, + 3, + 2, + ]); + /* ...but the prose is byte-identical, so steer queues ONE correction. */ + expect(messages).toHaveLength(3); + expect(new Set(messages).size).toBe(1); + }); + it('bounds a mixed-evidence round to one message per distinct fact', async () => { /* * One round can carry TWO pieces of evidence: `[a]`, `[a,b]`, `[a,b]` — @@ -1178,7 +1254,7 @@ describe('same-tool fan-out streaks', () => { */ expect(messages).toHaveLength(2); expect(new Set(messages).size).toBe(2); - expect(messages[0]).toContain('3 consecutive rounds'); + expect(messages[0]).toContain('this exact "read" call has been repeated'); expect(messages[1]).toContain('same set of 2 parallel calls'); }); From baa6b6ecbaafb295068d54b7d373c0220ffc1f56 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:06:54 -0500 Subject: [PATCH 31/32] docs(agent): name the expanding-fan-out anchor case in the false-positive class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Devin noted the changeset's false-positive paragraph was framed around fixed sets, while the expanding case — a repeated anchor call whose round-mates change every round, so the ROUND detector calls it progress yet the per-call detector still blocks it from round 3 — is a distinct and less obvious consequence that never fired on main. Both variants are now named, with the rationale (an already-read file is in context; re-reading is spend without progress), the loopKey escape, and the ladder's built-in warning rounds. --- .changeset/doom-loop-fanout.md | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/.changeset/doom-loop-fanout.md b/.changeset/doom-loop-fanout.md index f18432c0..90ae330f 100644 --- a/.changeset/doom-loop-fanout.md +++ b/.changeset/doom-loop-fanout.md @@ -62,13 +62,24 @@ and `callStreaks`, both above); everything existing is untouched and old blobs restore cleanly with their old semantics. **Newly reachable false positive.** The detector compares arguments, not -results, so a tool invoked with a stable *set* of parallel arguments every round -now accumulates a streak where it previously could not — an agent re-reading the -same context files each turn, or a fixed fan-out of pollers, is refused at the -default `block` rung from round 3, with one synthesized error per call in the -round. Exempt such tools with `loopKey: false` (or a `loopKey` returning `null` -for the call). This class was invisible to the detector before, so no existing -exemption covered it. +results, so repetition shapes that were previously invisible now accumulate and +are refused at the default `block` rung from round 3. Two variants: + +- A stable *set* of parallel arguments every round — an agent re-reading the + same context files each turn, or a fixed fan-out of pollers — blocks with one + synthesized error per call in the round. +- A single call re-issued verbatim while its round-mates CHANGE — re-reading an + anchor file (README, config, schema) while exploring new files each turn + (`[a]`, `[a,b]`, `[a,b,c]`: `a` blocks from round 3 even though every round + adds work). The per-call detector counts the call's own consecutive rounds, + so the round being "progress" does not exempt a member that itself repeats: + a file already read is in context, and re-reading it is spend without + progress. + +Exempt such tools with `loopKey: false` (or a `loopKey` returning `null` for +the call). These classes were invisible to the detector before, so no existing +exemption covered them; the graduated ladder gives every shape a free round and +an `observe` warning before anything is refused. For `callModel` users, nothing to change — `doomLoop` is configured exactly as before, and the engine declares each round for you. What changed is when it From 0dbaf9e5bba2df097e7fda620305b81c27fce853 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:19:21 -0500 Subject: [PATCH 32/32] fix(agent): export ResolvedEscalationConfig alongside ResolvedDoomLoopConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Devin caught that ResolvedDoomLoopConfig is exported while the type of its escalation field is not, so a consumer assembling or narrowing the config by hand had no importable name for that member — the same class of gap as the original resolveDoomLoopOption omission, caught the same way (by using the export rather than eyeballing it). Verified with a strict-mode tsc snippet that names both types through the package entrypoint. --- packages/agent/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index dc7420af..3b612660 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -139,6 +139,7 @@ export type { DoomLoopVerdict, LoopKeyResolution, ResolvedDoomLoopConfig, + ResolvedEscalationConfig, TextRepetitionResult, } from './lib/doom-loop.js'; export {