Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
f46f74c
fix(agent): detect a repeated same-tool fan-out
LukasParke Jul 29, 2026
eb3b51d
fix(agent): score a doom-loop round's declared set, not a growing prefix
LukasParke Jul 30, 2026
81c2572
fix(agent): don't share a round streak across an undeclared round
LukasParke Jul 30, 2026
f44c69d
fix(agent): scope round-streak sharing to declared members; call loop…
LukasParke Jul 30, 2026
a9d8b9c
fix(agent): keep a non-member record from clobbering the declared rou…
LukasParke Jul 30, 2026
43b88c2
fix(agent): don't run loopKey for calls the detector never checks; ad…
LukasParke Jul 30, 2026
ebc5538
fix(agent): don't carry a fan-out streak onto a single call across a …
LukasParke Jul 30, 2026
905bc7a
docs(agent): correct false changeset claims; document the new false-p…
LukasParke Jul 30, 2026
de42c4e
fix(agent): don't declare calls the round will never record
LukasParke Jul 30, 2026
2c5dbbd
fix(agent): guard loop-identity resolution; persist the streak agains…
LukasParke Jul 30, 2026
1afde41
docs(agent): correct the undeclared-round semantics claim; add a decl…
LukasParke Jul 30, 2026
bb1e57b
feat(agent): export resolveDoomLoopOption so DoomLoopMonitor is const…
LukasParke Jul 31, 2026
fc79004
refactor(agent): collapse recordToolCall scoring to one rule over two…
LukasParke Jul 31, 2026
b864967
feat(agent): persist a fan-out's round set so its streak survives sav…
LukasParke Jul 31, 2026
5860b26
docs(agent): state the declared-must-be-recorded invariant at the dec…
LukasParke Jul 31, 2026
fb8ea28
fix(agent): copy the round set into getState snapshots instead of ali…
LukasParke Jul 31, 2026
f8584f5
docs(agent): document the fixed-baseline mid-round match on the undec…
LukasParke Jul 31, 2026
6fcc74a
feat(agent): per-call streaks — flag a repeated call whose round-mate…
LukasParke Jul 31, 2026
7ab4731
refactor(agent): extract the verdict-message builder to satisfy the s…
LukasParke Jul 31, 2026
3f27fd6
refactor(agent): extract restoreStreakEntry — the gate's 10th complex…
LukasParke Jul 31, 2026
f3776e0
fix(agent): persist lone per-call evidence; collapse per-call steer t…
LukasParke Jul 31, 2026
d956562
Merge remote-tracking branch 'origin/main' into lukeparke/doom-loop-f…
LukasParke Aug 3, 2026
aaa2d83
fix(agent): one verdict text per undeclared multi-call round
LukasParke Aug 3, 2026
254b211
Merge remote-tracking branch 'origin/main' into lukeparke/doom-loop-f…
LukasParke Aug 3, 2026
15a7a9f
test(agent): end-to-end fan-out detection through callModel
LukasParke Aug 4, 2026
9fe31d2
fix(agent): validate restored round streak; honest undeclared verdict…
LukasParke Aug 4, 2026
6afab0d
perf(agent): parallel declaration digests, per-object fingerprint mem…
LukasParke Aug 4, 2026
af91a6c
fix(agent): poison the loop-key cache on duplicate call ids instead o…
LukasParke Aug 4, 2026
43d06bd
Merge branch 'main' into lukeparke/doom-loop-fanout-streaks
cortex-github-agent[bot] Aug 4, 2026
7e4d960
Merge branch 'main' into lukeparke/doom-loop-fanout-streaks
cortex-github-agent[bot] Aug 4, 2026
b10f3fb
docs(agent): a mixed-evidence round renders one message per distinct …
LukasParke Aug 4, 2026
7720959
Merge branch 'lukeparke/doom-loop-fanout-streaks' of https://github.c…
LukasParke Aug 4, 2026
1d74522
docs(agent): catalogue the declared-but-timed-out phantom as a known …
LukasParke Aug 4, 2026
054116a
Merge branch 'main' into lukeparke/doom-loop-fanout-streaks
cortex-github-agent[bot] Aug 4, 2026
625279b
Merge remote-tracking branch 'origin/main' into lukeparke/doom-loop-f…
LukasParke Aug 5, 2026
5b9044b
perf(agent): O(1) round bookkeeping; omit reconstructible per-call co…
LukasParke Aug 5, 2026
ee22240
Merge remote-tracking branch 'origin/lukeparke/doom-loop-fanout-strea…
LukasParke Aug 5, 2026
1851290
fix(agent): make the per-call verdict text count-free so staggered re…
LukasParke Aug 5, 2026
baa6b6e
docs(agent): name the expanding-fan-out anchor case in the false-posi…
LukasParke Aug 5, 2026
0dbaf9e
fix(agent): export ResolvedEscalationConfig alongside ResolvedDoomLoo…
LukasParke Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions .changeset/doom-loop-fanout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
---
'@openrouter/agent': minor
---

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. 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.

**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)
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 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
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
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, 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 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 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
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 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
// 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
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, resolveDoomLoopOption } from '@openrouter/agent';

const monitor = new DoomLoopMonitor(resolveDoomLoopOption(true));
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

for (const [round, batch] of batches.entries()) {
// 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 })),
);

for (const call of batch) {
const { verdict } = await monitor.recordToolCall(call.name, call.arguments, round);
if (verdict?.action === 'block') refuse(call, verdict.message);
}
}
```
55 changes: 49 additions & 6 deletions packages/agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,11 +285,37 @@ 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).

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 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 |
|---|---|
Expand Down Expand Up @@ -380,6 +406,18 @@ via `_meta['openrouter/loopKey']`. MCP-wrapped tools accept a `loopKey`
via `markMcp(tool, { loopKey })` or the `loopKeys` map on
`createMCPTools`.

> **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
Expand Down Expand Up @@ -415,6 +453,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).
- **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.

### Async Tools

Expand Down Expand Up @@ -661,7 +704,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` |
Comment thread
LukasParke marked this conversation as resolved.

Notes on lifecycle pairing: `SessionEnd` only fires when a matching
`SessionStart` succeeded, and at most once per run. Pending async hook work is
Expand Down
3 changes: 3 additions & 0 deletions packages/agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ export type {
DoomLoopTextOptions,
DoomLoopVerdict,
LoopKeyResolution,
ResolvedDoomLoopConfig,
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
ResolvedEscalationConfig,
TextRepetitionResult,
} from './lib/doom-loop.js';
export {
Expand All @@ -149,6 +151,7 @@ export {
fingerprintKeyMaterial,
fingerprintToolCall,
MAX_CANONICALIZE_DEPTH,
resolveDoomLoopOption,
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
resolveLoopKeyMaterial,
} from './lib/doom-loop.js';
// Lifecycle hooks system (PreToolUse, PostToolUse, Stop, SessionStart, ...).
Expand Down
Loading