@openrouter/agent 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +732 -7
- package/THIRD_PARTY_NOTICES.md +25 -0
- package/esm/index.d.ts +20 -5
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +10 -3
- package/esm/index.js.map +1 -1
- package/esm/inner-loop/call-model.d.ts.map +1 -1
- package/esm/inner-loop/call-model.js +63 -46
- package/esm/inner-loop/call-model.js.map +1 -1
- package/esm/inner-loop/resume-tool-results.d.ts +128 -0
- package/esm/inner-loop/resume-tool-results.d.ts.map +1 -0
- package/esm/inner-loop/resume-tool-results.js +244 -0
- package/esm/inner-loop/resume-tool-results.js.map +1 -0
- package/esm/lib/agent-tool.d.ts +92 -0
- package/esm/lib/agent-tool.d.ts.map +1 -0
- package/esm/lib/agent-tool.js +211 -0
- package/esm/lib/agent-tool.js.map +1 -0
- package/esm/lib/async-params.d.ts +96 -0
- package/esm/lib/async-params.d.ts.map +1 -1
- package/esm/lib/async-params.js +30 -1
- package/esm/lib/async-params.js.map +1 -1
- package/esm/lib/async-tool-registry.d.ts +134 -0
- package/esm/lib/async-tool-registry.d.ts.map +1 -0
- package/esm/lib/async-tool-registry.js +352 -0
- package/esm/lib/async-tool-registry.js.map +1 -0
- package/esm/lib/async-tools.d.ts +23 -0
- package/esm/lib/async-tools.d.ts.map +1 -0
- package/esm/lib/async-tools.js +19 -0
- package/esm/lib/async-tools.js.map +1 -0
- package/esm/lib/conversation-state.d.ts +1 -0
- package/esm/lib/conversation-state.d.ts.map +1 -1
- package/esm/lib/conversation-state.js +8 -0
- package/esm/lib/conversation-state.js.map +1 -1
- package/esm/lib/doom-loop.d.ts +533 -0
- package/esm/lib/doom-loop.d.ts.map +1 -0
- package/esm/lib/doom-loop.js +1221 -0
- package/esm/lib/doom-loop.js.map +1 -0
- package/esm/lib/hooks-schemas.d.ts +33 -0
- package/esm/lib/hooks-schemas.d.ts.map +1 -1
- package/esm/lib/hooks-schemas.js +54 -0
- package/esm/lib/hooks-schemas.js.map +1 -1
- package/esm/lib/hooks-types.d.ts +6 -2
- package/esm/lib/hooks-types.d.ts.map +1 -1
- package/esm/lib/hooks-types.js.map +1 -1
- package/esm/lib/model-result.d.ts +648 -12
- package/esm/lib/model-result.d.ts.map +1 -1
- package/esm/lib/model-result.js +2784 -164
- package/esm/lib/model-result.js.map +1 -1
- package/esm/lib/next-turn-params.d.ts.map +1 -1
- package/esm/lib/next-turn-params.js +3 -1
- package/esm/lib/next-turn-params.js.map +1 -1
- package/esm/lib/next-turn-params.test.js +72 -0
- package/esm/lib/next-turn-params.test.js.map +1 -1
- package/esm/lib/reusable-stream.d.ts +9 -0
- package/esm/lib/reusable-stream.d.ts.map +1 -1
- package/esm/lib/reusable-stream.js +17 -0
- package/esm/lib/reusable-stream.js.map +1 -1
- package/esm/lib/stream-transformers.d.ts +21 -1
- package/esm/lib/stream-transformers.d.ts.map +1 -1
- package/esm/lib/stream-transformers.js +45 -0
- package/esm/lib/stream-transformers.js.map +1 -1
- package/esm/lib/tool-check.d.ts +76 -0
- package/esm/lib/tool-check.d.ts.map +1 -0
- package/esm/lib/tool-check.js +241 -0
- package/esm/lib/tool-check.js.map +1 -0
- package/esm/lib/tool-concurrency.d.ts +42 -0
- package/esm/lib/tool-concurrency.d.ts.map +1 -0
- package/esm/lib/tool-concurrency.js +80 -0
- package/esm/lib/tool-concurrency.js.map +1 -0
- package/esm/lib/tool-context.d.ts +45 -1
- package/esm/lib/tool-context.d.ts.map +1 -1
- package/esm/lib/tool-context.js +60 -1
- package/esm/lib/tool-context.js.map +1 -1
- package/esm/lib/tool-executor.d.ts +52 -9
- package/esm/lib/tool-executor.d.ts.map +1 -1
- package/esm/lib/tool-executor.js +253 -21
- package/esm/lib/tool-executor.js.map +1 -1
- package/esm/lib/tool-orchestrator.d.ts.map +1 -1
- package/esm/lib/tool-orchestrator.js +87 -50
- package/esm/lib/tool-orchestrator.js.map +1 -1
- package/esm/lib/tool-set-types.d.ts +298 -0
- package/esm/lib/tool-set-types.d.ts.map +1 -0
- package/esm/lib/tool-set-types.js +2 -0
- package/esm/lib/tool-set-types.js.map +1 -0
- package/esm/lib/tool-set.d.ts +157 -0
- package/esm/lib/tool-set.d.ts.map +1 -0
- package/esm/lib/tool-set.js +473 -0
- package/esm/lib/tool-set.js.map +1 -0
- package/esm/lib/tool-task.d.ts +157 -0
- package/esm/lib/tool-task.d.ts.map +1 -0
- package/esm/lib/tool-task.js +206 -0
- package/esm/lib/tool-task.js.map +1 -0
- package/esm/lib/tool-types.d.ts +667 -38
- package/esm/lib/tool-types.d.ts.map +1 -1
- package/esm/lib/tool-types.js +59 -10
- package/esm/lib/tool-types.js.map +1 -1
- package/esm/lib/tool.d.ts +227 -19
- package/esm/lib/tool.d.ts.map +1 -1
- package/esm/lib/tool.js +206 -6
- package/esm/lib/tool.js.map +1 -1
- package/esm/mcp/auth/auth-resolver.d.ts +14 -0
- package/esm/mcp/auth/auth-resolver.d.ts.map +1 -0
- package/esm/mcp/auth/auth-resolver.js +27 -0
- package/esm/mcp/auth/auth-resolver.js.map +1 -0
- package/esm/mcp/auth/auth-types.d.ts +33 -0
- package/esm/mcp/auth/auth-types.d.ts.map +1 -0
- package/esm/mcp/auth/auth-types.js +4 -0
- package/esm/mcp/auth/auth-types.js.map +1 -0
- package/esm/mcp/build-tools.d.ts +29 -0
- package/esm/mcp/build-tools.d.ts.map +1 -0
- package/esm/mcp/build-tools.js +80 -0
- package/esm/mcp/build-tools.js.map +1 -0
- package/esm/mcp/cache/cache-store.d.ts +24 -0
- package/esm/mcp/cache/cache-store.d.ts.map +1 -0
- package/esm/mcp/cache/cache-store.js +20 -0
- package/esm/mcp/cache/cache-store.js.map +1 -0
- package/esm/mcp/cache/cache-types.d.ts +71 -0
- package/esm/mcp/cache/cache-types.d.ts.map +1 -0
- package/esm/mcp/cache/cache-types.js +49 -0
- package/esm/mcp/cache/cache-types.js.map +1 -0
- package/esm/mcp/cache/serialize.d.ts +25 -0
- package/esm/mcp/cache/serialize.d.ts.map +1 -0
- package/esm/mcp/cache/serialize.js +96 -0
- package/esm/mcp/cache/serialize.js.map +1 -0
- package/esm/mcp/close-quietly.d.ts +24 -0
- package/esm/mcp/close-quietly.d.ts.map +1 -0
- package/esm/mcp/close-quietly.js +30 -0
- package/esm/mcp/close-quietly.js.map +1 -0
- package/esm/mcp/create-mcp-tools.d.ts +13 -0
- package/esm/mcp/create-mcp-tools.d.ts.map +1 -0
- package/esm/mcp/create-mcp-tools.js +87 -0
- package/esm/mcp/create-mcp-tools.js.map +1 -0
- package/esm/mcp/elicitation.d.ts +11 -0
- package/esm/mcp/elicitation.d.ts.map +1 -0
- package/esm/mcp/elicitation.js +51 -0
- package/esm/mcp/elicitation.js.map +1 -0
- package/esm/mcp/errors.d.ts +91 -0
- package/esm/mcp/errors.d.ts.map +1 -0
- package/esm/mcp/errors.js +91 -0
- package/esm/mcp/errors.js.map +1 -0
- package/esm/mcp/handle.d.ts +66 -0
- package/esm/mcp/handle.d.ts.map +1 -0
- package/esm/mcp/handle.js +323 -0
- package/esm/mcp/handle.js.map +1 -0
- package/esm/mcp/index.d.ts +13 -0
- package/esm/mcp/index.d.ts.map +1 -0
- package/esm/mcp/index.js +11 -0
- package/esm/mcp/index.js.map +1 -0
- package/esm/mcp/mcp-connection.d.ts +150 -0
- package/esm/mcp/mcp-connection.d.ts.map +1 -0
- package/esm/mcp/mcp-connection.js +478 -0
- package/esm/mcp/mcp-connection.js.map +1 -0
- package/esm/mcp/mcp-sdk.d.ts +10 -0
- package/esm/mcp/mcp-sdk.d.ts.map +1 -0
- package/esm/mcp/mcp-sdk.js +37 -0
- package/esm/mcp/mcp-sdk.js.map +1 -0
- package/esm/mcp/rehydrate.d.ts +76 -0
- package/esm/mcp/rehydrate.d.ts.map +1 -0
- package/esm/mcp/rehydrate.js +505 -0
- package/esm/mcp/rehydrate.js.map +1 -0
- package/esm/mcp/resource-tools.d.ts +17 -0
- package/esm/mcp/resource-tools.d.ts.map +1 -0
- package/esm/mcp/resource-tools.js +155 -0
- package/esm/mcp/resource-tools.js.map +1 -0
- package/esm/mcp/result-mapper.d.ts +19 -0
- package/esm/mcp/result-mapper.d.ts.map +1 -0
- package/esm/mcp/result-mapper.js +40 -0
- package/esm/mcp/result-mapper.js.map +1 -0
- package/esm/mcp/schema/json-schema-guards.d.ts +14 -0
- package/esm/mcp/schema/json-schema-guards.d.ts.map +1 -0
- package/esm/mcp/schema/json-schema-guards.js +19 -0
- package/esm/mcp/schema/json-schema-guards.js.map +1 -0
- package/esm/mcp/schema/json-schema-to-zod.d.ts +20 -0
- package/esm/mcp/schema/json-schema-to-zod.d.ts.map +1 -0
- package/esm/mcp/schema/json-schema-to-zod.js +72 -0
- package/esm/mcp/schema/json-schema-to-zod.js.map +1 -0
- package/esm/mcp/tool-wrapper.d.ts +41 -0
- package/esm/mcp/tool-wrapper.d.ts.map +1 -0
- package/esm/mcp/tool-wrapper.js +131 -0
- package/esm/mcp/tool-wrapper.js.map +1 -0
- package/esm/mcp/transport-types.d.ts +45 -0
- package/esm/mcp/transport-types.d.ts.map +1 -0
- package/esm/mcp/transport-types.js +2 -0
- package/esm/mcp/transport-types.js.map +1 -0
- package/esm/mcp/types.d.ts +119 -0
- package/esm/mcp/types.d.ts.map +1 -0
- package/esm/mcp/types.js +2 -0
- package/esm/mcp/types.js.map +1 -0
- package/esm/mcp/version.d.ts +3 -0
- package/esm/mcp/version.d.ts.map +1 -0
- package/esm/mcp/version.js +5 -0
- package/esm/mcp/version.js.map +1 -0
- package/package.json +70 -5
|
@@ -0,0 +1,1221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doom-loop detection for the tool-execution loop.
|
|
3
|
+
*
|
|
4
|
+
* A "doom loop" is a run that stops making progress while continuing to spend:
|
|
5
|
+
* the model re-issues the same tool call with identical arguments (including
|
|
6
|
+
* repeated *empty* calls and repeated unparseable calls), or emits the same
|
|
7
|
+
* text tokens over and over. This module provides the deterministic detection
|
|
8
|
+
* primitives; `ModelResult` wires them into the loop behind the `doomLoop`
|
|
9
|
+
* option on `callModel`.
|
|
10
|
+
*
|
|
11
|
+
* Design principles:
|
|
12
|
+
*
|
|
13
|
+
* - **Deterministic.** Every verdict is a pure function of the recorded
|
|
14
|
+
* sequence of tool calls / assistant texts. The same scripted transcript
|
|
15
|
+
* produces the same verdicts at the same indices — no wall clocks, no
|
|
16
|
+
* randomness — so the feature is testable without a live model and
|
|
17
|
+
* portable across SDK ports via shared test vectors.
|
|
18
|
+
* - **Cross-port fingerprints.** Key material is canonicalized per RFC 8785
|
|
19
|
+
* (JCS) and hashed with SHA-256 over the UTF-8 bytes. Ports (Python/Go)
|
|
20
|
+
* MUST use a JCS implementation (e.g. pip `jcs`,
|
|
21
|
+
* `cyberphone/json-canonicalization`), not their stdlib JSON serializer —
|
|
22
|
+
* the shared vector file `tests/vectors/doom-loop-fingerprints.json` is
|
|
23
|
+
* the conformance contract.
|
|
24
|
+
* - **Tool-declared identity.** A tool opts into precise loop identity via
|
|
25
|
+
* `loopKey` on its definition: a function computing key material
|
|
26
|
+
* (e.g. `({ command, cwd }) => ({ command, cwd })`), a field-name array
|
|
27
|
+
* (`['command', 'cwd']` — data, not code, so it can ride MCP tool caches
|
|
28
|
+
* and the `_meta['openrouter/loopKey']` wire field), or `false` to exempt
|
|
29
|
+
* the tool. Tools declare *what identifies a call*; this module owns
|
|
30
|
+
* canonicalization and hashing so fingerprints are uniform across tools
|
|
31
|
+
* and ports.
|
|
32
|
+
* - **Round-scoped streaks.** A streak measures the model *re-issuing a call
|
|
33
|
+
* after seeing its result*, which requires a model round trip. N identical
|
|
34
|
+
* calls fanned out in ONE round count once; the duplicates share that
|
|
35
|
+
* round's decision. A round's identity for one tool is the *set* of
|
|
36
|
+
* fingerprints it was called with, so a fan-out of distinct arguments
|
|
37
|
+
* (`read(a), read(b), read(c)`) reissued verbatim accumulates, and every
|
|
38
|
+
* call in the round reports the round's streak. Ports must declare a
|
|
39
|
+
* round's complete set before scoring any of its calls (see
|
|
40
|
+
* `declareRound`): scoring a set as it accumulates makes a superset round
|
|
41
|
+
* transiently match the previous one, which both fires on real progress
|
|
42
|
+
* and makes the outcome depend on emission order.
|
|
43
|
+
* - **Graduated response.** Detection feeds a configurable action ladder
|
|
44
|
+
* (observe → steer → block → stop) rather than killing the run outright.
|
|
45
|
+
* - **Never the cause of failure.** Detection may only affect a run through
|
|
46
|
+
* its defined actions. Any internal error (unhashable key material,
|
|
47
|
+
* throwing `loopKey`, corrupt persisted state) degrades to a fallback
|
|
48
|
+
* identity or skips detection for that call — it never rejects the call
|
|
49
|
+
* or crashes the run.
|
|
50
|
+
*/
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region Defaults & Config Resolution
|
|
53
|
+
/**
|
|
54
|
+
* Default ladder: observe at 2 consecutive identical rounds, block at 3,
|
|
55
|
+
* stop at 6. Steer is off by default (blocking already delivers feedback in
|
|
56
|
+
* the tool output, where the model looks first).
|
|
57
|
+
*/
|
|
58
|
+
export const DEFAULT_DOOM_LOOP_LADDER = Object.freeze({
|
|
59
|
+
observe: 2,
|
|
60
|
+
steer: false,
|
|
61
|
+
escalate: false,
|
|
62
|
+
block: 3,
|
|
63
|
+
stop: 6,
|
|
64
|
+
});
|
|
65
|
+
/** Default escalation budget when the rung is enabled without a cap. */
|
|
66
|
+
export const DEFAULT_MAX_ESCALATIONS = 2;
|
|
67
|
+
const DEFAULT_TEXT_OPTIONS = Object.freeze({
|
|
68
|
+
enabled: true,
|
|
69
|
+
maxPeriodTokens: 16,
|
|
70
|
+
minRepeats: 4,
|
|
71
|
+
minCoveredTokens: 12,
|
|
72
|
+
maxWindowTokens: 400,
|
|
73
|
+
});
|
|
74
|
+
function sanitizeThreshold(value, fallback) {
|
|
75
|
+
if (value === false) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 1) {
|
|
79
|
+
return Math.floor(value);
|
|
80
|
+
}
|
|
81
|
+
return fallback;
|
|
82
|
+
}
|
|
83
|
+
/** Ladder rungs ordered weakest → strongest, for dead-rung analysis. */
|
|
84
|
+
const LADDER_ORDER = [
|
|
85
|
+
'observe',
|
|
86
|
+
'steer',
|
|
87
|
+
'escalate',
|
|
88
|
+
'block',
|
|
89
|
+
'stop',
|
|
90
|
+
];
|
|
91
|
+
/**
|
|
92
|
+
* Warn about configurations that are accepted but probably not what the
|
|
93
|
+
* caller meant:
|
|
94
|
+
*
|
|
95
|
+
* - `block` enabled with `stop: false` — blocked calls still increment the
|
|
96
|
+
* streak, so a model that keeps re-issuing the call produces an unbounded
|
|
97
|
+
* block/re-issue ping-pong bounded only by `stopWhen` (which defaults to
|
|
98
|
+
* unbounded). Explicitly allowed, loudly flagged.
|
|
99
|
+
* - Dead rungs — with strongest-wins resolution, a weaker rung whose
|
|
100
|
+
* threshold is >= an enabled stronger rung's threshold can never fire
|
|
101
|
+
* (e.g. `{ observe: 5, block: 2 }`: block wins from streak 2 on).
|
|
102
|
+
*/
|
|
103
|
+
function warnOnLadderHazards(ladder) {
|
|
104
|
+
if (ladder.block !== false && ladder.stop === false) {
|
|
105
|
+
console.warn('[DoomLoop] ladder has block enabled with stop disabled: a model that keeps ' +
|
|
106
|
+
're-issuing a blocked call loops indefinitely (each blocked round still costs a ' +
|
|
107
|
+
'model request). Bound the run with stopWhen, or enable the stop rung.');
|
|
108
|
+
}
|
|
109
|
+
for (let weak = 0; weak < LADDER_ORDER.length; weak++) {
|
|
110
|
+
const weakName = LADDER_ORDER[weak];
|
|
111
|
+
const weakThreshold = ladder[weakName];
|
|
112
|
+
if (weakThreshold === false) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
for (let strong = weak + 1; strong < LADDER_ORDER.length; strong++) {
|
|
116
|
+
const strongName = LADDER_ORDER[strong];
|
|
117
|
+
const strongThreshold = ladder[strongName];
|
|
118
|
+
if (strongThreshold !== false && weakThreshold >= strongThreshold) {
|
|
119
|
+
console.warn(`[DoomLoop] ladder rung "${weakName}" (${weakThreshold}) can never fire: ` +
|
|
120
|
+
`stronger rung "${strongName}" (${strongThreshold}) already wins at that streak ` +
|
|
121
|
+
'(strongest crossed rung is applied).');
|
|
122
|
+
break; // one warning per dead rung is enough
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
export function resolveDoomLoopOption(option) {
|
|
128
|
+
if (option === undefined || option === false) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
const config = option === true ? {} : option;
|
|
132
|
+
const ladderInput = config.ladder ?? {};
|
|
133
|
+
const textInput = config.text === undefined || config.text === true ? {} : config.text;
|
|
134
|
+
const ladder = {
|
|
135
|
+
observe: sanitizeThreshold(ladderInput.observe, DEFAULT_DOOM_LOOP_LADDER.observe),
|
|
136
|
+
steer: sanitizeThreshold(ladderInput.steer, DEFAULT_DOOM_LOOP_LADDER.steer),
|
|
137
|
+
escalate: sanitizeThreshold(ladderInput.escalate, DEFAULT_DOOM_LOOP_LADDER.escalate),
|
|
138
|
+
block: sanitizeThreshold(ladderInput.block, DEFAULT_DOOM_LOOP_LADDER.block),
|
|
139
|
+
stop: sanitizeThreshold(ladderInput.stop, DEFAULT_DOOM_LOOP_LADDER.stop),
|
|
140
|
+
};
|
|
141
|
+
warnOnLadderHazards(ladder);
|
|
142
|
+
// Escalation recovery: usable only when the config names at least one
|
|
143
|
+
// mechanism. An enabled rung without a mechanism (or vice versa, a
|
|
144
|
+
// mechanism without the rung) is probably a config mistake — warn, and
|
|
145
|
+
// treat the rung as absent so verdicts fall through to weaker rungs.
|
|
146
|
+
const escalationInput = config.escalation;
|
|
147
|
+
// `advisor: false` is an explicit opt-out, not a mechanism. The recovery step
|
|
148
|
+
// skips a disabled advisor (see model-result.ts), so counting it here would
|
|
149
|
+
// resolve an escalation rung that consumes budget and announces recovery
|
|
150
|
+
// without applying any override — `{ advisor: false }` alone must read the
|
|
151
|
+
// same as `{}`.
|
|
152
|
+
const hasAdvisor = escalationInput?.advisor !== undefined && escalationInput.advisor !== false;
|
|
153
|
+
const hasMechanism = escalationInput !== undefined && (escalationInput.model !== undefined || hasAdvisor);
|
|
154
|
+
let escalation = null;
|
|
155
|
+
if (hasMechanism) {
|
|
156
|
+
const cap = escalationInput.maxEscalations;
|
|
157
|
+
escalation = {
|
|
158
|
+
...(escalationInput.model !== undefined && {
|
|
159
|
+
model: escalationInput.model,
|
|
160
|
+
}),
|
|
161
|
+
...(hasAdvisor && {
|
|
162
|
+
advisor: escalationInput.advisor,
|
|
163
|
+
}),
|
|
164
|
+
maxEscalations: typeof cap === 'number' && Number.isFinite(cap) && cap >= 1
|
|
165
|
+
? Math.floor(cap)
|
|
166
|
+
: DEFAULT_MAX_ESCALATIONS,
|
|
167
|
+
};
|
|
168
|
+
if (ladder.escalate === false) {
|
|
169
|
+
console.warn('[DoomLoop] escalation config provided but the escalate ladder rung is disabled; ' +
|
|
170
|
+
'set ladder.escalate to a streak threshold for recovery to trigger.');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
else if (ladder.escalate !== false) {
|
|
174
|
+
console.warn('[DoomLoop] ladder.escalate is enabled but no escalation config (model/advisor) was ' +
|
|
175
|
+
'provided; the rung is skipped and verdicts fall through to weaker rungs.');
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
ladder,
|
|
179
|
+
escalation,
|
|
180
|
+
text: textInput === false
|
|
181
|
+
? {
|
|
182
|
+
...DEFAULT_TEXT_OPTIONS,
|
|
183
|
+
enabled: false,
|
|
184
|
+
}
|
|
185
|
+
: {
|
|
186
|
+
enabled: textInput.enabled ?? DEFAULT_TEXT_OPTIONS.enabled,
|
|
187
|
+
maxPeriodTokens: textInput.maxPeriodTokens ?? DEFAULT_TEXT_OPTIONS.maxPeriodTokens,
|
|
188
|
+
minRepeats: textInput.minRepeats ?? DEFAULT_TEXT_OPTIONS.minRepeats,
|
|
189
|
+
minCoveredTokens: textInput.minCoveredTokens ?? DEFAULT_TEXT_OPTIONS.minCoveredTokens,
|
|
190
|
+
maxWindowTokens: textInput.maxWindowTokens ?? DEFAULT_TEXT_OPTIONS.maxWindowTokens,
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
//#endregion
|
|
195
|
+
//#region loopKey Resolution
|
|
196
|
+
/**
|
|
197
|
+
* Resolve a tool's `loopKey` declaration (function, `false`, or absent)
|
|
198
|
+
* against one call's validated arguments.
|
|
199
|
+
*
|
|
200
|
+
* - absent (`undefined` declaration) → the full arguments object.
|
|
201
|
+
* - `false` → the tool is statically exempt.
|
|
202
|
+
* - function → called with the arguments, like every other tool hook.
|
|
203
|
+
* `null` exempts THIS call; `undefined` falls back to the full arguments
|
|
204
|
+
* (with a warning — a bare `return;` is almost always a bug, and treating
|
|
205
|
+
* it as key material would collide every call of the tool onto one
|
|
206
|
+
* fingerprint); a throw falls back to the full arguments (detection must
|
|
207
|
+
* never take down a run).
|
|
208
|
+
*/
|
|
209
|
+
export function resolveLoopKeyMaterial(loopKey, args) {
|
|
210
|
+
if (loopKey === undefined) {
|
|
211
|
+
return {
|
|
212
|
+
kind: 'key',
|
|
213
|
+
keyMaterial: args,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
if (loopKey === false) {
|
|
217
|
+
return {
|
|
218
|
+
kind: 'exempt',
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (Array.isArray(loopKey)) {
|
|
222
|
+
/*
|
|
223
|
+
* An empty field list would make EVERY call to the tool fingerprint
|
|
224
|
+
* identically — `ls` and `rm -rf /` collapse to one identity — so a
|
|
225
|
+
* detector armed to block a repeat would refuse the second unrelated call.
|
|
226
|
+
* That is a footgun from a local declaration and a vector once the list can
|
|
227
|
+
* be advertised over the wire by an MCP server (`_meta['openrouter/loopKey']`).
|
|
228
|
+
* Fall back to full arguments with a warning, mirroring how the function
|
|
229
|
+
* form handles a degenerate result rather than trusting it.
|
|
230
|
+
*/
|
|
231
|
+
if (loopKey.length === 0) {
|
|
232
|
+
return {
|
|
233
|
+
kind: 'fallback',
|
|
234
|
+
keyMaterial: args,
|
|
235
|
+
warning: 'loopKey is an empty field list, which would give every call to this tool the same identity; falling back to full arguments. Use `false` to exempt the tool.',
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const subset = Object.create(null);
|
|
239
|
+
for (const field of loopKey) {
|
|
240
|
+
if (typeof field === 'string' && field in args) {
|
|
241
|
+
subset[field] = args[field];
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/*
|
|
245
|
+
* Every declared field missing from the arguments is the same collapse by
|
|
246
|
+
* another route: the subset is empty, so identity is again constant.
|
|
247
|
+
*/
|
|
248
|
+
if (Object.keys(subset).length === 0) {
|
|
249
|
+
return {
|
|
250
|
+
kind: 'fallback',
|
|
251
|
+
keyMaterial: args,
|
|
252
|
+
warning: `loopKey fields [${loopKey.filter((f) => typeof f === 'string').join(', ')}] are all absent from the arguments, which would give every call the same identity; falling back to full arguments.`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
kind: 'key',
|
|
257
|
+
keyMaterial: subset,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
if (typeof loopKey === 'function') {
|
|
261
|
+
let result;
|
|
262
|
+
try {
|
|
263
|
+
result = loopKey(args);
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
return {
|
|
267
|
+
kind: 'fallback',
|
|
268
|
+
keyMaterial: args,
|
|
269
|
+
warning: `loopKey threw (${error instanceof Error ? error.message : String(error)}); falling back to full arguments`,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
if (result === null) {
|
|
273
|
+
return {
|
|
274
|
+
kind: 'exempt',
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
if (result === undefined) {
|
|
278
|
+
return {
|
|
279
|
+
kind: 'fallback',
|
|
280
|
+
keyMaterial: args,
|
|
281
|
+
warning: 'loopKey returned undefined; falling back to full arguments. Return null to exempt a call, or a value to use as identity.',
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
kind: 'key',
|
|
286
|
+
keyMaterial: result,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
kind: 'fallback',
|
|
291
|
+
keyMaterial: args,
|
|
292
|
+
warning: `loopKey has unsupported shape (${typeof loopKey}); falling back to full arguments`,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
//#endregion
|
|
296
|
+
//#region Canonicalization & Fingerprinting
|
|
297
|
+
/**
|
|
298
|
+
* Maximum nesting depth accepted by {@link canonicalizeKeyMaterial}. Real
|
|
299
|
+
* tool arguments come from parsed JSON and stay shallow; anything deeper is
|
|
300
|
+
* hostile or buggy key material and must fail fast (with a catchable error)
|
|
301
|
+
* instead of overflowing the stack.
|
|
302
|
+
*/
|
|
303
|
+
export const MAX_CANONICALIZE_DEPTH = 64;
|
|
304
|
+
/**
|
|
305
|
+
* RFC 8785 (JCS) canonical JSON: object keys sorted by UTF-16 code units
|
|
306
|
+
* (recursively), arrays in order, strings and finite numbers serialized per
|
|
307
|
+
* ECMAScript `JSON.stringify` (which IS the JCS serialization — e.g. `-0`
|
|
308
|
+
* canonicalizes to `0`, `1e21` to `1e+21`, lone surrogates to `\udXXX`
|
|
309
|
+
* escapes). Insensitive to key insertion order, so `{a:1,b:2}` and
|
|
310
|
+
* `{b:2,a:1}` canonicalize identically.
|
|
311
|
+
*
|
|
312
|
+
* This is the cross-port contract: Python and Go ports must produce
|
|
313
|
+
* byte-identical canonical strings for the same key material, which means
|
|
314
|
+
* they MUST use an RFC 8785 implementation (pip `jcs`,
|
|
315
|
+
* `cyberphone/json-canonicalization`), not their stdlib JSON serializer
|
|
316
|
+
* (Python's `json.dumps(-0.0)` is `-0.0`, which is NOT JCS).
|
|
317
|
+
*
|
|
318
|
+
* JSON-data-model semantics for JS-only values:
|
|
319
|
+
* - `undefined` / function / symbol object entries are dropped (as
|
|
320
|
+
* `JSON.stringify` does); in arrays they serialize as `null`.
|
|
321
|
+
*
|
|
322
|
+
* @throws Error on values RFC 8785 cannot represent — non-finite numbers,
|
|
323
|
+
* bigint — plus circular references and nesting deeper than
|
|
324
|
+
* {@link MAX_CANONICALIZE_DEPTH}. Real tool arguments (parsed JSON) can
|
|
325
|
+
* never trigger these; only computed `loopKey` material can, and the engine
|
|
326
|
+
* catches the error and falls back to the full-arguments identity.
|
|
327
|
+
*/
|
|
328
|
+
export function canonicalizeKeyMaterial(value) {
|
|
329
|
+
const seen = new Set();
|
|
330
|
+
const canon = (v, depth) => {
|
|
331
|
+
if (depth > MAX_CANONICALIZE_DEPTH) {
|
|
332
|
+
throw new Error(`Cannot canonicalize key material nested deeper than ${MAX_CANONICALIZE_DEPTH} levels for doom-loop fingerprinting`);
|
|
333
|
+
}
|
|
334
|
+
if (v === null || v === undefined) {
|
|
335
|
+
return 'null';
|
|
336
|
+
}
|
|
337
|
+
switch (typeof v) {
|
|
338
|
+
case 'number':
|
|
339
|
+
if (!Number.isFinite(v)) {
|
|
340
|
+
throw new Error('Cannot canonicalize non-finite number (NaN/Infinity) for doom-loop fingerprinting: RFC 8785 has no representation');
|
|
341
|
+
}
|
|
342
|
+
return JSON.stringify(v);
|
|
343
|
+
case 'string':
|
|
344
|
+
case 'boolean':
|
|
345
|
+
return JSON.stringify(v);
|
|
346
|
+
case 'bigint':
|
|
347
|
+
throw new Error('Cannot canonicalize bigint for doom-loop fingerprinting: RFC 8785 has no representation (convert to string or number in loopKey)');
|
|
348
|
+
case 'object':
|
|
349
|
+
break;
|
|
350
|
+
default:
|
|
351
|
+
// function / symbol — not representable; JSON semantics say null here.
|
|
352
|
+
return 'null';
|
|
353
|
+
}
|
|
354
|
+
const obj = v;
|
|
355
|
+
if (seen.has(obj)) {
|
|
356
|
+
throw new Error('Cannot canonicalize circular key material for doom-loop fingerprinting');
|
|
357
|
+
}
|
|
358
|
+
seen.add(obj);
|
|
359
|
+
try {
|
|
360
|
+
if (Array.isArray(obj)) {
|
|
361
|
+
return `[${obj.map((item) => canon(item, depth + 1)).join(',')}]`;
|
|
362
|
+
}
|
|
363
|
+
const record = obj;
|
|
364
|
+
const keys = Object.keys(record)
|
|
365
|
+
.filter((key) => {
|
|
366
|
+
const entry = record[key];
|
|
367
|
+
return entry !== undefined && typeof entry !== 'function' && typeof entry !== 'symbol';
|
|
368
|
+
})
|
|
369
|
+
.sort();
|
|
370
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${canon(record[key], depth + 1)}`).join(',')}}`;
|
|
371
|
+
}
|
|
372
|
+
finally {
|
|
373
|
+
seen.delete(obj);
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
return canon(value, 0);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* SHA-256 over UTF-8 bytes via WebCrypto — no dependency, no `node:`
|
|
380
|
+
* builtin, so the module stays edge/browser-safe. `globalThis.crypto` is
|
|
381
|
+
* available unflagged in Node ≥19 (Node 18 required
|
|
382
|
+
* `--experimental-global-webcrypto` and is EOL), Bun, Deno, workers, and
|
|
383
|
+
* browsers; the supported floor is the active Node LTS (CI runs 22).
|
|
384
|
+
* Lone surrogates never reach the encoder: `JSON.stringify` escapes them as
|
|
385
|
+
* `\udXXX` (ASCII) inside the canonical string.
|
|
386
|
+
*/
|
|
387
|
+
async function sha256Hex(canonical) {
|
|
388
|
+
const bytes = new TextEncoder().encode(canonical);
|
|
389
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes);
|
|
390
|
+
const view = new Uint8Array(digest);
|
|
391
|
+
let hex = '';
|
|
392
|
+
for (let i = 0; i < view.length; i++) {
|
|
393
|
+
hex += view[i].toString(16).padStart(2, '0');
|
|
394
|
+
}
|
|
395
|
+
return hex;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Fingerprint arbitrary key material: `sha256(utf8(jcs(keyMaterial)))`,
|
|
399
|
+
* lowercase hex (64 chars).
|
|
400
|
+
*/
|
|
401
|
+
export function fingerprintKeyMaterial(keyMaterial) {
|
|
402
|
+
return sha256Hex(canonicalizeKeyMaterial(keyMaterial));
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Fingerprint a tool call: `sha256(utf8(toolName + '\n' + jcs(keyMaterial)))`,
|
|
406
|
+
* lowercase hex. The tool name participates so `search({q})` and
|
|
407
|
+
* `fetch({q})` with equal arguments never share a fingerprint. This exact
|
|
408
|
+
* construction is the cross-port contract — see
|
|
409
|
+
* `tests/vectors/doom-loop-fingerprints.json`.
|
|
410
|
+
*/
|
|
411
|
+
export function fingerprintToolCall(toolName, keyMaterial) {
|
|
412
|
+
return sha256Hex(`${toolName}\n${canonicalizeKeyMaterial(keyMaterial)}`);
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Detect a period-p token block repeating at the *tail* of `text` — the
|
|
416
|
+
* canonical shape of an in-response token doom loop ("I am stuck. I am
|
|
417
|
+
* stuck. I am stuck. …"). Pure and deterministic: whitespace tokenization,
|
|
418
|
+
* suffix comparison, no heuristics beyond the thresholds.
|
|
419
|
+
*
|
|
420
|
+
* Only the trailing region of the text is examined: a fixed character
|
|
421
|
+
* budget (64 chars per window token) is sliced off the tail before
|
|
422
|
+
* tokenization, so multi-megabyte responses do not pay a full-text token
|
|
423
|
+
* split. The budget is part of the deterministic contract (same text ⇒
|
|
424
|
+
* same slice ⇒ same result).
|
|
425
|
+
*
|
|
426
|
+
* Ties on covered tokens prefer the smallest period (the most repeats), so
|
|
427
|
+
* "no no no no no no" reports p=1 × 6, not p=2 × 3 — the repeat count is
|
|
428
|
+
* what feeds the ladder.
|
|
429
|
+
*
|
|
430
|
+
* Returns null when no block meets `minRepeats` and `minCoveredTokens`
|
|
431
|
+
* within `maxPeriodTokens` / `maxWindowTokens`.
|
|
432
|
+
*
|
|
433
|
+
* KNOWN LIMIT: token blocks must repeat *exactly*. Paraphrased loops ("I am
|
|
434
|
+
* stuck" / "I appear to be stuck" / …) do not trip this detector.
|
|
435
|
+
*/
|
|
436
|
+
export function detectTextRepetition(text, options) {
|
|
437
|
+
const maxPeriodTokens = options?.maxPeriodTokens ?? DEFAULT_TEXT_OPTIONS.maxPeriodTokens;
|
|
438
|
+
const minRepeats = options?.minRepeats ?? DEFAULT_TEXT_OPTIONS.minRepeats;
|
|
439
|
+
const minCoveredTokens = options?.minCoveredTokens ?? DEFAULT_TEXT_OPTIONS.minCoveredTokens;
|
|
440
|
+
const maxWindowTokens = options?.maxWindowTokens ?? DEFAULT_TEXT_OPTIONS.maxWindowTokens;
|
|
441
|
+
// Bound the tokenization cost: 64 chars/token is a generous upper bound
|
|
442
|
+
// for whitespace-delimited tokens in practice; the window slice below
|
|
443
|
+
// still enforces the exact token count.
|
|
444
|
+
const charBudget = maxWindowTokens * 64;
|
|
445
|
+
const tail = text.length > charBudget ? text.slice(-charBudget) : text;
|
|
446
|
+
const allTokens = tail.trim().split(/\s+/).filter(Boolean);
|
|
447
|
+
const tokens = allTokens.slice(-maxWindowTokens);
|
|
448
|
+
if (tokens.length < Math.max(minCoveredTokens, 2)) {
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
let best = null;
|
|
452
|
+
const maxPeriod = Math.min(maxPeriodTokens, Math.floor(tokens.length / 2));
|
|
453
|
+
for (let period = 1; period <= maxPeriod; period++) {
|
|
454
|
+
let repeats = 1;
|
|
455
|
+
outer: for (let start = tokens.length - 2 * period; start >= 0; start -= period) {
|
|
456
|
+
for (let i = 0; i < period; i++) {
|
|
457
|
+
if (tokens[start + i] !== tokens[tokens.length - period + i]) {
|
|
458
|
+
break outer;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
repeats++;
|
|
462
|
+
}
|
|
463
|
+
const coveredTokens = repeats * period;
|
|
464
|
+
if (repeats >= minRepeats && coveredTokens >= minCoveredTokens) {
|
|
465
|
+
if (!best || coveredTokens > best.coveredTokens) {
|
|
466
|
+
best = {
|
|
467
|
+
repeats,
|
|
468
|
+
periodTokens: period,
|
|
469
|
+
coveredTokens,
|
|
470
|
+
sample: tokens.slice(-period).join(' '),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return best;
|
|
476
|
+
}
|
|
477
|
+
//#endregion
|
|
478
|
+
//#region Ladder Resolution
|
|
479
|
+
/**
|
|
480
|
+
* Map a streak onto the strongest crossed ladder rung.
|
|
481
|
+
*
|
|
482
|
+
* `allowBlock: false` is used for text and server-tool verdicts: the tokens
|
|
483
|
+
* are already emitted / the tool already ran server-side, so there is
|
|
484
|
+
* nothing to block — a block-level streak falls through to escalate/steer
|
|
485
|
+
* (when enabled) or observe, while stop still stops.
|
|
486
|
+
*
|
|
487
|
+
* `allowEscalate: false` disables the escalate rung for this resolution —
|
|
488
|
+
* used when no escalation mechanism is configured or the run's escalation
|
|
489
|
+
* budget is exhausted; the streak falls through to the weaker rungs.
|
|
490
|
+
* Escalation applies to the NEXT model request, so it is available to every
|
|
491
|
+
* detector kind (tool, server-tool, and text verdicts alike).
|
|
492
|
+
*/
|
|
493
|
+
export function resolveLadderAction(ladder, streak, options) {
|
|
494
|
+
const meets = (threshold) => threshold !== false && streak >= threshold;
|
|
495
|
+
if (meets(ladder.stop)) {
|
|
496
|
+
return 'stop';
|
|
497
|
+
}
|
|
498
|
+
if (options.allowBlock && meets(ladder.block)) {
|
|
499
|
+
return 'block';
|
|
500
|
+
}
|
|
501
|
+
if ((options.allowEscalate ?? true) && meets(ladder.escalate)) {
|
|
502
|
+
return 'escalate';
|
|
503
|
+
}
|
|
504
|
+
if (meets(ladder.steer)) {
|
|
505
|
+
return 'steer';
|
|
506
|
+
}
|
|
507
|
+
if (meets(ladder.observe)) {
|
|
508
|
+
return 'observe';
|
|
509
|
+
}
|
|
510
|
+
return undefined;
|
|
511
|
+
}
|
|
512
|
+
//#endregion
|
|
513
|
+
//#region Monitor
|
|
514
|
+
function isValidStreak(value) {
|
|
515
|
+
return (typeof value === 'object' &&
|
|
516
|
+
value !== null &&
|
|
517
|
+
typeof value.fingerprint === 'string' &&
|
|
518
|
+
typeof value.streak === 'number' &&
|
|
519
|
+
Number.isFinite(value.streak));
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* True when an entry's per-call counts carry no information beyond its round
|
|
523
|
+
* set and round streak, so getState() may omit them and restore() can rebuild
|
|
524
|
+
* them exactly. Holds in the steady state of a repeating fan-out — every
|
|
525
|
+
* member has been issued in the same consecutive rounds, so every count equals
|
|
526
|
+
* the round streak. Persisting both in that state stored each 64-char hash
|
|
527
|
+
* twice (once in the set, once as a count key): ~13 KB per width-100 round,
|
|
528
|
+
* copied on every save. The counts must also cover exactly the set members —
|
|
529
|
+
* a shrunk round (count > streak) or an extra recorded non-member is real
|
|
530
|
+
* evidence and is still persisted verbatim.
|
|
531
|
+
*/
|
|
532
|
+
function callStreaksReconstructible(entry) {
|
|
533
|
+
const counts = entry.callStreaks;
|
|
534
|
+
if (counts === undefined) {
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
const set = entry.roundFingerprints ?? [
|
|
538
|
+
entry.fingerprint,
|
|
539
|
+
];
|
|
540
|
+
const keys = Object.keys(counts);
|
|
541
|
+
return (keys.length === set.length &&
|
|
542
|
+
keys.every((key) => set.includes(key) && counts[key] === entry.streak));
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Rebuild one tool's in-memory streak entry from its persisted shape.
|
|
546
|
+
*
|
|
547
|
+
* The persisted set (when the last round was multi-call) restores the round's
|
|
548
|
+
* identity exactly, so a resumed fan-out continues its streak and a resumed
|
|
549
|
+
* SUBSET cannot match it — the false-positive and false-negative failure
|
|
550
|
+
* modes of a single-fingerprint save. Older blobs (and single-call rounds)
|
|
551
|
+
* carry no set; the lone fingerprint fully describes those rounds. Malformed
|
|
552
|
+
* sets fall back the same way rather than dropping the entry.
|
|
553
|
+
*
|
|
554
|
+
* Per-call counts restore into the CURRENT-round slot: the first resumed
|
|
555
|
+
* record is a new round, so `recordToolCall` rolls them into its baseline
|
|
556
|
+
* (`priorCallStreaks`) exactly as a live round transition would. Validated
|
|
557
|
+
* entry-by-entry — the blob is caller-writable JSON, and one malformed count
|
|
558
|
+
* must not poison the rest. `Object.create(null)` + direct assignment so a
|
|
559
|
+
* persisted "__proto__" key is inert data, mirroring the Map rationale at the
|
|
560
|
+
* `tools` field. `round` is intentionally absent from the result: the first
|
|
561
|
+
* resumed record is always a new round, whatever the numbering.
|
|
562
|
+
*/
|
|
563
|
+
function restoreStreakEntry(entry) {
|
|
564
|
+
const persistedSet = Array.isArray(entry.roundFingerprints) &&
|
|
565
|
+
entry.roundFingerprints.length > 1 &&
|
|
566
|
+
entry.roundFingerprints.every((value) => typeof value === 'string')
|
|
567
|
+
? [
|
|
568
|
+
...entry.roundFingerprints,
|
|
569
|
+
].sort()
|
|
570
|
+
: [
|
|
571
|
+
entry.fingerprint,
|
|
572
|
+
];
|
|
573
|
+
const persistedCallStreaks = Object.create(null);
|
|
574
|
+
if (typeof entry.callStreaks === 'object' && entry.callStreaks !== null) {
|
|
575
|
+
for (const [callFingerprint, count] of Object.entries(entry.callStreaks)) {
|
|
576
|
+
if (typeof count === 'number' && Number.isFinite(count) && count >= 1) {
|
|
577
|
+
persistedCallStreaks[callFingerprint] = Math.floor(count);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
/*
|
|
582
|
+
* The round streak gets the same range validation as the per-call counts —
|
|
583
|
+
* the blob is caller-writable JSON, and `isValidStreak` only checks "finite
|
|
584
|
+
* number". A negative count would hold the guardrail below every rung for
|
|
585
|
+
* that tool (thresholds compare with `>=`); a fractional or absurd one
|
|
586
|
+
* would feed the ladder garbage. Clamped to a sane count rather than
|
|
587
|
+
* rejected: a corrupt streak must degrade the entry, not drop the tool's
|
|
588
|
+
* evidence entirely (fail-open, like every other restore path). The cap is
|
|
589
|
+
* generous — real streaks stop the run at single digits — and exists only
|
|
590
|
+
* to bound arithmetic, not to change semantics.
|
|
591
|
+
*/
|
|
592
|
+
const streak = Math.min(Math.max(Math.floor(entry.streak), 1), 1000000);
|
|
593
|
+
return {
|
|
594
|
+
fingerprint: entry.fingerprint,
|
|
595
|
+
streak,
|
|
596
|
+
roundFingerprints: persistedSet,
|
|
597
|
+
/*
|
|
598
|
+
* Absent per-call counts mean getState() proved them reconstructible:
|
|
599
|
+
* every member of the round set carried exactly the round streak (the
|
|
600
|
+
* steady state of a repeating fan-out — see callStreaksReconstructible).
|
|
601
|
+
* Rebuild them for the WHOLE set, not just the last-recorded member, or a
|
|
602
|
+
* width-W fan-out would resume with W−1 members' evidence reset.
|
|
603
|
+
*/
|
|
604
|
+
callStreaks: Object.keys(persistedCallStreaks).length > 0
|
|
605
|
+
? persistedCallStreaks
|
|
606
|
+
: Object.fromEntries(persistedSet.map((member) => [
|
|
607
|
+
member,
|
|
608
|
+
streak,
|
|
609
|
+
])),
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Pure state machine over the recorded transcript: feed it tool calls and
|
|
614
|
+
* assistant texts, get verdicts back. Holds no engine concerns (hook
|
|
615
|
+
* emission, blocking, steering, stopping live in `ModelResult`), so its
|
|
616
|
+
* behavior is fully exercisable in unit tests and portable as a spec.
|
|
617
|
+
*
|
|
618
|
+
* State is bounded, plain JSON (one streak entry per distinct tool name +
|
|
619
|
+
* one text streak) and round-trips through `ConversationState`.
|
|
620
|
+
*/
|
|
621
|
+
export class DoomLoopMonitor {
|
|
622
|
+
constructor(config, initialState) {
|
|
623
|
+
// Map, not Record: tool names are dynamic runtime keys, and on restore
|
|
624
|
+
// they come from persisted (caller-writable) JSON — a Record's bracket
|
|
625
|
+
// setter would let a crafted "__proto__" key reassign the map's
|
|
626
|
+
// prototype and corrupt streak bookkeeping. Map keys are inert data, so
|
|
627
|
+
// hostile names (and legitimate tools named "__proto__") just work.
|
|
628
|
+
this.tools = new Map();
|
|
629
|
+
// Escalation recoveries consumed by this conversation. Persisted (see
|
|
630
|
+
// getState/restore) so a resumed run cannot reset its budget.
|
|
631
|
+
this.escalationsUsed = 0;
|
|
632
|
+
/**
|
|
633
|
+
* Fingerprint with a per-object memo. The engine hands the SAME key-material
|
|
634
|
+
* object to `declareRound` and then to `recordToolCall` (the resolved
|
|
635
|
+
* `loopKey` output is cached per call id), so without this every checked
|
|
636
|
+
* call is canonicalized and SHA-256'd twice per round. WeakMap keeps the
|
|
637
|
+
* memo from retaining arguments beyond their natural lifetime; primitive
|
|
638
|
+
* key material (raw-string malformed args) skips the memo — it cannot key a
|
|
639
|
+
* WeakMap, and such calls are rare and small. Callers that mutate key
|
|
640
|
+
* material between declare and record get the declare-time identity, which
|
|
641
|
+
* is the identity the round was declared with — the consistent choice.
|
|
642
|
+
*/
|
|
643
|
+
this.fingerprintMemo = new WeakMap();
|
|
644
|
+
this.config = config;
|
|
645
|
+
if (initialState !== undefined) {
|
|
646
|
+
this.restore(initialState);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Declare the complete set of calls a round will make, before any of them
|
|
651
|
+
* is recorded. Each entry is one call's `(toolName, keyMaterial)`; the
|
|
652
|
+
* monitor groups them per tool into that tool's round set.
|
|
653
|
+
*
|
|
654
|
+
* A round's identity is the *set* of fingerprints a tool was called with,
|
|
655
|
+
* so that set has to be known up front. Accumulating it call by call meant
|
|
656
|
+
* a round that is a strict superset of the previous one transiently equaled
|
|
657
|
+
* it while filling — `[a,b]`, `[a,b]`, `[a,b,c]` scored a verdict on the `b`
|
|
658
|
+
* of the third round and refused a call that represented real progress. It
|
|
659
|
+
* was also emission-order dependent: `[c,a,b]` never formed the matching
|
|
660
|
+
* prefix and scored nothing. Declaring the whole set removes both.
|
|
661
|
+
*
|
|
662
|
+
* Idempotent per round, and safe to skip: PER-CALL detection needs no
|
|
663
|
+
* declaration — every repeated `(tool, arguments)` identity accumulates its
|
|
664
|
+
* own consecutive-round count regardless, so an undeclared repeating
|
|
665
|
+
* fan-out still flags each repeated member (server-tool records take that
|
|
666
|
+
* path). What the declaration adds is round-set evidence: the fan-out
|
|
667
|
+
* scored as one unit, with a shared verdict and one steer message, instead
|
|
668
|
+
* of member-by-member. Unhashable key material is skipped here with a
|
|
669
|
+
* warning; the caller's own fallback chain handles it at record time.
|
|
670
|
+
* Never serialized.
|
|
671
|
+
*/
|
|
672
|
+
async declareRound(round, calls) {
|
|
673
|
+
/*
|
|
674
|
+
* Independent digests run concurrently — declaration gates dispatch of
|
|
675
|
+
* the whole batch, so serial awaits would add one hash latency per call
|
|
676
|
+
* to every round. Failures are per-call and fail open, as before.
|
|
677
|
+
*/
|
|
678
|
+
const hashed = await Promise.all(calls.map(async (call) => {
|
|
679
|
+
try {
|
|
680
|
+
return {
|
|
681
|
+
call,
|
|
682
|
+
fingerprint: await this.fingerprintOnce(call.toolName, call.keyMaterial),
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
catch (error) {
|
|
686
|
+
// Unhashable: leave it out of the declared set — recordToolCall's
|
|
687
|
+
// fallback chain decides this call's identity on its own — but say
|
|
688
|
+
// so, like every other fail-open path: a direct consumer debugging
|
|
689
|
+
// an unexpectedly incomplete declaration needs the tool name and
|
|
690
|
+
// cause. (The engine warns separately on its own declaration path.)
|
|
691
|
+
console.warn(`[DoomLoop] could not fingerprint a "${call.toolName}" call while declaring round ` +
|
|
692
|
+
`${round}; excluding it from the round set:`, error);
|
|
693
|
+
return null;
|
|
694
|
+
}
|
|
695
|
+
}));
|
|
696
|
+
/* Accumulate per tool, dedupe and sort ONCE per set — not per member. */
|
|
697
|
+
const collected = new Map();
|
|
698
|
+
for (const entry of hashed) {
|
|
699
|
+
if (entry === null) {
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
const set = collected.get(entry.call.toolName) ?? new Set();
|
|
703
|
+
set.add(entry.fingerprint);
|
|
704
|
+
collected.set(entry.call.toolName, set);
|
|
705
|
+
}
|
|
706
|
+
const fingerprints = new Map();
|
|
707
|
+
for (const [toolName, set] of collected) {
|
|
708
|
+
fingerprints.set(toolName, [
|
|
709
|
+
...set,
|
|
710
|
+
].sort());
|
|
711
|
+
}
|
|
712
|
+
this.declaredRound = {
|
|
713
|
+
round,
|
|
714
|
+
fingerprints,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
async fingerprintOnce(toolName, keyMaterial) {
|
|
718
|
+
const memoizable = typeof keyMaterial === 'object' && keyMaterial !== null;
|
|
719
|
+
if (memoizable) {
|
|
720
|
+
// Keyed by object AND tool name: the tool name participates in the
|
|
721
|
+
// hash, so one arguments object used by two tools must not collide.
|
|
722
|
+
const hit = this.fingerprintMemo.get(keyMaterial)?.get(toolName);
|
|
723
|
+
if (hit !== undefined) {
|
|
724
|
+
return hit;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
const fingerprint = await fingerprintToolCall(toolName, keyMaterial);
|
|
728
|
+
if (memoizable) {
|
|
729
|
+
const perTool = this.fingerprintMemo.get(keyMaterial) ?? new Map();
|
|
730
|
+
perTool.set(toolName, fingerprint);
|
|
731
|
+
this.fingerprintMemo.set(keyMaterial, perTool);
|
|
732
|
+
}
|
|
733
|
+
return fingerprint;
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* True when the escalate rung can still fire: a mechanism is configured
|
|
737
|
+
* and the budget is not exhausted.
|
|
738
|
+
*/
|
|
739
|
+
canEscalate() {
|
|
740
|
+
return (this.config.escalation !== null &&
|
|
741
|
+
this.escalationsUsed < this.config.escalation.maxEscalations);
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Consume one escalation from the budget. The ENGINE calls this when it
|
|
745
|
+
* actually applies the recovery (model swap / advisor forcing) — not at
|
|
746
|
+
* verdict time, so a verdict the engine ends up not honoring (e.g. a hook
|
|
747
|
+
* override) does not burn budget.
|
|
748
|
+
*/
|
|
749
|
+
consumeEscalation() {
|
|
750
|
+
this.escalationsUsed++;
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Snapshot the serializable detector state (deep copy). Round markers are
|
|
754
|
+
* deliberately dropped: rounds are meaningful only within one run.
|
|
755
|
+
* `stopVerdict`/`pendingSteer` are owned by the engine, which merges them
|
|
756
|
+
* into the persisted blob alongside this snapshot.
|
|
757
|
+
*/
|
|
758
|
+
getState() {
|
|
759
|
+
return {
|
|
760
|
+
// Object.fromEntries defines OWN properties (CreateDataProperty), so
|
|
761
|
+
// even a tool named "__proto__" lands as plain data, not a setter hit.
|
|
762
|
+
tools: Object.fromEntries(Array.from(this.tools, ([name, entry]) => [
|
|
763
|
+
name,
|
|
764
|
+
{
|
|
765
|
+
fingerprint: entry.fingerprint,
|
|
766
|
+
streak: entry.streak,
|
|
767
|
+
/*
|
|
768
|
+
* A multi-call round persists its full set, so a resumed run knows
|
|
769
|
+
* WHICH set earned the count. Without it, the streak either
|
|
770
|
+
* attached to one arbitrary member — a resumed round of just that
|
|
771
|
+
* call inherited a fan-out's whole evidence and could be refused
|
|
772
|
+
* on its first appearance — or had to be discarded at every save,
|
|
773
|
+
* which reset condemned fan-outs across approval pauses and made
|
|
774
|
+
* per-turn-resume topologies never accumulate. Single-call rounds
|
|
775
|
+
* omit it: their fingerprint fully describes the round.
|
|
776
|
+
*
|
|
777
|
+
* COPIED, not aliased: the in-memory array is shared with the live
|
|
778
|
+
* streak entry (and, for declared rounds, with `declaredRound`
|
|
779
|
+
* itself), and the snapshot is handed to the caller's
|
|
780
|
+
* StateAccessor. An in-place mutation of the saved blob must not
|
|
781
|
+
* corrupt the detector still running against it.
|
|
782
|
+
*/
|
|
783
|
+
...((entry.roundFingerprints?.length ?? 0) > 1
|
|
784
|
+
? {
|
|
785
|
+
roundFingerprints: [
|
|
786
|
+
...entry.roundFingerprints,
|
|
787
|
+
],
|
|
788
|
+
}
|
|
789
|
+
: {}),
|
|
790
|
+
/*
|
|
791
|
+
* Per-call counts persist so a repeat spanning a save/resume
|
|
792
|
+
* boundary keeps counting. Copied for the same isolation reason
|
|
793
|
+
* as the set above. `fromEntries` for the same "__proto__" safety
|
|
794
|
+
* as the outer record. Omitted only when `fingerprint`+`streak`
|
|
795
|
+
* already carry the identical information (a single-call round
|
|
796
|
+
* whose per-call count equals its round count — `restore`
|
|
797
|
+
* reconstructs exactly that). The count-mismatch check matters:
|
|
798
|
+
* when a round SHRINKS to one call, the round streak resets while
|
|
799
|
+
* the per-call count keeps climbing, and dropping it would hand
|
|
800
|
+
* the repeat a fresh grace window on resume.
|
|
801
|
+
*/
|
|
802
|
+
...(entry.callStreaks !== undefined && !callStreaksReconstructible(entry)
|
|
803
|
+
? {
|
|
804
|
+
callStreaks: Object.fromEntries(Object.entries(entry.callStreaks)),
|
|
805
|
+
}
|
|
806
|
+
: {}),
|
|
807
|
+
},
|
|
808
|
+
])),
|
|
809
|
+
...(this.text && {
|
|
810
|
+
text: {
|
|
811
|
+
fingerprint: this.text.fingerprint,
|
|
812
|
+
streak: this.text.streak,
|
|
813
|
+
},
|
|
814
|
+
}),
|
|
815
|
+
...(this.escalationsUsed > 0 && {
|
|
816
|
+
escalationsUsed: this.escalationsUsed,
|
|
817
|
+
}),
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Restore persisted state (from `ConversationState.doomLoop`). Invalid
|
|
822
|
+
* blobs are ignored with a warning — a corrupt detector state must never
|
|
823
|
+
* take down a resumed run. Engine-owned fields (`stopVerdict`,
|
|
824
|
+
* `pendingSteer`) are ignored here; the engine restores them itself.
|
|
825
|
+
*/
|
|
826
|
+
restore(state) {
|
|
827
|
+
if (typeof state !== 'object' || state === null || Array.isArray(state)) {
|
|
828
|
+
console.warn('[DoomLoop] Ignoring invalid persisted doom-loop state');
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
const candidate = state;
|
|
832
|
+
const tools = new Map();
|
|
833
|
+
if (typeof candidate.tools === 'object' && candidate.tools !== null) {
|
|
834
|
+
for (const [name, entry] of Object.entries(candidate.tools)) {
|
|
835
|
+
if (isValidStreak(entry)) {
|
|
836
|
+
tools.set(name, restoreStreakEntry(entry));
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
this.tools = tools;
|
|
841
|
+
this.text = isValidStreak(candidate.text)
|
|
842
|
+
? {
|
|
843
|
+
fingerprint: candidate.text.fingerprint,
|
|
844
|
+
streak: candidate.text.streak,
|
|
845
|
+
}
|
|
846
|
+
: undefined;
|
|
847
|
+
this.escalationsUsed =
|
|
848
|
+
typeof candidate.escalationsUsed === 'number' &&
|
|
849
|
+
Number.isFinite(candidate.escalationsUsed) &&
|
|
850
|
+
candidate.escalationsUsed >= 0
|
|
851
|
+
? Math.floor(candidate.escalationsUsed)
|
|
852
|
+
: 0;
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Record one tool call and return the streak record plus any verdict.
|
|
856
|
+
*
|
|
857
|
+
* Streak semantics:
|
|
858
|
+
* - Per tool: interleaved calls to *other* tools do not reset a tool's
|
|
859
|
+
* streak (so `search X, read file, search X, read file` still trips).
|
|
860
|
+
* - Per round: the same `(tool, fingerprint)` recorded again in the SAME
|
|
861
|
+
* `round` is a duplicate — the streak does not increment
|
|
862
|
+
* (`duplicateInRound: true`) and the caller should reuse the decision it
|
|
863
|
+
* applied for the first occurrence. A streak measures the model
|
|
864
|
+
* re-issuing a call *after seeing its result*, which requires a round
|
|
865
|
+
* trip; N parallel identical calls in one turn are one piece of
|
|
866
|
+
* evidence, not N.
|
|
867
|
+
* - A round's identity is the *set* of fingerprints the tool was called
|
|
868
|
+
* with, not its last call, so a fan-out of distinct arguments reissued
|
|
869
|
+
* verbatim accumulates. A round whose set differs from the previous
|
|
870
|
+
* round's — in either direction, including a superset — resets to 1.
|
|
871
|
+
* Every call in a round reports that round's streak, so the ladder
|
|
872
|
+
* applies to the round as a unit. See {@link declareRound}: the set must
|
|
873
|
+
* be declared before the round's first call for this to hold for
|
|
874
|
+
* multi-call rounds.
|
|
875
|
+
*
|
|
876
|
+
* Blocked calls are recorded like any other — a model re-issuing a
|
|
877
|
+
* blocked call in a later round is stronger loop evidence, not progress.
|
|
878
|
+
*
|
|
879
|
+
* @throws Whatever {@link canonicalizeKeyMaterial} throws for unhashable
|
|
880
|
+
* key material. Callers (the engine) must catch and fall back — see
|
|
881
|
+
* `resolveLoopKeyMaterial` and the engine's fallback chain.
|
|
882
|
+
*/
|
|
883
|
+
async recordToolCall(toolName, keyMaterial, round, options) {
|
|
884
|
+
const fingerprint = await this.fingerprintOnce(toolName, keyMaterial);
|
|
885
|
+
const previous = this.tools.get(toolName);
|
|
886
|
+
const isSameRound = previous?.round !== undefined && previous.round === round;
|
|
887
|
+
/*
|
|
888
|
+
* The round's declared membership, when the engine announced it (see
|
|
889
|
+
* `declareRound`). A declaration only speaks for the calls it contains: a
|
|
890
|
+
* call dropped as unhashable still records here via the caller's fallback
|
|
891
|
+
* chain, but as a NON-member — it cannot inherit or move the ROUND's
|
|
892
|
+
* counters. Its own repetition still counts: the per-call detector below
|
|
893
|
+
* needs no declaration, so a fallback-identity call reissued verbatim
|
|
894
|
+
* every round accumulates like any other repeat.
|
|
895
|
+
*/
|
|
896
|
+
const declared = this.declaredRound?.round === round
|
|
897
|
+
? this.declaredRound.fingerprints.get(toolName)
|
|
898
|
+
: undefined;
|
|
899
|
+
const declaredMember = declared?.includes(fingerprint) === true;
|
|
900
|
+
const seen = isSameRound ? (previous?.seenThisRound ?? EMPTY_SEEN) : EMPTY_SEEN;
|
|
901
|
+
const duplicateInRound = seen.has(fingerprint);
|
|
902
|
+
/*
|
|
903
|
+
* The baseline every call of this round is measured against: the set the
|
|
904
|
+
* PREVIOUS round was called with and the streak it earned. Fixed at the
|
|
905
|
+
* round transition and carried unchanged for the round's length, so
|
|
906
|
+
* arrival order within a round can never affect a score.
|
|
907
|
+
*/
|
|
908
|
+
const priorSet = isSameRound
|
|
909
|
+
? previous?.priorRoundFingerprints
|
|
910
|
+
: (previous?.roundFingerprints ??
|
|
911
|
+
(previous
|
|
912
|
+
? [
|
|
913
|
+
previous.fingerprint,
|
|
914
|
+
]
|
|
915
|
+
: undefined));
|
|
916
|
+
const priorStreak = isSameRound ? (previous?.priorStreak ?? 0) : (previous?.streak ?? 0);
|
|
917
|
+
const score = (set) => priorSet !== undefined && setsMatch(priorSet, set) ? priorStreak + 1 : 1;
|
|
918
|
+
/*
|
|
919
|
+
* PER-CALL evidence, alongside the round-set streak: the number of
|
|
920
|
+
* consecutive rounds THIS exact fingerprint has been issued in, whatever
|
|
921
|
+
* its round-mates did. Round identity treats any membership change as
|
|
922
|
+
* progress, which is right for the fan-out as a unit but blind to one
|
|
923
|
+
* call repeating inside varying company (`[a,b]`, `[a,c]`, `[a,d]` — the
|
|
924
|
+
* set differs every round, yet `a` is a 3-peat) and to a paused HITL
|
|
925
|
+
* member changing the resumed round's identity. The per-call count sees
|
|
926
|
+
* exactly those. Baseline fixed at the round transition, like the set
|
|
927
|
+
* baseline, so arrival order cannot affect it.
|
|
928
|
+
*/
|
|
929
|
+
const priorCallStreaks = isSameRound
|
|
930
|
+
? (previous?.priorCallStreaks ?? {})
|
|
931
|
+
: (previous?.callStreaks ?? {});
|
|
932
|
+
const callStreak = (Object.hasOwn(priorCallStreaks, fingerprint)
|
|
933
|
+
? priorCallStreaks[fingerprint]
|
|
934
|
+
: 0) + 1;
|
|
935
|
+
/*
|
|
936
|
+
* Two identities, one scoring rule.
|
|
937
|
+
*
|
|
938
|
+
* What this CALL reports: the declared set when it is a member (every
|
|
939
|
+
* member of a repeating fan-out shares the round's streak, so the ladder
|
|
940
|
+
* applies to the round as a unit), its own singleton otherwise. On the
|
|
941
|
+
* undeclared path the singleton comparison is the pre-fan-out per-call
|
|
942
|
+
* semantics for single-call rounds; for undeclared MULTI-call rounds it
|
|
943
|
+
* accumulates only on whichever call lands last (see the README limit).
|
|
944
|
+
*
|
|
945
|
+
* What the ROUND stores: the declared set when there is one — a
|
|
946
|
+
* non-member must not overwrite the round's identity with its singleton,
|
|
947
|
+
* or the next round's members would compare against it and reset forever.
|
|
948
|
+
*/
|
|
949
|
+
const callSet = declaredMember
|
|
950
|
+
? declared
|
|
951
|
+
: [
|
|
952
|
+
fingerprint,
|
|
953
|
+
];
|
|
954
|
+
const streak = score(callSet);
|
|
955
|
+
const roundSet = declared ?? [
|
|
956
|
+
fingerprint,
|
|
957
|
+
];
|
|
958
|
+
this.tools.set(toolName, {
|
|
959
|
+
/*
|
|
960
|
+
* The identity that pairs with `streak` in persisted state: a non-member
|
|
961
|
+
* must not become it, or the saved count would attach to a call that
|
|
962
|
+
* never earned it (blocked on first appearance after a resume, while the
|
|
963
|
+
* real repeat lost its evidence).
|
|
964
|
+
*/
|
|
965
|
+
fingerprint: declared !== undefined && !declaredMember && previous !== undefined
|
|
966
|
+
? previous.fingerprint
|
|
967
|
+
: fingerprint,
|
|
968
|
+
round,
|
|
969
|
+
/*
|
|
970
|
+
* Grown in place, O(1) per record: run-local, never serialized, and only
|
|
971
|
+
* the latest entry's set is ever read, so sharing the object between the
|
|
972
|
+
* previous and next entry is fine. The shared EMPTY_SEEN sentinel is the
|
|
973
|
+
* one instance that must never be mutated.
|
|
974
|
+
*/
|
|
975
|
+
seenThisRound: seen === EMPTY_SEEN
|
|
976
|
+
? new Set([
|
|
977
|
+
fingerprint,
|
|
978
|
+
])
|
|
979
|
+
: seen.add(fingerprint),
|
|
980
|
+
roundFingerprints: roundSet,
|
|
981
|
+
streak: score(roundSet),
|
|
982
|
+
...(priorSet !== undefined
|
|
983
|
+
? {
|
|
984
|
+
priorRoundFingerprints: priorSet,
|
|
985
|
+
}
|
|
986
|
+
: {}),
|
|
987
|
+
priorStreak,
|
|
988
|
+
priorCallStreaks,
|
|
989
|
+
callStreaks: growCallStreaks(isSameRound ? previous : undefined, fingerprint, callStreak),
|
|
990
|
+
});
|
|
991
|
+
/*
|
|
992
|
+
* The stronger of the two detectors decides. The round streak covers the
|
|
993
|
+
* reissued fan-out as a unit; the per-call streak covers a repeat whose
|
|
994
|
+
* round-mates keep changing. For an exactly-repeating round both counts
|
|
995
|
+
* are equal, so nothing double-fires — the counts only diverge when one
|
|
996
|
+
* detector sees something the other cannot.
|
|
997
|
+
*/
|
|
998
|
+
const effectiveStreak = Math.max(streak, callStreak);
|
|
999
|
+
const allowBlock = options?.allowBlock ?? true;
|
|
1000
|
+
const action = resolveLadderAction(this.config.ladder, effectiveStreak, {
|
|
1001
|
+
allowBlock,
|
|
1002
|
+
allowEscalate: this.canEscalate(),
|
|
1003
|
+
});
|
|
1004
|
+
if (!action) {
|
|
1005
|
+
return {
|
|
1006
|
+
fingerprint,
|
|
1007
|
+
streak: effectiveStreak,
|
|
1008
|
+
duplicateInRound,
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
return {
|
|
1012
|
+
fingerprint,
|
|
1013
|
+
streak: effectiveStreak,
|
|
1014
|
+
duplicateInRound,
|
|
1015
|
+
verdict: {
|
|
1016
|
+
detector: options?.detector ?? 'tool-fingerprint',
|
|
1017
|
+
action,
|
|
1018
|
+
streak: effectiveStreak,
|
|
1019
|
+
fingerprint,
|
|
1020
|
+
toolName,
|
|
1021
|
+
message: buildToolVerdictMessage({
|
|
1022
|
+
toolName,
|
|
1023
|
+
fingerprint,
|
|
1024
|
+
callSet,
|
|
1025
|
+
roundStreak: streak,
|
|
1026
|
+
callStreak,
|
|
1027
|
+
// An undeclared record's `callSet` is just this one call, which says
|
|
1028
|
+
// nothing about the round's real width — so it may not be described
|
|
1029
|
+
// as a set, nor have its fingerprint quoted as the round's identity.
|
|
1030
|
+
roundDeclared: declaredMember,
|
|
1031
|
+
}),
|
|
1032
|
+
},
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Clear the cross-step text streak. Called by the engine when a late
|
|
1037
|
+
* async-tool result is injected into the conversation: the run made
|
|
1038
|
+
* observable forward progress, so a model that said "still waiting…"
|
|
1039
|
+
* between deliveries must not accumulate a repetition streak toward a
|
|
1040
|
+
* false-positive stop. Tool streaks are deliberately kept — re-issuing an
|
|
1041
|
+
* identical tool call after a delivery is still loop evidence.
|
|
1042
|
+
*/
|
|
1043
|
+
resetTextStreak() {
|
|
1044
|
+
this.text = undefined;
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Record one step's assistant text and return the strongest verdict from
|
|
1048
|
+
* the two text detectors:
|
|
1049
|
+
*
|
|
1050
|
+
* - `text-repetition`: a token block repeating within THIS response; the
|
|
1051
|
+
* repeat count feeds the ladder directly, so a single response spinning
|
|
1052
|
+
* "I am stuck." dozens of times can stop the run immediately.
|
|
1053
|
+
* - `text-streak`: byte-identical (whitespace-normalized) text across
|
|
1054
|
+
* consecutive steps. KNOWN LIMIT: paraphrased repetition does not trip.
|
|
1055
|
+
*
|
|
1056
|
+
* Empty/whitespace-only text (typical for tool-only turns) is a no-op:
|
|
1057
|
+
* it neither counts nor resets the cross-step streak, mirroring how
|
|
1058
|
+
* interleaved other-tool calls don't reset a tool streak.
|
|
1059
|
+
*/
|
|
1060
|
+
async recordAssistantText(text) {
|
|
1061
|
+
if (!this.config.text.enabled) {
|
|
1062
|
+
return undefined;
|
|
1063
|
+
}
|
|
1064
|
+
const normalized = text.replace(/\s+/g, ' ').trim();
|
|
1065
|
+
if (normalized.length === 0) {
|
|
1066
|
+
return undefined;
|
|
1067
|
+
}
|
|
1068
|
+
let withinResponse;
|
|
1069
|
+
const repetition = detectTextRepetition(normalized, this.config.text);
|
|
1070
|
+
if (repetition) {
|
|
1071
|
+
const action = resolveLadderAction(this.config.ladder, repetition.repeats, {
|
|
1072
|
+
allowBlock: false,
|
|
1073
|
+
allowEscalate: this.canEscalate(),
|
|
1074
|
+
});
|
|
1075
|
+
if (action) {
|
|
1076
|
+
withinResponse = {
|
|
1077
|
+
detector: 'text-repetition',
|
|
1078
|
+
action,
|
|
1079
|
+
streak: repetition.repeats,
|
|
1080
|
+
fingerprint: await fingerprintKeyMaterial(repetition.sample),
|
|
1081
|
+
message: `Doom loop suspected: the response repeats "${repetition.sample}" ` +
|
|
1082
|
+
`${repetition.repeats} times in a row. Stop repeating and take a different approach.`,
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
const fingerprint = await fingerprintKeyMaterial(normalized);
|
|
1087
|
+
const previous = this.text;
|
|
1088
|
+
const streak = previous && previous.fingerprint === fingerprint ? previous.streak + 1 : 1;
|
|
1089
|
+
this.text = {
|
|
1090
|
+
fingerprint,
|
|
1091
|
+
streak,
|
|
1092
|
+
};
|
|
1093
|
+
let crossStep;
|
|
1094
|
+
const crossAction = resolveLadderAction(this.config.ladder, streak, {
|
|
1095
|
+
allowBlock: false,
|
|
1096
|
+
allowEscalate: this.canEscalate(),
|
|
1097
|
+
});
|
|
1098
|
+
if (crossAction) {
|
|
1099
|
+
crossStep = {
|
|
1100
|
+
detector: 'text-streak',
|
|
1101
|
+
action: crossAction,
|
|
1102
|
+
streak,
|
|
1103
|
+
fingerprint,
|
|
1104
|
+
message: `Doom loop suspected: the assistant produced identical text for ${streak} ` +
|
|
1105
|
+
'consecutive turns. Stop repeating and take a different approach.',
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
return strongerVerdict(withinResponse, crossStep);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
const ACTION_STRENGTH = Object.freeze({
|
|
1112
|
+
observe: 0,
|
|
1113
|
+
steer: 1,
|
|
1114
|
+
escalate: 2,
|
|
1115
|
+
block: 3,
|
|
1116
|
+
stop: 4,
|
|
1117
|
+
});
|
|
1118
|
+
function strongerVerdict(a, b) {
|
|
1119
|
+
if (!a) {
|
|
1120
|
+
return b;
|
|
1121
|
+
}
|
|
1122
|
+
if (!b) {
|
|
1123
|
+
return a;
|
|
1124
|
+
}
|
|
1125
|
+
// Within-response wins ties: its message names the repeated block.
|
|
1126
|
+
return ACTION_STRENGTH[b.action] > ACTION_STRENGTH[a.action] ? b : a;
|
|
1127
|
+
}
|
|
1128
|
+
//#endregion
|
|
1129
|
+
/**
|
|
1130
|
+
* This round's per-call accumulator, grown in place — O(1) per record instead
|
|
1131
|
+
* of an O(W) spread per call of a W-wide round. Safe because only the latest
|
|
1132
|
+
* entry's object is ever read mid-round, and `priorCallStreaks` aliases the
|
|
1133
|
+
* PREVIOUS round's object (a fresh object is created at each round
|
|
1134
|
+
* transition), so mutating this round's accumulator never disturbs the
|
|
1135
|
+
* baseline. getState() copies before persisting either.
|
|
1136
|
+
*/
|
|
1137
|
+
function growCallStreaks(previous, fingerprint, callStreak) {
|
|
1138
|
+
if (previous?.callStreaks !== undefined) {
|
|
1139
|
+
previous.callStreaks[fingerprint] = callStreak;
|
|
1140
|
+
return previous.callStreaks;
|
|
1141
|
+
}
|
|
1142
|
+
return {
|
|
1143
|
+
[fingerprint]: callStreak,
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* The empty seen-set, shared: `recordToolCall` runs per call, and most rounds
|
|
1148
|
+
* open with no prior members — allocating a fresh empty Set for each would be
|
|
1149
|
+
* churn for nothing. Never mutated (the write path copies before adding).
|
|
1150
|
+
*/
|
|
1151
|
+
const EMPTY_SEEN = new Set();
|
|
1152
|
+
/**
|
|
1153
|
+
* Short, stable identity for a round's fingerprint set: the first members'
|
|
1154
|
+
* prefixes, so every call of one round produces the same string (see the
|
|
1155
|
+
* verdict message — steer dedupes on exact text).
|
|
1156
|
+
*/
|
|
1157
|
+
function summarizeRound(fingerprints) {
|
|
1158
|
+
const shown = fingerprints.slice(0, 3).map((value) => value.slice(0, 8));
|
|
1159
|
+
return fingerprints.length > 3
|
|
1160
|
+
? `${shown.join('+')}+${fingerprints.length - 3} more…`
|
|
1161
|
+
: `${shown.join('+')}…`;
|
|
1162
|
+
}
|
|
1163
|
+
/** Set equality over two sorted fingerprint lists. */
|
|
1164
|
+
function setsMatch(left, right) {
|
|
1165
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Verdict text for a tool-fingerprint detection, naming what actually
|
|
1169
|
+
* repeated. When the round streak decides (or ties), a multi-call round quotes
|
|
1170
|
+
* the ROUND's identity — identical text for every call, deliberately, because
|
|
1171
|
+
* the steer rung dedupes queued guidance by exact message text and one round
|
|
1172
|
+
* of evidence must not queue N near-identical corrections. When the PER-CALL
|
|
1173
|
+
* streak alone decides (its count exceeds the round's), the text carries
|
|
1174
|
+
* neither the call's fingerprint nor its exact count: both vary between
|
|
1175
|
+
* members of one round (an expanding fan-out holds counts 4, 3, 2 at once),
|
|
1176
|
+
* and either would split one piece of guidance into per-member strings. The
|
|
1177
|
+
* enforced bound is therefore at most TWO distinct messages per tool per
|
|
1178
|
+
* round — one per-call text, one round-set text — each stating a distinct
|
|
1179
|
+
* fact; exact counts live in the verdict payload's `streak`.
|
|
1180
|
+
*
|
|
1181
|
+
* `roundDeclared` says whether the detector was told the round's true
|
|
1182
|
+
* membership. Only then does `callSet` describe the round, so only then may the
|
|
1183
|
+
* text name a set or quote an argument fingerprint. On the UNDECLARED path
|
|
1184
|
+
* (server-tool records, direct `DoomLoopMonitor` consumers, the SDK ports) each
|
|
1185
|
+
* call is recorded alone, so `callSet` holds just that call however wide the
|
|
1186
|
+
* round really was: the last-recorded call tied at `roundStreak == callStreak`
|
|
1187
|
+
* and rendered the fingerprint-bearing single-call text while its round-mates
|
|
1188
|
+
* rendered the per-call text — two strings for one round, which defeats the
|
|
1189
|
+
* exact-text steer dedupe this shaping exists to preserve.
|
|
1190
|
+
*/
|
|
1191
|
+
function buildToolVerdictMessage(input) {
|
|
1192
|
+
const { toolName, fingerprint, callSet, roundStreak, callStreak, roundDeclared } = input;
|
|
1193
|
+
if (callStreak > roundStreak || !roundDeclared) {
|
|
1194
|
+
/*
|
|
1195
|
+
* Fingerprint-free AND count-free, so every per-call verdict of one tool
|
|
1196
|
+
* renders byte-identical text and the steer dedupe collapses them to one
|
|
1197
|
+
* correction. Reached when the per-call streak decides, and when the
|
|
1198
|
+
* round's true membership is unknown (undeclared path). Both varying
|
|
1199
|
+
* elements had to go for the dedupe to hold: quoting one call's hash
|
|
1200
|
+
* diverged the members of a wide round, and quoting the exact count
|
|
1201
|
+
* diverged members whose repeats started at different times (an expanding
|
|
1202
|
+
* fan-out carries counts 4, 3, 2 in one round — three near-identical
|
|
1203
|
+
* strings for one piece of guidance). The exact count still reaches
|
|
1204
|
+
* consumers via the verdict payload's `streak`; the prose tells the model
|
|
1205
|
+
* what to do about it, which does not depend on the number.
|
|
1206
|
+
*/
|
|
1207
|
+
return (`Doom loop suspected: this exact "${toolName}" call has been repeated across ` +
|
|
1208
|
+
'consecutive rounds. Repeating it will not change the result. ' +
|
|
1209
|
+
'Take a different approach, or explain why repetition is required.');
|
|
1210
|
+
}
|
|
1211
|
+
if (callSet.length > 1) {
|
|
1212
|
+
return (`Doom loop suspected: tool "${toolName}" was invoked in ${roundStreak} consecutive rounds ` +
|
|
1213
|
+
`with the same set of ${callSet.length} parallel calls ` +
|
|
1214
|
+
`(round identity ${summarizeRound(callSet)}). Reissuing the same fan-out ` +
|
|
1215
|
+
'will not change the results. Take a different approach, or explain why repetition is required.');
|
|
1216
|
+
}
|
|
1217
|
+
return (`Doom loop suspected: tool "${toolName}" was invoked in ${roundStreak} consecutive rounds ` +
|
|
1218
|
+
`with identical arguments (fingerprint ${fingerprint.slice(0, 16)}…). Repeating the call ` +
|
|
1219
|
+
'will not change the result. Take a different approach, or explain why repetition is required.');
|
|
1220
|
+
}
|
|
1221
|
+
//# sourceMappingURL=doom-loop.js.map
|