opencode-doom-loop-detector 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +53 -3
- package/dist/index.js +449 -0
- package/package.json +40 -4
- package/src/detector.ts +382 -0
- package/src/fold.ts +90 -0
- package/src/index.ts +278 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 metal-huang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,53 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# opencode-doom-loop-detector
|
|
2
|
+
|
|
3
|
+
Doom-loop detection for opencode v2, as an external plugin.
|
|
4
|
+
|
|
5
|
+
opencode v2 currently has no doom-loop detection: the `doom_loop` action is still accepted by the permission schema, but no code produces it. This plugin restores detection for anyone who installs it, with no changes to the opencode runner.
|
|
6
|
+
|
|
7
|
+
## What it catches
|
|
8
|
+
|
|
9
|
+
Four complementary detectors:
|
|
10
|
+
|
|
11
|
+
1. **Same-message guard** (the v1 behavior, unchanged): the same call emitted 3 times inside one assistant message asks once.
|
|
12
|
+
2. **Signal A** — per-signature identical-output streak (warn 3, block 6). Counting is per signature, so periodic rotations and interleaved calls never reset it. A repeat only counts when the output is byte-identical, so a read-edit-test cycle with real progress never builds a streak.
|
|
13
|
+
3. **Signal B** — line-level information novelty (warn 3/5, surgical ask at 8 only when that signature's A streak is ≥3, circuit breaker at 12). Outputs shorter than 3 lines are exempt.
|
|
14
|
+
4. **Signal C** — per-signature failure-equivalence streak (warn 4, block 6). Volatile error tokens are normalized, and successes of other signatures do not reset it.
|
|
15
|
+
|
|
16
|
+
Every stop goes through the existing `doom_loop` permission ask, so intentional polling always has an escape hatch. A user message resets A/C as well as B and the breaker (per-turn scope), so a periodic legitimate re-check cannot accumulate into a block over days.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
Either use the CLI:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
opencode plugin add opencode-doom-loop-detector
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or add the package to your `opencode.jsonc` directly (opencode installs missing npm plugins automatically at startup):
|
|
27
|
+
|
|
28
|
+
```jsonc
|
|
29
|
+
{
|
|
30
|
+
"plugin": ["opencode-doom-loop-detector"]
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
No configuration is required. The default thresholds are the ones above.
|
|
35
|
+
|
|
36
|
+
## How it works
|
|
37
|
+
|
|
38
|
+
- The detector is a zero-dependency pure module plus a thin plugin on the public `@opencode/plugin` API.
|
|
39
|
+
- State is kept in memory with bounded maps (LRU caps) and a bounded line store, and is rebuilt from durable session history after a restart.
|
|
40
|
+
- Cost is about 0.1 ms per tool call on a real 199-call incident corpus, with no I/O and no runtime dependencies beyond the opencode plugin/schema packages and Effect.
|
|
41
|
+
- A denial of the circuit-breaker prompt makes the stop sticky (until the user speaks); a rejection with feedback (`Permission.CorrectedError`) returns the feedback to the model instead of stopping.
|
|
42
|
+
|
|
43
|
+
## Verification
|
|
44
|
+
|
|
45
|
+
- This package: 45 tests pass (37 detector + 8 integration), `tsc --noEmit` clean.
|
|
46
|
+
- Upstream PR: 67 tests pass, typecheck clean; the review bot confirmed all four threads it raised are fixed.
|
|
47
|
+
- Upstream PR: https://github.com/anomalyco/opencode/pull/61328
|
|
48
|
+
- Incident analysis and call-by-call replays: https://metal-huang.github.io/opencode/doom-loop-report/
|
|
49
|
+
- Prior-art review of 14 harnesses: https://metal-huang.github.io/opencode/doom-loop-report/prior-art.html
|
|
50
|
+
|
|
51
|
+
## License
|
|
52
|
+
|
|
53
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { define } from "@opencode/plugin/effect/plugin";
|
|
3
|
+
import { Effect, Exit, Option, Scope, Stream } from "effect";
|
|
4
|
+
import { Tool } from "@opencode/schema/tool";
|
|
5
|
+
|
|
6
|
+
// src/detector.ts
|
|
7
|
+
var A_WARN = 3;
|
|
8
|
+
var A_BLOCK = 6;
|
|
9
|
+
var B_WARN_LOW = 3;
|
|
10
|
+
var B_WARN_HIGH = 5;
|
|
11
|
+
var B_BLOCK = 8;
|
|
12
|
+
var B_CIRCUIT = 12;
|
|
13
|
+
var B_MIN_LINES = 3;
|
|
14
|
+
var C_WARN = 4;
|
|
15
|
+
var C_BLOCK = 6;
|
|
16
|
+
var GUARD_TRIGGER = 3;
|
|
17
|
+
var UNOBSERVED_PROMPT = 6;
|
|
18
|
+
var LINE_LIMIT = 1e4;
|
|
19
|
+
var LINE_CAP = 1024;
|
|
20
|
+
var TIGHTENED_LINE_KEEP = 1000;
|
|
21
|
+
var SIGNATURE_LIMIT = 4096;
|
|
22
|
+
var FNV_OFFSET = 0xcbf29ce484222325n;
|
|
23
|
+
var FNV_PRIME = 0x100000001b3n;
|
|
24
|
+
var FNV_MASK = 0xffffffffffffffffn;
|
|
25
|
+
function fnv64(text) {
|
|
26
|
+
let hash = FNV_OFFSET;
|
|
27
|
+
for (let index = 0;index < text.length; index++) {
|
|
28
|
+
hash ^= BigInt(text.charCodeAt(index));
|
|
29
|
+
hash = hash * FNV_PRIME & FNV_MASK;
|
|
30
|
+
}
|
|
31
|
+
return hash.toString(16).padStart(16, "0");
|
|
32
|
+
}
|
|
33
|
+
function canonical(value) {
|
|
34
|
+
if (value === null || typeof value !== "object") {
|
|
35
|
+
const primitive = JSON.stringify(value === undefined ? null : value);
|
|
36
|
+
return primitive === undefined ? "null" : primitive;
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(value))
|
|
39
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
40
|
+
const record = value;
|
|
41
|
+
const body = Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",");
|
|
42
|
+
return `{${body}}`;
|
|
43
|
+
}
|
|
44
|
+
var signature = (tool, input) => fnv64(`${tool}:${canonical(input)}`);
|
|
45
|
+
var normalizeVolatile = (line) => line.replace(/[0-9a-f]{8,}/gi, "<id>").replace(/\b\d+(?:\.\d+)?(?:ns|us|ms|s|m|h)\b/g, "<duration>").replace(/\b\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?\b/g, "<timestamp>").replace(/\b\d+(?:\.\d+)?\b/g, "<num>");
|
|
46
|
+
var SEPARATOR = /^[-=_*~ ]{3,}$/;
|
|
47
|
+
var contentLines = (text) => text.split(`
|
|
48
|
+
`).map((line) => line.trim()).filter((line) => line.length > 0 && !SEPARATOR.test(line)).map((line) => normalizeVolatile(line).slice(0, LINE_CAP));
|
|
49
|
+
var stringify = (value) => {
|
|
50
|
+
if (typeof value === "string")
|
|
51
|
+
return value;
|
|
52
|
+
try {
|
|
53
|
+
return JSON.stringify(value) ?? String(value);
|
|
54
|
+
} catch {
|
|
55
|
+
return String(value);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
function outputText(content, output) {
|
|
59
|
+
if (typeof content === "string")
|
|
60
|
+
return content;
|
|
61
|
+
if (content !== undefined && content.length > 0)
|
|
62
|
+
return content.flatMap((item) => item.type === "text" && typeof item.text === "string" ? [item.text] : []).join(`
|
|
63
|
+
`);
|
|
64
|
+
return stringify(output);
|
|
65
|
+
}
|
|
66
|
+
var copy = {
|
|
67
|
+
warnA: (tool, streak) => `The ${tool} call with these exact arguments has returned byte-identical output ${streak} times in a row. It is not producing new information. Change approach: inspect the actual current state, or ask the user.`,
|
|
68
|
+
warnB3: `Your recent tool calls have produced no new information. Before making another call, write down what new information you expect the next call to reveal.`,
|
|
69
|
+
warnB5: `These repeated calls keep returning nothing new. Choose one: run a falsification check on your current hypothesis, ask the user for the missing input, or conclude based on the evidence you already have.`,
|
|
70
|
+
warnC: (tool, streak) => `The ${tool} tool has failed ${streak} times in a row with the same error. Stop retrying it unchanged. Inspect the target or ask the user before trying again.`,
|
|
71
|
+
blockA: (tool, streak) => `This exact ${tool} call has returned identical output ${streak} times in a row and is blocked as a suspected repetition loop. If this repetition is intentional (for example user-requested polling), approve the doom_loop permission to continue.`,
|
|
72
|
+
blockB: `Recent calls keep producing nothing new, and this exact call has already returned identical output repeatedly. It is blocked as suspected non-progress. Run a falsification check, ask the user for missing input, or conclude with the evidence at hand.`,
|
|
73
|
+
blockC: (tool, streak) => `The ${tool} tool has failed ${streak} times in a row with the same error and is blocked as a suspected retry loop. Inspect the target state or ask the user before trying again.`,
|
|
74
|
+
circuit: `Twelve recent tool calls in a row produced no new information. Stop making tool calls entirely. Summarize the current state, the hypotheses you have ruled out, and what input you need from the user, in plain text.`,
|
|
75
|
+
guard: (count, tool) => `This exact ${tool} call has already been emitted ${count} times within the current assistant message. If the same invocation is repeating without progress, stop and report to the user instead.`,
|
|
76
|
+
guardMerged: (count, tool, aStreak) => `This exact ${tool} call has been emitted ${count} times within the current assistant message and returned identical output ${aStreak} times before. Stop repeating it and report to the user instead.`,
|
|
77
|
+
unobserved: `This call's progress cannot be observed from the session output (for example a background shell). If you are running it in a loop: write progress to a file and report from it, report the current status directly to the user, or stop and summarize.`
|
|
78
|
+
};
|
|
79
|
+
var resourceSuffix = { guard: ":repeat", unobserved: ":unobserved", a: "", b: "", c: "" };
|
|
80
|
+
var VALIDATION_PREFIX = "Invalid arguments for tool";
|
|
81
|
+
function injectedWarnings(tool) {
|
|
82
|
+
const a = copy.warnA(tool, A_WARN);
|
|
83
|
+
return [a, copy.warnB3, copy.warnB5, `${a}
|
|
84
|
+
|
|
85
|
+
${copy.warnB3}`, `${a}
|
|
86
|
+
|
|
87
|
+
${copy.warnB5}`];
|
|
88
|
+
}
|
|
89
|
+
var injectedFailureSuffix = (tool) => `
|
|
90
|
+
|
|
91
|
+
${copy.warnC(tool, C_WARN)}`;
|
|
92
|
+
var blockedMetadata = { doom_loop: "blocked" };
|
|
93
|
+
var hasBlockedMetadata = (metadata) => metadata?.doom_loop === blockedMetadata.doom_loop;
|
|
94
|
+
function state() {
|
|
95
|
+
return {
|
|
96
|
+
signatures: new Map,
|
|
97
|
+
lines: { set: new Set, order: [] },
|
|
98
|
+
bStreak: 0,
|
|
99
|
+
failures: new Map,
|
|
100
|
+
emitted: { messageID: "", counts: new Map },
|
|
101
|
+
unobserved: new Map,
|
|
102
|
+
highTierPrompted: false,
|
|
103
|
+
circuitBroken: false
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function beforeCall(current, call) {
|
|
107
|
+
if (current.circuitBroken)
|
|
108
|
+
return { kind: "circuit", message: copy.circuit, sticky: true };
|
|
109
|
+
if (current.bStreak >= B_CIRCUIT)
|
|
110
|
+
return { kind: "circuit", message: copy.circuit, sticky: false };
|
|
111
|
+
if (current.emitted.messageID !== call.messageID) {
|
|
112
|
+
current.emitted = { messageID: call.messageID, counts: new Map };
|
|
113
|
+
}
|
|
114
|
+
const emitted = (current.emitted.counts.get(call.sigHash) ?? 0) + 1;
|
|
115
|
+
current.emitted.counts.set(call.sigHash, emitted);
|
|
116
|
+
if (emitted === GUARD_TRIGGER) {
|
|
117
|
+
const entry = current.signatures.get(call.sigHash);
|
|
118
|
+
const message = entry !== undefined && entry.streak >= A_WARN ? copy.guardMerged(emitted, call.tool, entry.streak) : copy.guard(emitted, call.tool);
|
|
119
|
+
return { kind: "assert", signal: "guard", message };
|
|
120
|
+
}
|
|
121
|
+
const unobserved = current.unobserved.get(call.sigHash) ?? 0;
|
|
122
|
+
if (unobserved >= UNOBSERVED_PROMPT && !current.highTierPrompted) {
|
|
123
|
+
current.highTierPrompted = true;
|
|
124
|
+
return { kind: "assert", signal: "unobserved", message: copy.unobserved };
|
|
125
|
+
}
|
|
126
|
+
const entry = current.signatures.get(call.sigHash);
|
|
127
|
+
if (entry !== undefined && entry.streak >= A_BLOCK) {
|
|
128
|
+
return { kind: "assert", signal: "a", message: copy.blockA(call.tool, entry.streak) };
|
|
129
|
+
}
|
|
130
|
+
if (current.bStreak >= B_BLOCK && entry !== undefined && entry.streak >= A_WARN) {
|
|
131
|
+
return { kind: "assert", signal: "b", message: copy.blockB };
|
|
132
|
+
}
|
|
133
|
+
const failure = current.failures.get(call.sigHash);
|
|
134
|
+
if (failure !== undefined && failure.streak >= C_BLOCK) {
|
|
135
|
+
return { kind: "assert", signal: "c", message: copy.blockC(call.tool, failure.streak) };
|
|
136
|
+
}
|
|
137
|
+
return { kind: "pass" };
|
|
138
|
+
}
|
|
139
|
+
function lruSet(map, key, value) {
|
|
140
|
+
map.delete(key);
|
|
141
|
+
map.set(key, value);
|
|
142
|
+
if (map.size <= SIGNATURE_LIMIT)
|
|
143
|
+
return;
|
|
144
|
+
const oldest = map.keys().next().value;
|
|
145
|
+
if (oldest !== undefined)
|
|
146
|
+
map.delete(oldest);
|
|
147
|
+
}
|
|
148
|
+
function recordCompleted(current, call) {
|
|
149
|
+
current.failures.delete(call.sigHash);
|
|
150
|
+
const outputHash = fnv64(call.text);
|
|
151
|
+
const previous = current.signatures.get(call.sigHash);
|
|
152
|
+
const entry = previous !== undefined && previous.lastOutputHash === outputHash ? { lastOutputHash: outputHash, streak: previous.streak + 1 } : { lastOutputHash: outputHash, streak: 1 };
|
|
153
|
+
lruSet(current.signatures, call.sigHash, entry);
|
|
154
|
+
const warnings = [];
|
|
155
|
+
if (entry.streak === A_WARN)
|
|
156
|
+
warnings.push(copy.warnA(call.tool, entry.streak));
|
|
157
|
+
const lines = contentLines(call.text);
|
|
158
|
+
if (lines.length >= B_MIN_LINES) {
|
|
159
|
+
const distinct = [...new Set(lines)];
|
|
160
|
+
let novel = 0;
|
|
161
|
+
for (const line of distinct) {
|
|
162
|
+
if (!current.lines.set.has(line))
|
|
163
|
+
novel++;
|
|
164
|
+
}
|
|
165
|
+
const productive = novel >= 2 || novel / lines.length >= 0.5;
|
|
166
|
+
if (productive) {
|
|
167
|
+
current.bStreak = 0;
|
|
168
|
+
} else {
|
|
169
|
+
current.bStreak++;
|
|
170
|
+
if (current.bStreak === B_WARN_LOW)
|
|
171
|
+
warnings.push(copy.warnB3);
|
|
172
|
+
if (current.bStreak === B_WARN_HIGH)
|
|
173
|
+
warnings.push(copy.warnB5);
|
|
174
|
+
}
|
|
175
|
+
for (const line of distinct)
|
|
176
|
+
addLine(current, line);
|
|
177
|
+
}
|
|
178
|
+
return warnings.length === 0 ? undefined : warnings.join(`
|
|
179
|
+
|
|
180
|
+
`);
|
|
181
|
+
}
|
|
182
|
+
function addLine(current, line) {
|
|
183
|
+
if (current.lines.set.has(line))
|
|
184
|
+
return;
|
|
185
|
+
current.lines.set.add(line);
|
|
186
|
+
current.lines.order.push(line);
|
|
187
|
+
if (current.lines.order.length > LINE_LIMIT) {
|
|
188
|
+
const evicted = current.lines.order.splice(0, LINE_LIMIT / 2);
|
|
189
|
+
for (const old of evicted)
|
|
190
|
+
current.lines.set.delete(old);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function recordFailed(current, call) {
|
|
194
|
+
if (call.validation)
|
|
195
|
+
return;
|
|
196
|
+
const failureHash = fnv64(normalizeVolatile(call.errorText));
|
|
197
|
+
const previous = current.failures.get(call.sigHash);
|
|
198
|
+
const entry = previous !== undefined && previous.lastFailureHash === failureHash ? { lastFailureHash: failureHash, streak: previous.streak + 1 } : { lastFailureHash: failureHash, streak: 1 };
|
|
199
|
+
lruSet(current.failures, call.sigHash, entry);
|
|
200
|
+
if (entry.streak === C_WARN)
|
|
201
|
+
return copy.warnC(call.tool, entry.streak);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
function recordUnobserved(current, sigHash) {
|
|
205
|
+
lruSet(current.unobserved, sigHash, (current.unobserved.get(sigHash) ?? 0) + 1);
|
|
206
|
+
}
|
|
207
|
+
function compacted(current) {
|
|
208
|
+
for (const entry of current.signatures.values())
|
|
209
|
+
entry.streak = 0;
|
|
210
|
+
current.lines.set.clear();
|
|
211
|
+
current.lines.order = [];
|
|
212
|
+
current.bStreak = 0;
|
|
213
|
+
}
|
|
214
|
+
function userMessage(current) {
|
|
215
|
+
current.signatures.clear();
|
|
216
|
+
current.failures.clear();
|
|
217
|
+
current.bStreak = 0;
|
|
218
|
+
current.circuitBroken = false;
|
|
219
|
+
}
|
|
220
|
+
function filesChanged(current) {
|
|
221
|
+
current.bStreak = 0;
|
|
222
|
+
}
|
|
223
|
+
function tighten(current) {
|
|
224
|
+
if (current.lines.order.length <= TIGHTENED_LINE_KEEP)
|
|
225
|
+
return;
|
|
226
|
+
const evicted = current.lines.order.splice(0, current.lines.order.length - TIGHTENED_LINE_KEEP);
|
|
227
|
+
for (const old of evicted)
|
|
228
|
+
current.lines.set.delete(old);
|
|
229
|
+
}
|
|
230
|
+
// src/fold.ts
|
|
231
|
+
var UNEXECUTED_ERROR_TYPES = new Set(["aborted", "tool.input-json", "tool.input-incomplete"]);
|
|
232
|
+
function fold(state, messages) {
|
|
233
|
+
for (const message of messages) {
|
|
234
|
+
if (message.type === "user") {
|
|
235
|
+
userMessage(state);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (message.type !== "assistant")
|
|
239
|
+
continue;
|
|
240
|
+
for (const part of message.content) {
|
|
241
|
+
if (part.type !== "tool" || part.name === "execute")
|
|
242
|
+
continue;
|
|
243
|
+
if (part.state.status === "error") {
|
|
244
|
+
if (hasBlockedMetadata(part.state.metadata) || UNEXECUTED_ERROR_TYPES.has(part.state.error.type))
|
|
245
|
+
continue;
|
|
246
|
+
const suffix = injectedFailureSuffix(part.name);
|
|
247
|
+
const message = part.state.error.message;
|
|
248
|
+
const errorText = message.endsWith(suffix) ? message.slice(0, -suffix.length) : message;
|
|
249
|
+
recordFailed(state, {
|
|
250
|
+
tool: part.name,
|
|
251
|
+
sigHash: signature(part.name, part.state.input),
|
|
252
|
+
errorText,
|
|
253
|
+
validation: errorText.startsWith(VALIDATION_PREFIX)
|
|
254
|
+
});
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (part.state.status !== "completed")
|
|
258
|
+
continue;
|
|
259
|
+
const sigHash = signature(part.name, part.state.input);
|
|
260
|
+
if (part.name === "question" || part.name === "shell" && part.state.metadata?.status === "running") {
|
|
261
|
+
recordUnobserved(state, sigHash);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const first = part.state.content[0];
|
|
265
|
+
const content = first !== undefined && first.type === "text" && injectedWarnings(part.name).includes(first.text) ? part.state.content.slice(1) : part.state.content;
|
|
266
|
+
recordCompleted(state, {
|
|
267
|
+
tool: part.name,
|
|
268
|
+
sigHash,
|
|
269
|
+
text: outputText(content)
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
tighten(state);
|
|
274
|
+
state.bStreak = 0;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// src/index.ts
|
|
278
|
+
var DEFAULT_MAX_TRACKED_SESSIONS = 64;
|
|
279
|
+
var circuitKey = (sessionID) => `circuit:${sessionID}`;
|
|
280
|
+
function makePlugin(options) {
|
|
281
|
+
const cap = options?.maxTrackedSessions ?? DEFAULT_MAX_TRACKED_SESSIONS;
|
|
282
|
+
return define({
|
|
283
|
+
id: "opencode.doom.loop",
|
|
284
|
+
effect: Effect.fn(function* (ctx) {
|
|
285
|
+
const scope = yield* Scope.Scope;
|
|
286
|
+
const sessions = new Map;
|
|
287
|
+
const folded = (sessionID) => Effect.runPromise(Effect.gen(function* () {
|
|
288
|
+
const entry = { state: state() };
|
|
289
|
+
const messages = yield* ctx.session.context({ sessionID });
|
|
290
|
+
fold(entry.state, messages);
|
|
291
|
+
if ((yield* ctx.storage.get(circuitKey(sessionID))) === true)
|
|
292
|
+
entry.state.circuitBroken = true;
|
|
293
|
+
return entry;
|
|
294
|
+
})).catch(() => ({ state: state() }));
|
|
295
|
+
const session = (sessionID) => {
|
|
296
|
+
const existing = sessions.get(sessionID);
|
|
297
|
+
if (existing) {
|
|
298
|
+
sessions.delete(sessionID);
|
|
299
|
+
sessions.set(sessionID, existing);
|
|
300
|
+
return existing;
|
|
301
|
+
}
|
|
302
|
+
const created = folded(sessionID);
|
|
303
|
+
sessions.set(sessionID, created);
|
|
304
|
+
while (sessions.size > cap) {
|
|
305
|
+
const oldest = sessions.keys().next().value;
|
|
306
|
+
if (oldest === undefined)
|
|
307
|
+
break;
|
|
308
|
+
sessions.delete(oldest);
|
|
309
|
+
}
|
|
310
|
+
return created;
|
|
311
|
+
};
|
|
312
|
+
const mutate = (sessionID, fn) => {
|
|
313
|
+
const existing = sessions.get(sessionID);
|
|
314
|
+
if (existing)
|
|
315
|
+
sessions.set(sessionID, existing.then((entry) => {
|
|
316
|
+
fn(entry.state);
|
|
317
|
+
return entry;
|
|
318
|
+
}));
|
|
319
|
+
};
|
|
320
|
+
yield* ctx.tool.hook("execute.before", (event) => Effect.gen(function* () {
|
|
321
|
+
if (event.tool === "execute")
|
|
322
|
+
return;
|
|
323
|
+
const current = yield* Effect.promise(() => session(event.sessionID));
|
|
324
|
+
const sigHash = signature(event.tool, event.input);
|
|
325
|
+
const verdict = beforeCall(current.state, {
|
|
326
|
+
tool: event.tool,
|
|
327
|
+
sigHash,
|
|
328
|
+
messageID: event.messageID
|
|
329
|
+
});
|
|
330
|
+
if (verdict.kind === "pass")
|
|
331
|
+
return;
|
|
332
|
+
if (verdict.kind === "circuit") {
|
|
333
|
+
if (verdict.sticky) {
|
|
334
|
+
yield* ctx.session.interrupt({ sessionID: event.sessionID }).pipe(Effect.ignore, Effect.forkIn(scope));
|
|
335
|
+
return yield* new Tool.Error({ message: verdict.message, metadata: blockedMetadata });
|
|
336
|
+
}
|
|
337
|
+
const resource = `${event.tool}:${sigHash}${resourceSuffix.b}`;
|
|
338
|
+
const outcome = yield* Effect.exit(ctx.permission.assert({
|
|
339
|
+
sessionID: event.sessionID,
|
|
340
|
+
agent: event.agent,
|
|
341
|
+
action: "doom_loop",
|
|
342
|
+
resources: [resource],
|
|
343
|
+
save: [resource],
|
|
344
|
+
source: { type: "tool", messageID: event.messageID, id: event.id }
|
|
345
|
+
}));
|
|
346
|
+
if (Exit.isSuccess(outcome)) {
|
|
347
|
+
current.state.bStreak = 0;
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const error = Option.getOrUndefined(Exit.findErrorOption(outcome));
|
|
351
|
+
const tagOf = (value) => typeof value === "object" && value !== null && ("_tag" in value) ? value._tag : undefined;
|
|
352
|
+
const corrected = tagOf(error) === "Permission.CorrectedError" || error instanceof Tool.Error && tagOf(error.error) === "Permission.CorrectedError";
|
|
353
|
+
if (!corrected) {
|
|
354
|
+
yield* ctx.storage.set(circuitKey(event.sessionID), true).pipe(Effect.ignore);
|
|
355
|
+
current.state.circuitBroken = true;
|
|
356
|
+
yield* ctx.session.interrupt({ sessionID: event.sessionID }).pipe(Effect.ignore, Effect.forkIn(scope));
|
|
357
|
+
}
|
|
358
|
+
return yield* new Tool.Error({
|
|
359
|
+
message: `${verdict.message}
|
|
360
|
+
|
|
361
|
+
${error?.message ?? "rejected"}`,
|
|
362
|
+
metadata: blockedMetadata
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
const resource = `${event.tool}:${sigHash}${resourceSuffix[verdict.signal]}`;
|
|
366
|
+
return yield* ctx.permission.assert({
|
|
367
|
+
sessionID: event.sessionID,
|
|
368
|
+
agent: event.agent,
|
|
369
|
+
action: "doom_loop",
|
|
370
|
+
resources: [resource],
|
|
371
|
+
save: [resource],
|
|
372
|
+
source: { type: "tool", messageID: event.messageID, id: event.id }
|
|
373
|
+
}).pipe(Effect.mapError((error) => new Tool.Error({
|
|
374
|
+
message: `${verdict.message}
|
|
375
|
+
|
|
376
|
+
${error.message}`,
|
|
377
|
+
metadata: blockedMetadata
|
|
378
|
+
})));
|
|
379
|
+
}));
|
|
380
|
+
yield* ctx.tool.hook("execute.after", (event) => Effect.gen(function* () {
|
|
381
|
+
if (event.tool === "execute")
|
|
382
|
+
return;
|
|
383
|
+
const current = yield* Effect.promise(() => session(event.sessionID));
|
|
384
|
+
const sigHash = signature(event.tool, event.input);
|
|
385
|
+
if (event.status === "error") {
|
|
386
|
+
const warning = recordFailed(current.state, {
|
|
387
|
+
tool: event.tool,
|
|
388
|
+
sigHash,
|
|
389
|
+
errorText: event.error.message,
|
|
390
|
+
validation: event.error.message.startsWith(VALIDATION_PREFIX)
|
|
391
|
+
});
|
|
392
|
+
if (warning !== undefined)
|
|
393
|
+
event.error.message = `${event.error.message}
|
|
394
|
+
|
|
395
|
+
${warning}`;
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const unobserved = event.tool === "question" || event.tool === "shell" && event.result.metadata !== undefined && event.result.metadata.status === "running";
|
|
399
|
+
if (unobserved) {
|
|
400
|
+
recordUnobserved(current.state, sigHash);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const text = outputText(event.result.content, event.result.output);
|
|
404
|
+
const warning = recordCompleted(current.state, { tool: event.tool, sigHash, text });
|
|
405
|
+
if (warning === undefined)
|
|
406
|
+
return;
|
|
407
|
+
const existing = event.result.content;
|
|
408
|
+
event.result = {
|
|
409
|
+
...event.result,
|
|
410
|
+
content: [
|
|
411
|
+
{ type: "text", text: warning },
|
|
412
|
+
...typeof existing === "string" ? [{ type: "text", text: existing }] : existing ?? []
|
|
413
|
+
]
|
|
414
|
+
};
|
|
415
|
+
}));
|
|
416
|
+
yield* ctx.event.subscribe().pipe(Stream.filter((event) => event.type === "session.deleted" || event.type === "session.inbox.enqueued" || event.type === "session.step.ended" || event.type === "session.compaction.ended" || event.type === "session.execution.succeeded" || event.type === "session.execution.failed" || event.type === "session.execution.interrupted"), Stream.runForEach((event) => Effect.gen(function* () {
|
|
417
|
+
const sessionID = event.data.sessionID;
|
|
418
|
+
if (event.type === "session.deleted") {
|
|
419
|
+
sessions.delete(sessionID);
|
|
420
|
+
yield* ctx.storage.remove(circuitKey(sessionID)).pipe(Effect.ignore);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (event.type === "session.inbox.enqueued") {
|
|
424
|
+
if (event.data.item.type !== "user")
|
|
425
|
+
return;
|
|
426
|
+
yield* ctx.storage.remove(circuitKey(sessionID)).pipe(Effect.ignore);
|
|
427
|
+
mutate(sessionID, userMessage);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (event.type === "session.step.ended") {
|
|
431
|
+
if (event.data.files === undefined || event.data.files.length === 0)
|
|
432
|
+
return;
|
|
433
|
+
mutate(sessionID, filesChanged);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (event.type === "session.compaction.ended") {
|
|
437
|
+
mutate(sessionID, compacted);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
mutate(sessionID, tighten);
|
|
441
|
+
})), Effect.forkScoped({ startImmediately: true }));
|
|
442
|
+
})
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
var Plugin = makePlugin();
|
|
446
|
+
export {
|
|
447
|
+
Plugin,
|
|
448
|
+
makePlugin
|
|
449
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-doom-loop-detector",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Doom-loop detection for opencode v2: catches repeated tool-call loops (consecutive, periodic rotation, failure streaks) and stops them through the existing doom_loop permission ask.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": "github:metal-huang/opencode-doom-loop-detector",
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"src",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "bun build src/index.ts --outfile dist/index.js --external @opencode/plugin --external @opencode/schema --external effect",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"test": "bun test"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@opencode/plugin": "^2.2.5",
|
|
25
|
+
"@opencode/schema": "^2.2.5",
|
|
26
|
+
"effect": "4.0.6"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@opencode/plugin": "^2.2.5",
|
|
30
|
+
"@opencode/schema": "^2.2.5",
|
|
31
|
+
"effect": "4.0.6",
|
|
32
|
+
"typescript": "^5.9.0"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"opencode",
|
|
36
|
+
"opencode-plugin",
|
|
37
|
+
"plugin",
|
|
38
|
+
"doom-loop",
|
|
39
|
+
"loop-detection",
|
|
40
|
+
"agent"
|
|
41
|
+
]
|
|
42
|
+
}
|
package/src/detector.ts
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
export * as DoomLoopDetector from "./detector.js"
|
|
2
|
+
|
|
3
|
+
// Pure doom-loop detection core: no Effect, no opencode imports, portable across hosts.
|
|
4
|
+
// Four detectors:
|
|
5
|
+
// - Signal A: per-signature (tool + canonical input) identical-output streak. Warn 3, block 6.
|
|
6
|
+
// - Signal B: global line-level information novelty. Warn 3/5, surgical block 8 (needs A streak >= 3
|
|
7
|
+
// as corroboration), circuit breaker 12 (hard stop until the user speaks).
|
|
8
|
+
// - Signal C: per-tool failure-equivalence streak (volatile tokens normalized before hashing).
|
|
9
|
+
// Warn 4, block 6. Interleaved successes of other tools do not reset it; a success of the same
|
|
10
|
+
// tool does.
|
|
11
|
+
// - Same-message repeat guard: identical (tool, input) emitted 3 times within one assistant message
|
|
12
|
+
// asks exactly once (== 3, not >= 3, so later high-tier prompts cannot be starved by it).
|
|
13
|
+
// Thresholds are accounted in after hooks and read in before hooks: "block at N" means the call
|
|
14
|
+
// emitted after the Nth accounting (the N+1th emission) is the one challenged in before.
|
|
15
|
+
|
|
16
|
+
const A_WARN = 3
|
|
17
|
+
const A_BLOCK = 6
|
|
18
|
+
const B_WARN_LOW = 3
|
|
19
|
+
const B_WARN_HIGH = 5
|
|
20
|
+
const B_BLOCK = 8
|
|
21
|
+
const B_CIRCUIT = 12
|
|
22
|
+
const B_MIN_LINES = 3
|
|
23
|
+
const C_WARN = 4
|
|
24
|
+
const C_BLOCK = 6
|
|
25
|
+
const GUARD_TRIGGER = 3
|
|
26
|
+
const UNOBSERVED_PROMPT = 6
|
|
27
|
+
const LINE_LIMIT = 10_000
|
|
28
|
+
const LINE_CAP = 1024
|
|
29
|
+
const TIGHTENED_LINE_KEEP = 1_000
|
|
30
|
+
// Per-session cap for the signature (A) and unobserved maps, each bounded independently. An
|
|
31
|
+
// entry is ~100 bytes, so a session worst-cases at ~400KB per map.
|
|
32
|
+
const SIGNATURE_LIMIT = 4_096
|
|
33
|
+
|
|
34
|
+
const FNV_OFFSET = 0xcbf29ce484222325n
|
|
35
|
+
const FNV_PRIME = 0x100000001b3n
|
|
36
|
+
const FNV_MASK = 0xffffffffffffffffn
|
|
37
|
+
|
|
38
|
+
export function fnv64(text: string): string {
|
|
39
|
+
let hash = FNV_OFFSET
|
|
40
|
+
for (let index = 0; index < text.length; index++) {
|
|
41
|
+
hash ^= BigInt(text.charCodeAt(index))
|
|
42
|
+
hash = (hash * FNV_PRIME) & FNV_MASK
|
|
43
|
+
}
|
|
44
|
+
return hash.toString(16).padStart(16, "0")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Canonical JSON with recursively sorted keys so semantically identical inputs hash identically
|
|
48
|
+
// regardless of key order. Bash AST normalization is deliberately omitted (decided): `ls` vs
|
|
49
|
+
// `ls -la` are distinct signatures and escape A, which signal B covers instead.
|
|
50
|
+
export function canonical(value: unknown): string {
|
|
51
|
+
if (value === null || typeof value !== "object") {
|
|
52
|
+
const primitive = JSON.stringify(value === undefined ? null : value)
|
|
53
|
+
return primitive === undefined ? "null" : primitive
|
|
54
|
+
}
|
|
55
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`
|
|
56
|
+
const record = value as Record<string, unknown>
|
|
57
|
+
const body = Object.keys(record)
|
|
58
|
+
.sort()
|
|
59
|
+
.map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`)
|
|
60
|
+
.join(",")
|
|
61
|
+
return `{${body}}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const signature = (tool: string, input: unknown) => fnv64(`${tool}:${canonical(input)}`)
|
|
65
|
+
|
|
66
|
+
// Volatile tokens (ids, durations, timestamps, plain numbers) collapse to placeholders so that
|
|
67
|
+
// "in 0.03s" and "in 0.05s" normalize to the same line and the same failure identity.
|
|
68
|
+
const normalizeVolatile = (line: string): string =>
|
|
69
|
+
line
|
|
70
|
+
.replace(/[0-9a-f]{8,}/gi, "<id>")
|
|
71
|
+
.replace(/\b\d+(?:\.\d+)?(?:ns|us|ms|s|m|h)\b/g, "<duration>")
|
|
72
|
+
.replace(/\b\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?\b/g, "<timestamp>")
|
|
73
|
+
.replace(/\b\d+(?:\.\d+)?\b/g, "<num>")
|
|
74
|
+
|
|
75
|
+
const SEPARATOR = /^[-=_*~ ]{3,}$/
|
|
76
|
+
|
|
77
|
+
// Lines enter the global novelty store normalized and capped at 1KB each (memory bound).
|
|
78
|
+
const contentLines = (text: string): string[] =>
|
|
79
|
+
text
|
|
80
|
+
.split("\n")
|
|
81
|
+
.map((line) => line.trim())
|
|
82
|
+
.filter((line) => line.length > 0 && !SEPARATOR.test(line))
|
|
83
|
+
.map((line) => normalizeVolatile(line).slice(0, LINE_CAP))
|
|
84
|
+
|
|
85
|
+
const stringify = (value: unknown): string => {
|
|
86
|
+
if (typeof value === "string") return value
|
|
87
|
+
try {
|
|
88
|
+
return JSON.stringify(value) ?? String(value)
|
|
89
|
+
} catch {
|
|
90
|
+
return String(value)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Mirrors the runtime content normalization: a tool result carries either string content, content
|
|
95
|
+
// parts, or (for output-only tools) falls back to the stringified machine output. Hashing must use
|
|
96
|
+
// this normalized view so output-only tools do not all hash as one constant empty string.
|
|
97
|
+
export function outputText(
|
|
98
|
+
content: string | ReadonlyArray<{ readonly type: string; readonly text?: string }> | undefined,
|
|
99
|
+
output?: unknown,
|
|
100
|
+
): string {
|
|
101
|
+
if (typeof content === "string") return content
|
|
102
|
+
if (content !== undefined && content.length > 0)
|
|
103
|
+
return content
|
|
104
|
+
.flatMap((item) => (item.type === "text" && typeof item.text === "string" ? [item.text] : []))
|
|
105
|
+
.join("\n")
|
|
106
|
+
return stringify(output)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const copy = {
|
|
110
|
+
warnA: (tool: string, streak: number): string =>
|
|
111
|
+
`The ${tool} call with these exact arguments has returned byte-identical output ${streak} times in a row. It is not producing new information. Change approach: inspect the actual current state, or ask the user.`,
|
|
112
|
+
warnB3: `Your recent tool calls have produced no new information. Before making another call, write down what new information you expect the next call to reveal.`,
|
|
113
|
+
warnB5: `These repeated calls keep returning nothing new. Choose one: run a falsification check on your current hypothesis, ask the user for the missing input, or conclude based on the evidence you already have.`,
|
|
114
|
+
warnC: (tool: string, streak: number): string =>
|
|
115
|
+
`The ${tool} tool has failed ${streak} times in a row with the same error. Stop retrying it unchanged. Inspect the target or ask the user before trying again.`,
|
|
116
|
+
blockA: (tool: string, streak: number): string =>
|
|
117
|
+
`This exact ${tool} call has returned identical output ${streak} times in a row and is blocked as a suspected repetition loop. If this repetition is intentional (for example user-requested polling), approve the doom_loop permission to continue.`,
|
|
118
|
+
blockB: `Recent calls keep producing nothing new, and this exact call has already returned identical output repeatedly. It is blocked as suspected non-progress. Run a falsification check, ask the user for missing input, or conclude with the evidence at hand.`,
|
|
119
|
+
blockC: (tool: string, streak: number): string =>
|
|
120
|
+
`The ${tool} tool has failed ${streak} times in a row with the same error and is blocked as a suspected retry loop. Inspect the target state or ask the user before trying again.`,
|
|
121
|
+
circuit: `Twelve recent tool calls in a row produced no new information. Stop making tool calls entirely. Summarize the current state, the hypotheses you have ruled out, and what input you need from the user, in plain text.`,
|
|
122
|
+
guard: (count: number, tool: string): string =>
|
|
123
|
+
`This exact ${tool} call has already been emitted ${count} times within the current assistant message. If the same invocation is repeating without progress, stop and report to the user instead.`,
|
|
124
|
+
guardMerged: (count: number, tool: string, aStreak: number): string =>
|
|
125
|
+
`This exact ${tool} call has been emitted ${count} times within the current assistant message and returned identical output ${aStreak} times before. Stop repeating it and report to the user instead.`,
|
|
126
|
+
unobserved: `This call's progress cannot be observed from the session output (for example a background shell). If you are running it in a loop: write progress to a file and report from it, report the current status directly to the user, or stop and summarize.`,
|
|
127
|
+
} as const
|
|
128
|
+
|
|
129
|
+
// Resource/save strings are `${tool}:${sigHash}` with a per-signal suffix so an "always" answer on
|
|
130
|
+
// one prompt never silently exempts another signal's prompt for the same signature.
|
|
131
|
+
export const resourceSuffix = { guard: ":repeat", unobserved: ":unobserved", a: "", b: "", c: "" } as const
|
|
132
|
+
|
|
133
|
+
// Host-specific strings that mark argument-validation rejections (the tool never executed).
|
|
134
|
+
// Kept in one place so a host copy change is a one-line fix; an unmatched message is treated as a
|
|
135
|
+
// real failure (conservative: counts into C rather than silently exempting it).
|
|
136
|
+
export const VALIDATION_PREFIX = "Invalid arguments for tool"
|
|
137
|
+
|
|
138
|
+
// Texts the live after hook can inject into a persisted result: warnings prepend to a completed
|
|
139
|
+
// result's content and append to a failed call's error message. They fire only at fixed streaks,
|
|
140
|
+
// so the complete set is enumerable. The fold strips them to hash the same text the live path
|
|
141
|
+
// accounted — live hashes before injecting.
|
|
142
|
+
export function injectedWarnings(tool: string): ReadonlyArray<string> {
|
|
143
|
+
const a = copy.warnA(tool, A_WARN)
|
|
144
|
+
return [a, copy.warnB3, copy.warnB5, `${a}\n\n${copy.warnB3}`, `${a}\n\n${copy.warnB5}`]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export const injectedFailureSuffix = (tool: string): string => `\n\n${copy.warnC(tool, C_WARN)}`
|
|
148
|
+
|
|
149
|
+
// Marker the plugin attaches to Tool.Errors raised by its before hook. It persists on the error
|
|
150
|
+
// part (runner failTool -> Tool.Failed metadata), letting the fold tell a blocked call — never
|
|
151
|
+
// executed, never accounted live — apart from a real execution failure.
|
|
152
|
+
export const blockedMetadata = { doom_loop: "blocked" } as const
|
|
153
|
+
|
|
154
|
+
export const hasBlockedMetadata = (metadata: Record<string, unknown> | undefined): boolean =>
|
|
155
|
+
metadata?.doom_loop === blockedMetadata.doom_loop
|
|
156
|
+
|
|
157
|
+
export type Verdict =
|
|
158
|
+
| { readonly kind: "pass" }
|
|
159
|
+
| { readonly kind: "circuit"; readonly message: string; readonly sticky: boolean }
|
|
160
|
+
| {
|
|
161
|
+
readonly kind: "assert"
|
|
162
|
+
readonly signal: keyof typeof resourceSuffix
|
|
163
|
+
readonly message: string
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface SignatureEntry {
|
|
167
|
+
lastOutputHash: string
|
|
168
|
+
streak: number
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface FailureEntry {
|
|
172
|
+
lastFailureHash: string
|
|
173
|
+
streak: number
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface SessionState {
|
|
177
|
+
signatures: Map<string, SignatureEntry>
|
|
178
|
+
lines: { set: Set<string>; order: string[] }
|
|
179
|
+
bStreak: number
|
|
180
|
+
failures: Map<string, FailureEntry>
|
|
181
|
+
// Only the current message's bucket is retained; a new messageID drops the old one.
|
|
182
|
+
emitted: { messageID: string; counts: Map<string, number> }
|
|
183
|
+
unobserved: Map<string, number>
|
|
184
|
+
highTierPrompted: boolean
|
|
185
|
+
circuitBroken: boolean
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function state(): SessionState {
|
|
189
|
+
return {
|
|
190
|
+
signatures: new Map(),
|
|
191
|
+
lines: { set: new Set(), order: [] },
|
|
192
|
+
bStreak: 0,
|
|
193
|
+
failures: new Map(),
|
|
194
|
+
emitted: { messageID: "", counts: new Map() },
|
|
195
|
+
unobserved: new Map(),
|
|
196
|
+
highTierPrompted: false,
|
|
197
|
+
circuitBroken: false,
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface Call {
|
|
202
|
+
readonly tool: string
|
|
203
|
+
readonly sigHash: string
|
|
204
|
+
readonly messageID: string
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Short-circuit order: circuit breaker -> same-message guard (==3, one-shot) -> unobserved-layer
|
|
208
|
+
// one-time prompt -> A >= 6 -> B >= 8 surgical (requires A streak >= 3 corroboration) -> C >= 6.
|
|
209
|
+
// The first hit is the only action for this call. A circuit hit is a permission ask like every
|
|
210
|
+
// other signal: allow resets B and lets the run continue; a denial makes it sticky (hard stop).
|
|
211
|
+
export function beforeCall(current: SessionState, call: Call): Verdict {
|
|
212
|
+
if (current.circuitBroken) return { kind: "circuit", message: copy.circuit, sticky: true }
|
|
213
|
+
if (current.bStreak >= B_CIRCUIT) return { kind: "circuit", message: copy.circuit, sticky: false }
|
|
214
|
+
if (current.emitted.messageID !== call.messageID) {
|
|
215
|
+
current.emitted = { messageID: call.messageID, counts: new Map() }
|
|
216
|
+
}
|
|
217
|
+
const emitted = (current.emitted.counts.get(call.sigHash) ?? 0) + 1
|
|
218
|
+
current.emitted.counts.set(call.sigHash, emitted)
|
|
219
|
+
if (emitted === GUARD_TRIGGER) {
|
|
220
|
+
const entry = current.signatures.get(call.sigHash)
|
|
221
|
+
const message =
|
|
222
|
+
entry !== undefined && entry.streak >= A_WARN
|
|
223
|
+
? copy.guardMerged(emitted, call.tool, entry.streak)
|
|
224
|
+
: copy.guard(emitted, call.tool)
|
|
225
|
+
return { kind: "assert", signal: "guard", message }
|
|
226
|
+
}
|
|
227
|
+
const unobserved = current.unobserved.get(call.sigHash) ?? 0
|
|
228
|
+
if (unobserved >= UNOBSERVED_PROMPT && !current.highTierPrompted) {
|
|
229
|
+
current.highTierPrompted = true
|
|
230
|
+
return { kind: "assert", signal: "unobserved", message: copy.unobserved }
|
|
231
|
+
}
|
|
232
|
+
const entry = current.signatures.get(call.sigHash)
|
|
233
|
+
if (entry !== undefined && entry.streak >= A_BLOCK) {
|
|
234
|
+
return { kind: "assert", signal: "a", message: copy.blockA(call.tool, entry.streak) }
|
|
235
|
+
}
|
|
236
|
+
if (current.bStreak >= B_BLOCK && entry !== undefined && entry.streak >= A_WARN) {
|
|
237
|
+
return { kind: "assert", signal: "b", message: copy.blockB }
|
|
238
|
+
}
|
|
239
|
+
const failure = current.failures.get(call.sigHash)
|
|
240
|
+
if (failure !== undefined && failure.streak >= C_BLOCK) {
|
|
241
|
+
return { kind: "assert", signal: "c", message: copy.blockC(call.tool, failure.streak) }
|
|
242
|
+
}
|
|
243
|
+
return { kind: "pass" }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export interface Completed {
|
|
247
|
+
readonly tool: string
|
|
248
|
+
readonly sigHash: string
|
|
249
|
+
readonly text: string
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// LRU write for the signature-keyed maps: a write moves the key to the newest position, and the
|
|
253
|
+
// oldest entry is evicted past SIGNATURE_LIMIT. Eviction is indistinguishable from the
|
|
254
|
+
// output-changed branch — a recurred signature restarts at streak 1 — so the loss is
|
|
255
|
+
// miss-direction only and never a false block. A genuinely looping signature re-fires on every
|
|
256
|
+
// call and stays hot; only one not seen for 4096 distinct others is dropped.
|
|
257
|
+
function lruSet<V>(map: Map<string, V>, key: string, value: V) {
|
|
258
|
+
map.delete(key)
|
|
259
|
+
map.set(key, value)
|
|
260
|
+
if (map.size <= SIGNATURE_LIMIT) return
|
|
261
|
+
const oldest = map.keys().next().value
|
|
262
|
+
if (oldest !== undefined) map.delete(oldest)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Accounts a successful call into A and B. Error events never reach this function: failures feed
|
|
266
|
+
// signal C only, so a run of failed calls cannot masquerade as a run of identical empty outputs.
|
|
267
|
+
// Returns warning text to append to the model-visible output, if any.
|
|
268
|
+
export function recordCompleted(current: SessionState, call: Completed): string | undefined {
|
|
269
|
+
// Only a success of the same signature proves the retry loop is over.
|
|
270
|
+
current.failures.delete(call.sigHash)
|
|
271
|
+
|
|
272
|
+
const outputHash = fnv64(call.text)
|
|
273
|
+
const previous = current.signatures.get(call.sigHash)
|
|
274
|
+
const entry: SignatureEntry =
|
|
275
|
+
previous !== undefined && previous.lastOutputHash === outputHash
|
|
276
|
+
? { lastOutputHash: outputHash, streak: previous.streak + 1 }
|
|
277
|
+
: { lastOutputHash: outputHash, streak: 1 }
|
|
278
|
+
lruSet(current.signatures, call.sigHash, entry)
|
|
279
|
+
|
|
280
|
+
const warnings: string[] = []
|
|
281
|
+
if (entry.streak === A_WARN) warnings.push(copy.warnA(call.tool, entry.streak))
|
|
282
|
+
|
|
283
|
+
const lines = contentLines(call.text)
|
|
284
|
+
// Outputs shorter than 3 lines are exempt from B: one-line receipts (edit/write) never streak.
|
|
285
|
+
if (lines.length >= B_MIN_LINES) {
|
|
286
|
+
// Novelty counts distinct lines: one new line repeated k times inside a single output is still
|
|
287
|
+
// one new fact and must not inflate the count. The ratio keeps the total line count as its
|
|
288
|
+
// denominator — "1 new line out of 4" is a decorator variant, not productivity.
|
|
289
|
+
const distinct = [...new Set(lines)]
|
|
290
|
+
let novel = 0
|
|
291
|
+
for (const line of distinct) {
|
|
292
|
+
if (!current.lines.set.has(line)) novel++
|
|
293
|
+
}
|
|
294
|
+
const productive = novel >= 2 || novel / lines.length >= 0.5
|
|
295
|
+
if (productive) {
|
|
296
|
+
current.bStreak = 0
|
|
297
|
+
} else {
|
|
298
|
+
current.bStreak++
|
|
299
|
+
if (current.bStreak === B_WARN_LOW) warnings.push(copy.warnB3)
|
|
300
|
+
if (current.bStreak === B_WARN_HIGH) warnings.push(copy.warnB5)
|
|
301
|
+
}
|
|
302
|
+
for (const line of distinct) addLine(current, line)
|
|
303
|
+
}
|
|
304
|
+
return warnings.length === 0 ? undefined : warnings.join("\n\n")
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function addLine(current: SessionState, line: string) {
|
|
308
|
+
if (current.lines.set.has(line)) return
|
|
309
|
+
current.lines.set.add(line)
|
|
310
|
+
current.lines.order.push(line)
|
|
311
|
+
// FIFO eviction of the oldest half at the 10k cap keeps the store bounded.
|
|
312
|
+
if (current.lines.order.length > LINE_LIMIT) {
|
|
313
|
+
const evicted = current.lines.order.splice(0, LINE_LIMIT / 2)
|
|
314
|
+
for (const old of evicted) current.lines.set.delete(old)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export interface Failed {
|
|
319
|
+
readonly tool: string
|
|
320
|
+
readonly sigHash: string
|
|
321
|
+
readonly errorText: string
|
|
322
|
+
// Argument-validation rejections that never executed do not count (OpenClaw argument-validation
|
|
323
|
+
// semantics); the model must be free to fix its arguments.
|
|
324
|
+
readonly validation: boolean
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Failure equivalence is keyed by signature (tool + canonical input), not by tool name alone:
|
|
328
|
+
// different commands of one tool failing with the same normalized error would otherwise block an
|
|
329
|
+
// unrelated call of that tool. A different error means progress and resets the streak. Interleaved
|
|
330
|
+
// successes of other tools deliberately do not reset it.
|
|
331
|
+
export function recordFailed(current: SessionState, call: Failed): string | undefined {
|
|
332
|
+
if (call.validation) return undefined
|
|
333
|
+
const failureHash = fnv64(normalizeVolatile(call.errorText))
|
|
334
|
+
const previous = current.failures.get(call.sigHash)
|
|
335
|
+
const entry: FailureEntry =
|
|
336
|
+
previous !== undefined && previous.lastFailureHash === failureHash
|
|
337
|
+
? { lastFailureHash: failureHash, streak: previous.streak + 1 }
|
|
338
|
+
: { lastFailureHash: failureHash, streak: 1 }
|
|
339
|
+
lruSet(current.failures, call.sigHash, entry)
|
|
340
|
+
if (entry.streak === C_WARN) return copy.warnC(call.tool, entry.streak)
|
|
341
|
+
return undefined
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Unobserved layer (background shell, question): A/B/C abstain; only the accounting for the
|
|
345
|
+
// one-time high-tier prompt happens here.
|
|
346
|
+
export function recordUnobserved(current: SessionState, sigHash: string) {
|
|
347
|
+
lruSet(current.unobserved, sigHash, (current.unobserved.get(sigHash) ?? 0) + 1)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Context discontinuity (compaction): A's per-signature streaks reset but keep their output hashes,
|
|
351
|
+
// B's line store and streak reset. C intentionally survives: failure equivalence does not depend on
|
|
352
|
+
// context content.
|
|
353
|
+
export function compacted(current: SessionState) {
|
|
354
|
+
for (const entry of current.signatures.values()) entry.streak = 0
|
|
355
|
+
current.lines.set.clear()
|
|
356
|
+
current.lines.order = []
|
|
357
|
+
current.bStreak = 0
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// User message: the circuit breaker's defined escape hatch and the per-turn scope boundary. B and
|
|
361
|
+
// the breaker reset, and A/C counters reset too — a new user turn opens a fresh observation window,
|
|
362
|
+
// so a periodic legitimate re-check cannot accumulate into a block over days. A real loop
|
|
363
|
+
// re-accumulates within seconds after an interrupt, so detection is preserved.
|
|
364
|
+
export function userMessage(current: SessionState) {
|
|
365
|
+
current.signatures.clear()
|
|
366
|
+
current.failures.clear()
|
|
367
|
+
current.bStreak = 0
|
|
368
|
+
current.circuitBroken = false
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Files actually changed on disk: the world-observable escape for B.
|
|
372
|
+
export function filesChanged(current: SessionState) {
|
|
373
|
+
current.bStreak = 0
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Execution-end sweep (memory bound): tighten the line store of the session that just finished.
|
|
377
|
+
// A/C counters are preserved here — they only reset on a new user message (per-turn scoping).
|
|
378
|
+
export function tighten(current: SessionState) {
|
|
379
|
+
if (current.lines.order.length <= TIGHTENED_LINE_KEEP) return
|
|
380
|
+
const evicted = current.lines.order.splice(0, current.lines.order.length - TIGHTENED_LINE_KEEP)
|
|
381
|
+
for (const old of evicted) current.lines.set.delete(old)
|
|
382
|
+
}
|
package/src/fold.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
export * as DoomLoopFold from "./fold.js"
|
|
2
|
+
|
|
3
|
+
import type { SessionMessage } from "@opencode/schema/session-message"
|
|
4
|
+
import { DoomLoopDetector } from "./detector.js"
|
|
5
|
+
|
|
6
|
+
// Rebuilds detector state from durable session history when a session is first touched after a
|
|
7
|
+
// restart. The history read is SessionHistory's "latest" view — it starts at the most recent
|
|
8
|
+
// completed compaction — and only the accounting half is replayed. Two calibrations keep the
|
|
9
|
+
// replay faithful to the live path:
|
|
10
|
+
//
|
|
11
|
+
// - Calls the before hook rejected DID persist (as error parts) but were never accounted live,
|
|
12
|
+
// because a rejected call never reaches execute.after (tool.ts runs before hooks first). The
|
|
13
|
+
// plugin marks its own rejections (blockedMetadata) and declines/interrupts/input failures carry
|
|
14
|
+
// non-execution error types; both are skipped here.
|
|
15
|
+
// - Warnings the after hook injected into results persist with them, while live hashes before
|
|
16
|
+
// injecting; they are stripped so streaks rebuild exactly.
|
|
17
|
+
//
|
|
18
|
+
// Declared losses: C's failure streaks from before the latest compaction are invisible (miss
|
|
19
|
+
// direction; the streak self-heals within a few calls); B restarts with a rebuilt line store but
|
|
20
|
+
// a zero streak — safe because B's surgical block still requires the corroborating A streak,
|
|
21
|
+
// which IS rebuilt; Code Mode inner calls are invisible (the outer "execute" part is skipped,
|
|
22
|
+
// mirroring the live exemption, but the real tool names it re-entered with are not in history).
|
|
23
|
+
// The circuit breaker cannot be folded from history; the plugin persists it separately.
|
|
24
|
+
|
|
25
|
+
// Error types a persisted error part can carry without the call ever having executed — the live
|
|
26
|
+
// after hook (and thus signal C) never saw them: declines and interruptions ("aborted" —
|
|
27
|
+
// runner step.ts declines, llm.ts settleStaleToolCalls) and input failures that never reached
|
|
28
|
+
// the tool ("tool.input-json" / "tool.input-incomplete" — runner INPUT_FAILURES).
|
|
29
|
+
const UNEXECUTED_ERROR_TYPES = new Set(["aborted", "tool.input-json", "tool.input-incomplete"])
|
|
30
|
+
|
|
31
|
+
export function fold(state: DoomLoopDetector.SessionState, messages: ReadonlyArray<SessionMessage.Info>) {
|
|
32
|
+
for (const message of messages) {
|
|
33
|
+
if (message.type === "user") {
|
|
34
|
+
DoomLoopDetector.userMessage(state)
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
// Standalone shell messages are user-run commands (Session.shell), not model tool calls; the
|
|
38
|
+
// model's background shell arrives as a "shell" tool part with metadata.status "running".
|
|
39
|
+
if (message.type !== "assistant") continue
|
|
40
|
+
for (const part of message.content) {
|
|
41
|
+
// The outer Code Mode tool re-enters with real tool names; its own call is exempt, as live.
|
|
42
|
+
if (part.type !== "tool" || part.name === "execute") continue
|
|
43
|
+
if (part.state.status === "error") {
|
|
44
|
+
if (
|
|
45
|
+
DoomLoopDetector.hasBlockedMetadata(part.state.metadata) ||
|
|
46
|
+
UNEXECUTED_ERROR_TYPES.has(part.state.error.type)
|
|
47
|
+
)
|
|
48
|
+
continue
|
|
49
|
+
// The live C warning rides the persisted error message; strip it so the failure hash
|
|
50
|
+
// matches what live accounted. recordFailed excludes argument-validation rejections via
|
|
51
|
+
// the same message prefix the live path matches on.
|
|
52
|
+
const suffix = DoomLoopDetector.injectedFailureSuffix(part.name)
|
|
53
|
+
const message = part.state.error.message
|
|
54
|
+
const errorText = message.endsWith(suffix) ? message.slice(0, -suffix.length) : message
|
|
55
|
+
DoomLoopDetector.recordFailed(state, {
|
|
56
|
+
tool: part.name,
|
|
57
|
+
sigHash: DoomLoopDetector.signature(part.name, part.state.input),
|
|
58
|
+
errorText,
|
|
59
|
+
validation: errorText.startsWith(DoomLoopDetector.VALIDATION_PREFIX),
|
|
60
|
+
})
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (part.state.status !== "completed") continue
|
|
64
|
+
const sigHash = DoomLoopDetector.signature(part.name, part.state.input)
|
|
65
|
+
if (part.name === "question" || (part.name === "shell" && part.state.metadata?.status === "running")) {
|
|
66
|
+
DoomLoopDetector.recordUnobserved(state, sigHash)
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
// The persisted content carries any injected warning prepended by the live after hook;
|
|
70
|
+
// strip a leading warning part so the hash matches what live accounted.
|
|
71
|
+
const first = part.state.content[0]
|
|
72
|
+
const content =
|
|
73
|
+
first !== undefined &&
|
|
74
|
+
first.type === "text" &&
|
|
75
|
+
DoomLoopDetector.injectedWarnings(part.name).includes(first.text)
|
|
76
|
+
? part.state.content.slice(1)
|
|
77
|
+
: part.state.content
|
|
78
|
+
DoomLoopDetector.recordCompleted(state, {
|
|
79
|
+
tool: part.name,
|
|
80
|
+
sigHash,
|
|
81
|
+
text: DoomLoopDetector.outputText(content),
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Match the live steady state: an execution end tightens the store to the newest 1000 lines,
|
|
86
|
+
// and a fold from a long history could otherwise fill it to the 10k cap.
|
|
87
|
+
DoomLoopDetector.tighten(state)
|
|
88
|
+
// Fresh streak over the rebuilt line store is the self-consistent restart state; see header.
|
|
89
|
+
state.bStreak = 0
|
|
90
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { define } from "@opencode/plugin/effect/plugin"
|
|
2
|
+
import type { Session } from "@opencode/schema/session"
|
|
3
|
+
import type { SessionEvent } from "@opencode/schema/session-event"
|
|
4
|
+
import { Effect, Exit, Option, Scope, Stream } from "effect"
|
|
5
|
+
import { Tool } from "@opencode/schema/tool"
|
|
6
|
+
import { DoomLoopDetector } from "./detector.js"
|
|
7
|
+
import { DoomLoopFold } from "./fold.js"
|
|
8
|
+
|
|
9
|
+
// Session state is rebuilt lazily from durable history on first tool-hook touch (fold), so a
|
|
10
|
+
// process restart continues A/C streaks instead of silently zeroing them. The one fact history
|
|
11
|
+
// cannot rebuild is a tripped circuit breaker (B's streak restarts at zero), so it is persisted
|
|
12
|
+
// in plugin KV and re-armed only by a real user message.
|
|
13
|
+
//
|
|
14
|
+
// Because every entry can be re-folded, the sessions map is a pure cache: past
|
|
15
|
+
// MAX_TRACKED_SESSIONS the least recently touched entry is dropped and re-folded on its next
|
|
16
|
+
// touch. Every eviction loss is miss-direction — bStreak restarts at zero exactly as after a
|
|
17
|
+
// restart, and calls accounted since the last durable write replay short. The breaker survives
|
|
18
|
+
// in KV, and an executing session touches the map on every hook, so the LRU tail is always a
|
|
19
|
+
// session no hook is touching.
|
|
20
|
+
|
|
21
|
+
const DEFAULT_MAX_TRACKED_SESSIONS = 64
|
|
22
|
+
|
|
23
|
+
type Tracked =
|
|
24
|
+
| SessionEvent.Deleted
|
|
25
|
+
| SessionEvent.InboxEnqueued
|
|
26
|
+
| SessionEvent.Step.Ended
|
|
27
|
+
| SessionEvent.Compaction.Ended
|
|
28
|
+
| SessionEvent.Execution.Succeeded
|
|
29
|
+
| SessionEvent.Execution.Failed
|
|
30
|
+
| SessionEvent.Execution.Interrupted
|
|
31
|
+
|
|
32
|
+
interface Entry {
|
|
33
|
+
state: DoomLoopDetector.SessionState
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const circuitKey = (sessionID: Session.ID) => `circuit:${sessionID}`
|
|
37
|
+
|
|
38
|
+
// Factory-injected cap: tests construct a plugin with a small cap instead of mutating shared state.
|
|
39
|
+
export function makePlugin(options?: { maxTrackedSessions?: number }) {
|
|
40
|
+
const cap = options?.maxTrackedSessions ?? DEFAULT_MAX_TRACKED_SESSIONS
|
|
41
|
+
|
|
42
|
+
return define({
|
|
43
|
+
id: "opencode.doom.loop",
|
|
44
|
+
effect: Effect.fn(function* (ctx) {
|
|
45
|
+
const scope = yield* Scope.Scope
|
|
46
|
+
const sessions = new Map<Session.ID, Promise<Entry>>()
|
|
47
|
+
|
|
48
|
+
const folded = (sessionID: Session.ID): Promise<Entry> =>
|
|
49
|
+
Effect.runPromise(
|
|
50
|
+
Effect.gen(function* () {
|
|
51
|
+
const entry: Entry = { state: DoomLoopDetector.state() }
|
|
52
|
+
const messages = yield* ctx.session.context({ sessionID })
|
|
53
|
+
DoomLoopFold.fold(entry.state, messages)
|
|
54
|
+
if ((yield* ctx.storage.get(circuitKey(sessionID))) === true) entry.state.circuitBroken = true
|
|
55
|
+
return entry
|
|
56
|
+
}),
|
|
57
|
+
// A history read failure must not fail the tool call: fall back to a fresh state (the
|
|
58
|
+
// pre-fold behavior) rather than dropping detection entirely.
|
|
59
|
+
).catch((): Entry => ({ state: DoomLoopDetector.state() }))
|
|
60
|
+
|
|
61
|
+
// The promise enters the map synchronously so concurrent first touches share one fold.
|
|
62
|
+
const session = (sessionID: Session.ID) => {
|
|
63
|
+
const existing = sessions.get(sessionID)
|
|
64
|
+
if (existing) {
|
|
65
|
+
// LRU touch: Map.set on an existing key does not reorder, so reinsert at the tail.
|
|
66
|
+
sessions.delete(sessionID)
|
|
67
|
+
sessions.set(sessionID, existing)
|
|
68
|
+
return existing
|
|
69
|
+
}
|
|
70
|
+
const created = folded(sessionID)
|
|
71
|
+
sessions.set(sessionID, created)
|
|
72
|
+
// Eviction is a bare delete: the KV breaker key belongs to the session lifecycle, not to
|
|
73
|
+
// this cache, and everything else re-folds from durable history on the next touch.
|
|
74
|
+
while (sessions.size > cap) {
|
|
75
|
+
const oldest = sessions.keys().next().value
|
|
76
|
+
if (oldest === undefined) break
|
|
77
|
+
sessions.delete(oldest)
|
|
78
|
+
}
|
|
79
|
+
return created
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Event-time mutations chain onto the in-flight fold so they apply after it settles. Sessions
|
|
83
|
+
// never touched by a hook are not created here: a fresh state is already the zeroed state.
|
|
84
|
+
const mutate = (sessionID: Session.ID, fn: (state: DoomLoopDetector.SessionState) => void) => {
|
|
85
|
+
const existing = sessions.get(sessionID)
|
|
86
|
+
if (existing)
|
|
87
|
+
sessions.set(
|
|
88
|
+
sessionID,
|
|
89
|
+
existing.then((entry) => {
|
|
90
|
+
fn(entry.state)
|
|
91
|
+
return entry
|
|
92
|
+
}),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
yield* ctx.tool.hook("execute.before", (event) =>
|
|
97
|
+
Effect.gen(function* () {
|
|
98
|
+
// The outer Code Mode tool is built per snapshot and re-enters with real tool names; its
|
|
99
|
+
// own `execute` call carries no observable signature. Skipping by name also exempts an
|
|
100
|
+
// MCP/custom tool literally named `execute` — a false-negative, conservative direction.
|
|
101
|
+
if (event.tool === "execute") return
|
|
102
|
+
const current = yield* Effect.promise(() => session(event.sessionID))
|
|
103
|
+
const sigHash = DoomLoopDetector.signature(event.tool, event.input)
|
|
104
|
+
const verdict = DoomLoopDetector.beforeCall(current.state, {
|
|
105
|
+
tool: event.tool,
|
|
106
|
+
sigHash,
|
|
107
|
+
messageID: event.messageID,
|
|
108
|
+
})
|
|
109
|
+
if (verdict.kind === "pass") return
|
|
110
|
+
if (verdict.kind === "circuit") {
|
|
111
|
+
// A sticky circuit (a previous denial) stops without re-asking, until the user speaks.
|
|
112
|
+
if (verdict.sticky) {
|
|
113
|
+
yield* ctx.session.interrupt({ sessionID: event.sessionID }).pipe(Effect.ignore, Effect.forkIn(scope))
|
|
114
|
+
return yield* new Tool.Error({ message: verdict.message, metadata: DoomLoopDetector.blockedMetadata })
|
|
115
|
+
}
|
|
116
|
+
// The breaker goes through doom_loop like every other signal: allow resets B and lets
|
|
117
|
+
// the run continue; a denial makes the stop sticky and explains why it happened.
|
|
118
|
+
const resource = `${event.tool}:${sigHash}${DoomLoopDetector.resourceSuffix.b}`
|
|
119
|
+
const outcome = yield* Effect.exit(
|
|
120
|
+
ctx.permission.assert({
|
|
121
|
+
sessionID: event.sessionID,
|
|
122
|
+
agent: event.agent,
|
|
123
|
+
action: "doom_loop",
|
|
124
|
+
resources: [resource],
|
|
125
|
+
save: [resource],
|
|
126
|
+
source: { type: "tool", messageID: event.messageID, id: event.id },
|
|
127
|
+
}),
|
|
128
|
+
)
|
|
129
|
+
if (Exit.isSuccess(outcome)) {
|
|
130
|
+
current.state.bStreak = 0
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
const error = Option.getOrUndefined(Exit.findErrorOption(outcome))
|
|
134
|
+
// A rule-based denial makes the stop sticky; a CorrectedError carries model feedback and
|
|
135
|
+
// returns as a plain Tool.Error so the model can act on it (no sticky stop, no interrupt).
|
|
136
|
+
// Only a CorrectedError returns as plain feedback for the model (no sticky stop).
|
|
137
|
+
// The public API errors are Tool.Error, so identify a correction by its tag (both a bare
|
|
138
|
+
// error and the host-wrapped `error` field), rather than by class identity.
|
|
139
|
+
const tagOf = (value: unknown): string | undefined =>
|
|
140
|
+
typeof value === "object" && value !== null && "_tag" in value
|
|
141
|
+
? (value as { _tag: string })._tag
|
|
142
|
+
: undefined
|
|
143
|
+
const corrected =
|
|
144
|
+
tagOf(error) === "Permission.CorrectedError" ||
|
|
145
|
+
(error instanceof Tool.Error && tagOf(error.error) === "Permission.CorrectedError")
|
|
146
|
+
if (!corrected) {
|
|
147
|
+
yield* ctx.storage.set(circuitKey(event.sessionID), true).pipe(Effect.ignore)
|
|
148
|
+
current.state.circuitBroken = true
|
|
149
|
+
yield* ctx.session.interrupt({ sessionID: event.sessionID }).pipe(Effect.ignore, Effect.forkIn(scope))
|
|
150
|
+
}
|
|
151
|
+
return yield* new Tool.Error({
|
|
152
|
+
message: `${verdict.message}\n\n${error?.message ?? "rejected"}`,
|
|
153
|
+
metadata: DoomLoopDetector.blockedMetadata,
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
const resource = `${event.tool}:${sigHash}${DoomLoopDetector.resourceSuffix[verdict.signal]}`
|
|
157
|
+
// host.ts maps BlockedError/CorrectedError to Tool.Error and keeps a plain rejection a
|
|
158
|
+
// defect; stacking our copy on the mapped message preserves the "why blocked + what next"
|
|
159
|
+
// guidance for both typed outcomes. Deliberately no inner `error`: toSessionError unwraps
|
|
160
|
+
// to the innermost error's message when one is attached, which would discard our copy.
|
|
161
|
+
// The blockedMetadata marker persists on the error part so the fold skips this call — it
|
|
162
|
+
// never executed, so the live path never accounted it.
|
|
163
|
+
return yield* ctx.permission
|
|
164
|
+
.assert({
|
|
165
|
+
sessionID: event.sessionID,
|
|
166
|
+
agent: event.agent,
|
|
167
|
+
action: "doom_loop",
|
|
168
|
+
resources: [resource],
|
|
169
|
+
save: [resource],
|
|
170
|
+
source: { type: "tool", messageID: event.messageID, id: event.id },
|
|
171
|
+
})
|
|
172
|
+
.pipe(
|
|
173
|
+
Effect.mapError(
|
|
174
|
+
(error) =>
|
|
175
|
+
new Tool.Error({
|
|
176
|
+
message: `${verdict.message}\n\n${error.message}`,
|
|
177
|
+
metadata: DoomLoopDetector.blockedMetadata,
|
|
178
|
+
}),
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
}),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
yield* ctx.tool.hook("execute.after", (event) =>
|
|
185
|
+
Effect.gen(function* () {
|
|
186
|
+
if (event.tool === "execute") return
|
|
187
|
+
const current = yield* Effect.promise(() => session(event.sessionID))
|
|
188
|
+
const sigHash = DoomLoopDetector.signature(event.tool, event.input)
|
|
189
|
+
if (event.status === "error") {
|
|
190
|
+
// Failure events feed signal C only — they never enter A/B, or a run of failures with
|
|
191
|
+
// distinct messages would count as identical empty outputs. The warning lands on the
|
|
192
|
+
// error message because a failed call has no result content.
|
|
193
|
+
// Deviation from ⑦, declared: error events carry no metadata, so a background shell's
|
|
194
|
+
// launch failure cannot be distinguished and is counted into C. Deliberately conservative
|
|
195
|
+
// direction (detect more; C still only ever asks, never hard-blocks silently).
|
|
196
|
+
const warning = DoomLoopDetector.recordFailed(current.state, {
|
|
197
|
+
tool: event.tool,
|
|
198
|
+
sigHash,
|
|
199
|
+
errorText: event.error.message,
|
|
200
|
+
validation: event.error.message.startsWith(DoomLoopDetector.VALIDATION_PREFIX),
|
|
201
|
+
})
|
|
202
|
+
if (warning !== undefined) event.error.message = `${event.error.message}\n\n${warning}`
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
// Unobserved layer (background shell — registered under the name "shell"; question):
|
|
206
|
+
// A/B/C abstain — the receipt text is fixed, so counting it would false-positive signal A
|
|
207
|
+
// immediately.
|
|
208
|
+
const unobserved =
|
|
209
|
+
event.tool === "question" ||
|
|
210
|
+
(event.tool === "shell" && event.result.metadata !== undefined && event.result.metadata.status === "running")
|
|
211
|
+
if (unobserved) {
|
|
212
|
+
DoomLoopDetector.recordUnobserved(current.state, sigHash)
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
// Hash and account before touching content so our own text never feeds back in.
|
|
216
|
+
const text = DoomLoopDetector.outputText(event.result.content, event.result.output)
|
|
217
|
+
const warning = DoomLoopDetector.recordCompleted(current.state, { tool: event.tool, sigHash, text })
|
|
218
|
+
if (warning === undefined) return
|
|
219
|
+
// Prepend rather than append: the runner's ToolOutput.truncate keeps the head of oversized
|
|
220
|
+
// outputs (tool-output.ts), so a tail warning would be cut exactly on the huge outputs of
|
|
221
|
+
// looping MCP-style tools.
|
|
222
|
+
const existing = event.result.content
|
|
223
|
+
event.result = {
|
|
224
|
+
...event.result,
|
|
225
|
+
content: [
|
|
226
|
+
{ type: "text" as const, text: warning },
|
|
227
|
+
...(typeof existing === "string" ? [{ type: "text" as const, text: existing }] : (existing ?? [])),
|
|
228
|
+
],
|
|
229
|
+
}
|
|
230
|
+
}),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
yield* ctx.event.subscribe().pipe(
|
|
234
|
+
Stream.filter(
|
|
235
|
+
(event): event is Tracked =>
|
|
236
|
+
event.type === "session.deleted" ||
|
|
237
|
+
event.type === "session.inbox.enqueued" ||
|
|
238
|
+
event.type === "session.step.ended" ||
|
|
239
|
+
event.type === "session.compaction.ended" ||
|
|
240
|
+
event.type === "session.execution.succeeded" ||
|
|
241
|
+
event.type === "session.execution.failed" ||
|
|
242
|
+
event.type === "session.execution.interrupted",
|
|
243
|
+
),
|
|
244
|
+
Stream.runForEach((event) =>
|
|
245
|
+
Effect.gen(function* () {
|
|
246
|
+
const sessionID = event.data.sessionID
|
|
247
|
+
if (event.type === "session.deleted") {
|
|
248
|
+
sessions.delete(sessionID)
|
|
249
|
+
yield* ctx.storage.remove(circuitKey(sessionID)).pipe(Effect.ignore)
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
if (event.type === "session.inbox.enqueued") {
|
|
253
|
+
// Only real user messages re-arm the breaker; our own synthetic injections must not.
|
|
254
|
+
// The persisted key clears even when this process never folded the session.
|
|
255
|
+
if (event.data.item.type !== "user") return
|
|
256
|
+
yield* ctx.storage.remove(circuitKey(sessionID)).pipe(Effect.ignore)
|
|
257
|
+
mutate(sessionID, DoomLoopDetector.userMessage)
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
if (event.type === "session.step.ended") {
|
|
261
|
+
if (event.data.files === undefined || event.data.files.length === 0) return
|
|
262
|
+
mutate(sessionID, DoomLoopDetector.filesChanged)
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
if (event.type === "session.compaction.ended") {
|
|
266
|
+
mutate(sessionID, DoomLoopDetector.compacted)
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
mutate(sessionID, DoomLoopDetector.tighten)
|
|
270
|
+
}),
|
|
271
|
+
),
|
|
272
|
+
Effect.forkScoped({ startImmediately: true }),
|
|
273
|
+
)
|
|
274
|
+
}),
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export const Plugin = makePlugin()
|