-
Notifications
You must be signed in to change notification settings - Fork 11
fix(agent): detect a repeated same-tool fan-out #89
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 eb3b51d
fix(agent): score a doom-loop round's declared set, not a growing prefix
LukasParke 81c2572
fix(agent): don't share a round streak across an undeclared round
LukasParke f44c69d
fix(agent): scope round-streak sharing to declared members; call loop…
LukasParke a9d8b9c
fix(agent): keep a non-member record from clobbering the declared rou…
LukasParke 43b88c2
fix(agent): don't run loopKey for calls the detector never checks; ad…
LukasParke ebc5538
fix(agent): don't carry a fan-out streak onto a single call across a …
LukasParke 905bc7a
docs(agent): correct false changeset claims; document the new false-p…
LukasParke de42c4e
fix(agent): don't declare calls the round will never record
LukasParke 2c5dbbd
fix(agent): guard loop-identity resolution; persist the streak agains…
LukasParke 1afde41
docs(agent): correct the undeclared-round semantics claim; add a decl…
LukasParke bb1e57b
feat(agent): export resolveDoomLoopOption so DoomLoopMonitor is const…
LukasParke fc79004
refactor(agent): collapse recordToolCall scoring to one rule over two…
LukasParke b864967
feat(agent): persist a fan-out's round set so its streak survives sav…
LukasParke 5860b26
docs(agent): state the declared-must-be-recorded invariant at the dec…
LukasParke fb8ea28
fix(agent): copy the round set into getState snapshots instead of ali…
LukasParke f8584f5
docs(agent): document the fixed-baseline mid-round match on the undec…
LukasParke 6fcc74a
feat(agent): per-call streaks — flag a repeated call whose round-mate…
LukasParke 7ab4731
refactor(agent): extract the verdict-message builder to satisfy the s…
LukasParke 3f27fd6
refactor(agent): extract restoreStreakEntry — the gate's 10th complex…
LukasParke f3776e0
fix(agent): persist lone per-call evidence; collapse per-call steer t…
LukasParke d956562
Merge remote-tracking branch 'origin/main' into lukeparke/doom-loop-f…
LukasParke aaa2d83
fix(agent): one verdict text per undeclared multi-call round
LukasParke 254b211
Merge remote-tracking branch 'origin/main' into lukeparke/doom-loop-f…
LukasParke 15a7a9f
test(agent): end-to-end fan-out detection through callModel
LukasParke 9fe31d2
fix(agent): validate restored round streak; honest undeclared verdict…
LukasParke 6afab0d
perf(agent): parallel declaration digests, per-object fingerprint mem…
LukasParke af91a6c
fix(agent): poison the loop-key cache on duplicate call ids instead o…
LukasParke 43d06bd
Merge branch 'main' into lukeparke/doom-loop-fanout-streaks
cortex-github-agent[bot] 7e4d960
Merge branch 'main' into lukeparke/doom-loop-fanout-streaks
cortex-github-agent[bot] b10f3fb
docs(agent): a mixed-evidence round renders one message per distinct …
LukasParke 7720959
Merge branch 'lukeparke/doom-loop-fanout-streaks' of https://github.c…
LukasParke 1d74522
docs(agent): catalogue the declared-but-timed-out phantom as a known …
LukasParke 054116a
Merge branch 'main' into lukeparke/doom-loop-fanout-streaks
cortex-github-agent[bot] 625279b
Merge remote-tracking branch 'origin/main' into lukeparke/doom-loop-f…
LukasParke 5b9044b
perf(agent): O(1) round bookkeeping; omit reconstructible per-call co…
LukasParke ee22240
Merge remote-tracking branch 'origin/lukeparke/doom-loop-fanout-strea…
LukasParke 1851290
fix(agent): make the per-call verdict text count-free so staggered re…
LukasParke baa6b6e
docs(agent): name the expanding-fan-out anchor case in the false-posi…
LukasParke 0dbaf9e
fix(agent): export ResolvedEscalationConfig alongside ResolvedDoomLoo…
LukasParke File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)); | ||
|
|
||
| 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); | ||
| } | ||
| } | ||
| ``` | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.