@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.
Files changed (193) hide show
  1. package/README.md +732 -7
  2. package/THIRD_PARTY_NOTICES.md +25 -0
  3. package/esm/index.d.ts +20 -5
  4. package/esm/index.d.ts.map +1 -1
  5. package/esm/index.js +10 -3
  6. package/esm/index.js.map +1 -1
  7. package/esm/inner-loop/call-model.d.ts.map +1 -1
  8. package/esm/inner-loop/call-model.js +63 -46
  9. package/esm/inner-loop/call-model.js.map +1 -1
  10. package/esm/inner-loop/resume-tool-results.d.ts +128 -0
  11. package/esm/inner-loop/resume-tool-results.d.ts.map +1 -0
  12. package/esm/inner-loop/resume-tool-results.js +244 -0
  13. package/esm/inner-loop/resume-tool-results.js.map +1 -0
  14. package/esm/lib/agent-tool.d.ts +92 -0
  15. package/esm/lib/agent-tool.d.ts.map +1 -0
  16. package/esm/lib/agent-tool.js +211 -0
  17. package/esm/lib/agent-tool.js.map +1 -0
  18. package/esm/lib/async-params.d.ts +96 -0
  19. package/esm/lib/async-params.d.ts.map +1 -1
  20. package/esm/lib/async-params.js +30 -1
  21. package/esm/lib/async-params.js.map +1 -1
  22. package/esm/lib/async-tool-registry.d.ts +134 -0
  23. package/esm/lib/async-tool-registry.d.ts.map +1 -0
  24. package/esm/lib/async-tool-registry.js +352 -0
  25. package/esm/lib/async-tool-registry.js.map +1 -0
  26. package/esm/lib/async-tools.d.ts +23 -0
  27. package/esm/lib/async-tools.d.ts.map +1 -0
  28. package/esm/lib/async-tools.js +19 -0
  29. package/esm/lib/async-tools.js.map +1 -0
  30. package/esm/lib/conversation-state.d.ts +1 -0
  31. package/esm/lib/conversation-state.d.ts.map +1 -1
  32. package/esm/lib/conversation-state.js +8 -0
  33. package/esm/lib/conversation-state.js.map +1 -1
  34. package/esm/lib/doom-loop.d.ts +533 -0
  35. package/esm/lib/doom-loop.d.ts.map +1 -0
  36. package/esm/lib/doom-loop.js +1221 -0
  37. package/esm/lib/doom-loop.js.map +1 -0
  38. package/esm/lib/hooks-schemas.d.ts +33 -0
  39. package/esm/lib/hooks-schemas.d.ts.map +1 -1
  40. package/esm/lib/hooks-schemas.js +54 -0
  41. package/esm/lib/hooks-schemas.js.map +1 -1
  42. package/esm/lib/hooks-types.d.ts +6 -2
  43. package/esm/lib/hooks-types.d.ts.map +1 -1
  44. package/esm/lib/hooks-types.js.map +1 -1
  45. package/esm/lib/model-result.d.ts +648 -12
  46. package/esm/lib/model-result.d.ts.map +1 -1
  47. package/esm/lib/model-result.js +2784 -164
  48. package/esm/lib/model-result.js.map +1 -1
  49. package/esm/lib/next-turn-params.d.ts.map +1 -1
  50. package/esm/lib/next-turn-params.js +3 -1
  51. package/esm/lib/next-turn-params.js.map +1 -1
  52. package/esm/lib/next-turn-params.test.js +72 -0
  53. package/esm/lib/next-turn-params.test.js.map +1 -1
  54. package/esm/lib/reusable-stream.d.ts +9 -0
  55. package/esm/lib/reusable-stream.d.ts.map +1 -1
  56. package/esm/lib/reusable-stream.js +17 -0
  57. package/esm/lib/reusable-stream.js.map +1 -1
  58. package/esm/lib/stream-transformers.d.ts +21 -1
  59. package/esm/lib/stream-transformers.d.ts.map +1 -1
  60. package/esm/lib/stream-transformers.js +45 -0
  61. package/esm/lib/stream-transformers.js.map +1 -1
  62. package/esm/lib/tool-check.d.ts +76 -0
  63. package/esm/lib/tool-check.d.ts.map +1 -0
  64. package/esm/lib/tool-check.js +241 -0
  65. package/esm/lib/tool-check.js.map +1 -0
  66. package/esm/lib/tool-concurrency.d.ts +42 -0
  67. package/esm/lib/tool-concurrency.d.ts.map +1 -0
  68. package/esm/lib/tool-concurrency.js +80 -0
  69. package/esm/lib/tool-concurrency.js.map +1 -0
  70. package/esm/lib/tool-context.d.ts +45 -1
  71. package/esm/lib/tool-context.d.ts.map +1 -1
  72. package/esm/lib/tool-context.js +60 -1
  73. package/esm/lib/tool-context.js.map +1 -1
  74. package/esm/lib/tool-executor.d.ts +52 -9
  75. package/esm/lib/tool-executor.d.ts.map +1 -1
  76. package/esm/lib/tool-executor.js +253 -21
  77. package/esm/lib/tool-executor.js.map +1 -1
  78. package/esm/lib/tool-orchestrator.d.ts.map +1 -1
  79. package/esm/lib/tool-orchestrator.js +87 -50
  80. package/esm/lib/tool-orchestrator.js.map +1 -1
  81. package/esm/lib/tool-set-types.d.ts +298 -0
  82. package/esm/lib/tool-set-types.d.ts.map +1 -0
  83. package/esm/lib/tool-set-types.js +2 -0
  84. package/esm/lib/tool-set-types.js.map +1 -0
  85. package/esm/lib/tool-set.d.ts +157 -0
  86. package/esm/lib/tool-set.d.ts.map +1 -0
  87. package/esm/lib/tool-set.js +473 -0
  88. package/esm/lib/tool-set.js.map +1 -0
  89. package/esm/lib/tool-task.d.ts +157 -0
  90. package/esm/lib/tool-task.d.ts.map +1 -0
  91. package/esm/lib/tool-task.js +206 -0
  92. package/esm/lib/tool-task.js.map +1 -0
  93. package/esm/lib/tool-types.d.ts +667 -38
  94. package/esm/lib/tool-types.d.ts.map +1 -1
  95. package/esm/lib/tool-types.js +59 -10
  96. package/esm/lib/tool-types.js.map +1 -1
  97. package/esm/lib/tool.d.ts +227 -19
  98. package/esm/lib/tool.d.ts.map +1 -1
  99. package/esm/lib/tool.js +206 -6
  100. package/esm/lib/tool.js.map +1 -1
  101. package/esm/mcp/auth/auth-resolver.d.ts +14 -0
  102. package/esm/mcp/auth/auth-resolver.d.ts.map +1 -0
  103. package/esm/mcp/auth/auth-resolver.js +27 -0
  104. package/esm/mcp/auth/auth-resolver.js.map +1 -0
  105. package/esm/mcp/auth/auth-types.d.ts +33 -0
  106. package/esm/mcp/auth/auth-types.d.ts.map +1 -0
  107. package/esm/mcp/auth/auth-types.js +4 -0
  108. package/esm/mcp/auth/auth-types.js.map +1 -0
  109. package/esm/mcp/build-tools.d.ts +29 -0
  110. package/esm/mcp/build-tools.d.ts.map +1 -0
  111. package/esm/mcp/build-tools.js +80 -0
  112. package/esm/mcp/build-tools.js.map +1 -0
  113. package/esm/mcp/cache/cache-store.d.ts +24 -0
  114. package/esm/mcp/cache/cache-store.d.ts.map +1 -0
  115. package/esm/mcp/cache/cache-store.js +20 -0
  116. package/esm/mcp/cache/cache-store.js.map +1 -0
  117. package/esm/mcp/cache/cache-types.d.ts +71 -0
  118. package/esm/mcp/cache/cache-types.d.ts.map +1 -0
  119. package/esm/mcp/cache/cache-types.js +49 -0
  120. package/esm/mcp/cache/cache-types.js.map +1 -0
  121. package/esm/mcp/cache/serialize.d.ts +25 -0
  122. package/esm/mcp/cache/serialize.d.ts.map +1 -0
  123. package/esm/mcp/cache/serialize.js +96 -0
  124. package/esm/mcp/cache/serialize.js.map +1 -0
  125. package/esm/mcp/close-quietly.d.ts +24 -0
  126. package/esm/mcp/close-quietly.d.ts.map +1 -0
  127. package/esm/mcp/close-quietly.js +30 -0
  128. package/esm/mcp/close-quietly.js.map +1 -0
  129. package/esm/mcp/create-mcp-tools.d.ts +13 -0
  130. package/esm/mcp/create-mcp-tools.d.ts.map +1 -0
  131. package/esm/mcp/create-mcp-tools.js +87 -0
  132. package/esm/mcp/create-mcp-tools.js.map +1 -0
  133. package/esm/mcp/elicitation.d.ts +11 -0
  134. package/esm/mcp/elicitation.d.ts.map +1 -0
  135. package/esm/mcp/elicitation.js +51 -0
  136. package/esm/mcp/elicitation.js.map +1 -0
  137. package/esm/mcp/errors.d.ts +91 -0
  138. package/esm/mcp/errors.d.ts.map +1 -0
  139. package/esm/mcp/errors.js +91 -0
  140. package/esm/mcp/errors.js.map +1 -0
  141. package/esm/mcp/handle.d.ts +66 -0
  142. package/esm/mcp/handle.d.ts.map +1 -0
  143. package/esm/mcp/handle.js +323 -0
  144. package/esm/mcp/handle.js.map +1 -0
  145. package/esm/mcp/index.d.ts +13 -0
  146. package/esm/mcp/index.d.ts.map +1 -0
  147. package/esm/mcp/index.js +11 -0
  148. package/esm/mcp/index.js.map +1 -0
  149. package/esm/mcp/mcp-connection.d.ts +150 -0
  150. package/esm/mcp/mcp-connection.d.ts.map +1 -0
  151. package/esm/mcp/mcp-connection.js +478 -0
  152. package/esm/mcp/mcp-connection.js.map +1 -0
  153. package/esm/mcp/mcp-sdk.d.ts +10 -0
  154. package/esm/mcp/mcp-sdk.d.ts.map +1 -0
  155. package/esm/mcp/mcp-sdk.js +37 -0
  156. package/esm/mcp/mcp-sdk.js.map +1 -0
  157. package/esm/mcp/rehydrate.d.ts +76 -0
  158. package/esm/mcp/rehydrate.d.ts.map +1 -0
  159. package/esm/mcp/rehydrate.js +505 -0
  160. package/esm/mcp/rehydrate.js.map +1 -0
  161. package/esm/mcp/resource-tools.d.ts +17 -0
  162. package/esm/mcp/resource-tools.d.ts.map +1 -0
  163. package/esm/mcp/resource-tools.js +155 -0
  164. package/esm/mcp/resource-tools.js.map +1 -0
  165. package/esm/mcp/result-mapper.d.ts +19 -0
  166. package/esm/mcp/result-mapper.d.ts.map +1 -0
  167. package/esm/mcp/result-mapper.js +40 -0
  168. package/esm/mcp/result-mapper.js.map +1 -0
  169. package/esm/mcp/schema/json-schema-guards.d.ts +14 -0
  170. package/esm/mcp/schema/json-schema-guards.d.ts.map +1 -0
  171. package/esm/mcp/schema/json-schema-guards.js +19 -0
  172. package/esm/mcp/schema/json-schema-guards.js.map +1 -0
  173. package/esm/mcp/schema/json-schema-to-zod.d.ts +20 -0
  174. package/esm/mcp/schema/json-schema-to-zod.d.ts.map +1 -0
  175. package/esm/mcp/schema/json-schema-to-zod.js +72 -0
  176. package/esm/mcp/schema/json-schema-to-zod.js.map +1 -0
  177. package/esm/mcp/tool-wrapper.d.ts +41 -0
  178. package/esm/mcp/tool-wrapper.d.ts.map +1 -0
  179. package/esm/mcp/tool-wrapper.js +131 -0
  180. package/esm/mcp/tool-wrapper.js.map +1 -0
  181. package/esm/mcp/transport-types.d.ts +45 -0
  182. package/esm/mcp/transport-types.d.ts.map +1 -0
  183. package/esm/mcp/transport-types.js +2 -0
  184. package/esm/mcp/transport-types.js.map +1 -0
  185. package/esm/mcp/types.d.ts +119 -0
  186. package/esm/mcp/types.d.ts.map +1 -0
  187. package/esm/mcp/types.js +2 -0
  188. package/esm/mcp/types.js.map +1 -0
  189. package/esm/mcp/version.d.ts +3 -0
  190. package/esm/mcp/version.d.ts.map +1 -0
  191. package/esm/mcp/version.js +5 -0
  192. package/esm/mcp/version.js.map +1 -0
  193. 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