acuvo-code 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
package/lib/stuck.mjs
ADDED
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⚠️⭐⭐ KNOWING WHEN IT IS GOING IN CIRCLES ────────────────────────────────
|
|
3
|
+
*
|
|
4
|
+
* This module is the other half of removing the round cap, and it is not
|
|
5
|
+
* optional. "Keep going until the task is done" without a loop detector is not
|
|
6
|
+
* ambition, it is "burn the whole budget rewriting the same file" — strictly
|
|
7
|
+
* worse than stopping early, because the user pays for the circles too.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️⚠️ THE TWO FAILURE MODES ARE NOT SYMMETRIC, AND THE WHOLE DESIGN FOLLOWS
|
|
10
|
+
* FROM THAT.
|
|
11
|
+
*
|
|
12
|
+
* · MISSING a loop costs a few more rounds — cents, on a task measured at
|
|
13
|
+
* ~$0.0007. Annoying. Recoverable. The budget guard catches it eventually.
|
|
14
|
+
* · CALLING A WORKING RUN STUCK costs the user THE WORK AND THE MONEY. Every
|
|
15
|
+
* round already paid for is thrown away, possibly one round before it would
|
|
16
|
+
* have succeeded, and the user has nothing to show for any of it.
|
|
17
|
+
*
|
|
18
|
+
* So every threshold here is set to the CONSERVATIVE side, every pattern
|
|
19
|
+
* requires a positive signature rather than the absence of one, and five
|
|
20
|
+
* legitimate shapes that look superficially like loops are pinned as negatives
|
|
21
|
+
* in the tests:
|
|
22
|
+
*
|
|
23
|
+
* 1. reading one file repeatedly while editing different parts of it
|
|
24
|
+
* 2. a test failing the same way while the code genuinely changes each run
|
|
25
|
+
* 3. a long research phase — many reads, no writes, no commands
|
|
26
|
+
* 4. retrying after a transient failure that then succeeds
|
|
27
|
+
* 5. the same command failing DIFFERENTLY each time (that is a descent, not
|
|
28
|
+
* a circle — the model is peeling errors off one at a time)
|
|
29
|
+
*
|
|
30
|
+
* ⭐ `suggestion` IS THE PRODUCT, NOT `stuck`. This text is appended to the
|
|
31
|
+
* conversation as a nudge to the MODEL, so "stuck: true" is worthless to it and
|
|
32
|
+
* a scolding is worse than worthless — a model told it has failed will often
|
|
33
|
+
* wrap up and hand back half a job, which is the exact behaviour an unattended
|
|
34
|
+
* loop exists to prevent. Every suggestion therefore names the concrete
|
|
35
|
+
* artifact (the path, the command, the message) and proposes a next move.
|
|
36
|
+
*
|
|
37
|
+
* ⚠️ PURE, AND DELIBERATELY SO. No clock, no randomness, no I/O, no state
|
|
38
|
+
* between calls. It takes the round history the loop already keeps and returns
|
|
39
|
+
* data. That is why it can be tested exhaustively without spending a cent on a
|
|
40
|
+
* real model — and untestable time is why half the bugs in this package were
|
|
41
|
+
* invisible for so long.
|
|
42
|
+
*
|
|
43
|
+
* WIRING: see the note at the bottom of this file. It is ten lines in
|
|
44
|
+
* `runSession`, right after `rounds.push(...)`.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Every pattern this module can return, in the order it prefers to report them.
|
|
49
|
+
*
|
|
50
|
+
* ⚠️ THE ORDER IS THE DIAGNOSIS, NOT A PREFERENCE. `A → B → A` also contains two
|
|
51
|
+
* identical writes of A; reporting that as "you wrote the same thing twice"
|
|
52
|
+
* would send the model to check its file path when the real problem is that it
|
|
53
|
+
* is alternating between two rejected answers. The more specific pattern must
|
|
54
|
+
* win, or the nudge actively misleads.
|
|
55
|
+
*/
|
|
56
|
+
export const STUCK_PATTERNS = [
|
|
57
|
+
'thrashing',
|
|
58
|
+
'repeated-identical-edit',
|
|
59
|
+
'tool-error-loop',
|
|
60
|
+
'repeated-command-failure',
|
|
61
|
+
'no-progress',
|
|
62
|
+
/**
|
|
63
|
+
* ⚠️ LAST ON PURPOSE. A period-1 cycle IS a repeated identical edit, and every
|
|
64
|
+
* shorter pattern above states the same fact more precisely. This one exists
|
|
65
|
+
* only for the loops the others structurally cannot see.
|
|
66
|
+
*/
|
|
67
|
+
'long-cycle',
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
/** How many of the most recent rounds are examined. */
|
|
71
|
+
const DEFAULT_WINDOW = 4;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* ⚠️ TWO IS THE THRESHOLD FOR AN IDENTICAL WRITE AND THREE FOR EVERYTHING ELSE,
|
|
75
|
+
* and that is not an inconsistency. Writing byte-identical content to the same
|
|
76
|
+
* path twice is a PROVEN no-op — the second write changed nothing, which is a
|
|
77
|
+
* fact, not an inference. A command failing twice is just a command failing
|
|
78
|
+
* twice; it takes a third to be a pattern.
|
|
79
|
+
*/
|
|
80
|
+
const IDENTICAL_WRITE_LIMIT = 2;
|
|
81
|
+
const COMMAND_FAILURE_LIMIT = 3;
|
|
82
|
+
const TOOL_ERROR_LIMIT = 3;
|
|
83
|
+
const INERT_ROUND_LIMIT = 3;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Tools that mean "a process ran".
|
|
87
|
+
*
|
|
88
|
+
* ⚠️ KEYED ON WHAT HAPPENED, NOT ON THE FAMOUS NAME. turn.mjs has now had the
|
|
89
|
+
* same bug three times — `evaluate`, then `check_acceptance`, then
|
|
90
|
+
* `run_program` were each missing from a list of "things that count as a run",
|
|
91
|
+
* and each time the honest line in the summary lied. Anything that spawns a
|
|
92
|
+
* process belongs here.
|
|
93
|
+
*/
|
|
94
|
+
const RUN_TOOLS = new Set(['run_command', 'run_program', 'evaluate', 'check_acceptance']);
|
|
95
|
+
|
|
96
|
+
/** Tools whose success means a file on disk is different afterwards. */
|
|
97
|
+
const MUTATING_TOOLS = new Set(['write_file', 'edit_file', 'delete_file']);
|
|
98
|
+
|
|
99
|
+
/* ══════════════════════════════════════════════════════════════════════════
|
|
100
|
+
* normalisation — every comparison below depends on these being boring
|
|
101
|
+
* ══════════════════════════════════════════════════════════════════════════ */
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* `src\x.js`, `./src/x.js` and `src//x.js` are one file.
|
|
105
|
+
*
|
|
106
|
+
* ⚠️ CASE IS DELIBERATELY LEFT ALONE even though Windows would fold it. Folding
|
|
107
|
+
* MERGES two paths, and merging is the direction that manufactures a false
|
|
108
|
+
* "you wrote the same file twice". Every ambiguity here resolves towards
|
|
109
|
+
* not-stuck.
|
|
110
|
+
*/
|
|
111
|
+
function normPath(value) {
|
|
112
|
+
if (typeof value !== 'string') return null;
|
|
113
|
+
let s = value.replace(/\\/g, '/').replace(/\/{2,}/g, '/');
|
|
114
|
+
while (s.startsWith('./')) s = s.slice(2);
|
|
115
|
+
s = s.replace(/\/+$/, '');
|
|
116
|
+
return s.length ? s : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Collapse whitespace so two renderings of one message compare equal. */
|
|
120
|
+
function normMessage(value, cap = 300) {
|
|
121
|
+
if (typeof value !== 'string') return null;
|
|
122
|
+
const s = value.replace(/\s+/g, ' ').trim();
|
|
123
|
+
return s.length ? s.slice(0, cap) : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The first non-empty line of a failure — stderr first, then stdout.
|
|
128
|
+
*
|
|
129
|
+
* ⭐ THE FIRST LINE IS THE SIGNATURE, NOT THE WHOLE OUTPUT. Test runners print
|
|
130
|
+
* timings, paths and a duration that differ on every single run; comparing full
|
|
131
|
+
* output would make two identical failures look different and this pattern
|
|
132
|
+
* would never fire at all.
|
|
133
|
+
*/
|
|
134
|
+
function firstErrorLine(result) {
|
|
135
|
+
const pick = (text) => {
|
|
136
|
+
if (typeof text !== 'string') return '';
|
|
137
|
+
for (const line of text.split(/\r?\n/)) {
|
|
138
|
+
const trimmed = line.trim();
|
|
139
|
+
if (trimmed) return trimmed;
|
|
140
|
+
}
|
|
141
|
+
return '';
|
|
142
|
+
};
|
|
143
|
+
return (pick(result?.stderr) || pick(result?.stdout) || '').slice(0, 200);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Deterministic identity for a read-only call, used to ask "is this new?". */
|
|
147
|
+
function probeKey(name, args) {
|
|
148
|
+
try {
|
|
149
|
+
const keys = Object.keys(args ?? {}).sort();
|
|
150
|
+
const body = JSON.stringify(keys.map((k) => [k, args[k]]));
|
|
151
|
+
if (typeof body !== 'string') return null;
|
|
152
|
+
return `${name} ${body.length} ${body.slice(0, 4096)}`;
|
|
153
|
+
} catch {
|
|
154
|
+
// circular or otherwise unserialisable — treat it as its own probe rather
|
|
155
|
+
// than as a repeat, which is the not-stuck direction.
|
|
156
|
+
body = `unserialisable:${name}:${keys_fallback()}`;
|
|
157
|
+
}
|
|
158
|
+
// Cap rather than hash: a hash collision would MERGE two probes and could
|
|
159
|
+
// manufacture an inert round, and probe arguments are short by nature.
|
|
160
|
+
return `${name}\u0000${body.length}\u0000${body.slice(0, 4096)}`;
|
|
161
|
+
}
|
|
162
|
+
let keysFallbackCounter = 0;
|
|
163
|
+
function keys_fallback() { return String(keysFallbackCounter++); }
|
|
164
|
+
|
|
165
|
+
/** The command string behind a run record, whatever tool produced it. */
|
|
166
|
+
function commandOf(ev) {
|
|
167
|
+
if (typeof ev.result.command === 'string') return ev.result.command;
|
|
168
|
+
if (typeof ev.args.command === 'string') return ev.args.command;
|
|
169
|
+
if (Array.isArray(ev.result.argv)) return ev.result.argv.join(' ');
|
|
170
|
+
if (Array.isArray(ev.args.argv)) return ev.args.argv.join(' ');
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* ⚠️ `ok: true` MEANS THE COMMAND RAN, NOT THAT IT PASSED — command.mjs says so
|
|
176
|
+
* itself. Reading `ok` as success here would mean every failing test looked
|
|
177
|
+
* like a success and this whole pattern would be dead code.
|
|
178
|
+
*/
|
|
179
|
+
function runFailed(result) {
|
|
180
|
+
if (result?.timedOut === true) return true;
|
|
181
|
+
if (result?.passed === false) return true;
|
|
182
|
+
return Number.isFinite(result?.exitCode) && result.exitCode !== 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* ══════════════════════════════════════════════════════════════════════════
|
|
186
|
+
* flattening — turn `rounds` into one ordered event stream
|
|
187
|
+
* ══════════════════════════════════════════════════════════════════════════ */
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* @param {Array} rounds records in the shape turn.mjs pushes:
|
|
191
|
+
* `{ round, note, executed: [{ id, name, args, result, mutated }], usage }`
|
|
192
|
+
*
|
|
193
|
+
* ⚠️ EVERY FIELD IS TREATED AS ABSENT UNTIL PROVEN PRESENT. A provider can emit
|
|
194
|
+
* a tool call whose arguments do not parse, and tools.mjs hands back
|
|
195
|
+
* `args: {}` with an error result; a resumed session can carry a round from an
|
|
196
|
+
* older shape. A detector that throws inside the loop it is meant to protect is
|
|
197
|
+
* worse than no detector.
|
|
198
|
+
*/
|
|
199
|
+
function flatten(rounds) {
|
|
200
|
+
const events = [];
|
|
201
|
+
const roundMeta = [];
|
|
202
|
+
if (!Array.isArray(rounds)) return { events, roundMeta };
|
|
203
|
+
|
|
204
|
+
rounds.forEach((round, roundIndex) => {
|
|
205
|
+
const label = Number.isFinite(round?.round) ? round.round : roundIndex + 1;
|
|
206
|
+
roundMeta.push({ roundIndex, label });
|
|
207
|
+
const executed = Array.isArray(round?.executed) ? round.executed : [];
|
|
208
|
+
for (const record of executed) {
|
|
209
|
+
if (!record || typeof record !== 'object') continue;
|
|
210
|
+
if (typeof record.name !== 'string' || !record.name) continue;
|
|
211
|
+
events.push({
|
|
212
|
+
roundIndex,
|
|
213
|
+
label,
|
|
214
|
+
name: record.name,
|
|
215
|
+
args: (record.args && typeof record.args === 'object') ? record.args : {},
|
|
216
|
+
result: (record.result && typeof record.result === 'object') ? record.result : {},
|
|
217
|
+
mutated: record.mutated === true,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
return { events, roundMeta };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Annotate each event with the two facts every pattern needs: did it change a
|
|
226
|
+
* file, and had we seen this exact probe before?
|
|
227
|
+
*
|
|
228
|
+
* ⚠️ THIS WALKS THE WHOLE HISTORY, NOT THE WINDOW. "Is this content new?" and
|
|
229
|
+
* "have I asked this before?" are questions about everything that came before,
|
|
230
|
+
* and answering them from the window alone would call a re-read of something
|
|
231
|
+
* fetched ten rounds ago a brand-new observation.
|
|
232
|
+
*/
|
|
233
|
+
function annotate(events) {
|
|
234
|
+
const lastWritten = new Map(); // path → content most recently written there
|
|
235
|
+
const seenProbes = new Set();
|
|
236
|
+
|
|
237
|
+
for (const ev of events) {
|
|
238
|
+
ev.path = null;
|
|
239
|
+
ev.content = null;
|
|
240
|
+
ev.changedDisk = false;
|
|
241
|
+
ev.newProbe = false;
|
|
242
|
+
ev.isRun = RUN_TOOLS.has(ev.name);
|
|
243
|
+
|
|
244
|
+
const ok = ev.result.ok === true;
|
|
245
|
+
|
|
246
|
+
if (ev.name === 'write_file' && ok) {
|
|
247
|
+
const path = normPath(ev.args.path);
|
|
248
|
+
const content = ev.args.content;
|
|
249
|
+
// ⚠️ a non-string content is NOT "the same as the last non-string
|
|
250
|
+
// content" — two unknowns are not a match, they are two unknowns.
|
|
251
|
+
if (path && typeof content === 'string') {
|
|
252
|
+
ev.path = path;
|
|
253
|
+
ev.content = content;
|
|
254
|
+
ev.changedDisk = !lastWritten.has(path) || lastWritten.get(path) !== content;
|
|
255
|
+
lastWritten.set(path, content);
|
|
256
|
+
} else {
|
|
257
|
+
ev.changedDisk = true;
|
|
258
|
+
}
|
|
259
|
+
} else if ((ev.name === 'edit_file' || ev.name === 'delete_file') && ok) {
|
|
260
|
+
ev.path = normPath(ev.args.path);
|
|
261
|
+
// edit.mjs refuses an edit whose old_string equals its new_string, so a
|
|
262
|
+
// successful edit is by construction a real change. After it, whatever we
|
|
263
|
+
// thought was on disk is stale — forget it rather than compare against it.
|
|
264
|
+
ev.changedDisk = true;
|
|
265
|
+
if (ev.path) lastWritten.delete(ev.path);
|
|
266
|
+
} else if (!ev.isRun && !MUTATING_TOOLS.has(ev.name)) {
|
|
267
|
+
const key = probeKey(ev.name, ev.args);
|
|
268
|
+
ev.newProbe = !seenProbes.has(key);
|
|
269
|
+
seenProbes.add(key);
|
|
270
|
+
} else if (!ok && MUTATING_TOOLS.has(ev.name)) {
|
|
271
|
+
// A refused write is not a change, but the refusal itself is information
|
|
272
|
+
// the first time it arrives.
|
|
273
|
+
const key = probeKey(ev.name, ev.args);
|
|
274
|
+
ev.newProbe = !seenProbes.has(key);
|
|
275
|
+
seenProbes.add(key);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return events;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/* ══════════════════════════════════════════════════════════════════════════
|
|
282
|
+
* the patterns
|
|
283
|
+
* ══════════════════════════════════════════════════════════════════════════ */
|
|
284
|
+
|
|
285
|
+
/** Writes to one path, in order, within the window. */
|
|
286
|
+
function writesByPath(windowEvents) {
|
|
287
|
+
const byPath = new Map();
|
|
288
|
+
for (const ev of windowEvents) {
|
|
289
|
+
if (ev.name !== 'write_file' || !ev.path || typeof ev.content !== 'string') continue;
|
|
290
|
+
if (!byPath.has(ev.path)) byPath.set(ev.path, []);
|
|
291
|
+
byPath.get(ev.path).push(ev);
|
|
292
|
+
}
|
|
293
|
+
return byPath;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** A → B → A. The file is being flipped between two answers already tried. */
|
|
297
|
+
function findThrashing(windowEvents) {
|
|
298
|
+
for (const [path, writes] of writesByPath(windowEvents)) {
|
|
299
|
+
for (let i = 2; i < writes.length; i += 1) {
|
|
300
|
+
if (writes[i].content === writes[i - 2].content && writes[i].content !== writes[i - 1].content) {
|
|
301
|
+
return {
|
|
302
|
+
pattern: 'thrashing',
|
|
303
|
+
evidence: {
|
|
304
|
+
key: `thrashing:${path}`,
|
|
305
|
+
path,
|
|
306
|
+
rounds: [writes[i - 2].label, writes[i - 1].label, writes[i].label],
|
|
307
|
+
count: 3,
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The same bytes written to the same path twice — the second one did nothing. */
|
|
317
|
+
function findIdenticalWrite(windowEvents) {
|
|
318
|
+
for (const [path, writes] of writesByPath(windowEvents)) {
|
|
319
|
+
const buckets = new Map();
|
|
320
|
+
for (const ev of writes) {
|
|
321
|
+
if (!buckets.has(ev.content)) buckets.set(ev.content, []);
|
|
322
|
+
buckets.get(ev.content).push(ev);
|
|
323
|
+
}
|
|
324
|
+
for (const [content, group] of buckets) {
|
|
325
|
+
if (group.length < IDENTICAL_WRITE_LIMIT) continue;
|
|
326
|
+
return {
|
|
327
|
+
pattern: 'repeated-identical-edit',
|
|
328
|
+
evidence: {
|
|
329
|
+
key: `repeated-identical-edit:${path}`,
|
|
330
|
+
path,
|
|
331
|
+
count: group.length,
|
|
332
|
+
bytes: content.length,
|
|
333
|
+
rounds: group.map((ev) => ev.label),
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The same command, same exit code, same first error line, three times.
|
|
343
|
+
*
|
|
344
|
+
* ⚠️⚠️ THE CHAIN RESETS ON A REAL FILE CHANGE, and that single rule is what
|
|
345
|
+
* keeps the most common legitimate shape in the world out of here: a test
|
|
346
|
+
* failing with the identical assertion while the model rewrites the code
|
|
347
|
+
* between every run. That is iteration, and it is what success looks like right
|
|
348
|
+
* up until the last round. Only failures with NOTHING changed between them are
|
|
349
|
+
* a circle.
|
|
350
|
+
*
|
|
351
|
+
* ⚠️ A DIFFERENT FIRST LINE STARTS A NEW CHAIN — "same command failing
|
|
352
|
+
* differently" is the model peeling errors off one at a time, which is the
|
|
353
|
+
* definition of progress.
|
|
354
|
+
*/
|
|
355
|
+
function findCommandFailureLoop(windowEvents) {
|
|
356
|
+
let chain = null;
|
|
357
|
+
for (const ev of windowEvents) {
|
|
358
|
+
if (ev.changedDisk) { chain = null; continue; }
|
|
359
|
+
if (!ev.isRun || ev.result.ok !== true) continue;
|
|
360
|
+
|
|
361
|
+
const command = commandOf(ev);
|
|
362
|
+
if (!command) continue;
|
|
363
|
+
|
|
364
|
+
if (!runFailed(ev.result)) {
|
|
365
|
+
// The same command working is the clearest possible "not stuck".
|
|
366
|
+
if (chain && chain.command === command) chain = null;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const exitCode = Number.isFinite(ev.result.exitCode) ? ev.result.exitCode : null;
|
|
371
|
+
const errorLine = firstErrorLine(ev.result);
|
|
372
|
+
const same = chain && chain.command === command && chain.exitCode === exitCode && chain.errorLine === errorLine;
|
|
373
|
+
if (same) {
|
|
374
|
+
chain.count += 1;
|
|
375
|
+
chain.rounds.push(ev.label);
|
|
376
|
+
} else {
|
|
377
|
+
chain = { command, exitCode, errorLine, count: 1, rounds: [ev.label] };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (chain.count >= COMMAND_FAILURE_LIMIT) {
|
|
381
|
+
return {
|
|
382
|
+
pattern: 'repeated-command-failure',
|
|
383
|
+
evidence: {
|
|
384
|
+
key: `repeated-command-failure:${command}:${exitCode}:${errorLine}`,
|
|
385
|
+
command, exitCode, errorLine,
|
|
386
|
+
count: chain.count,
|
|
387
|
+
rounds: chain.rounds.slice(),
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* The same tool refusing with the same message, three times running.
|
|
397
|
+
*
|
|
398
|
+
* ⚠️ THE SAME TOOL SUCCEEDING CLEARS THE CHAIN. An `edit_file` that misses,
|
|
399
|
+
* lands, misses again is an ordinary editing session with two typos in it — not
|
|
400
|
+
* a loop. Only an unbroken run of identical refusals counts.
|
|
401
|
+
*/
|
|
402
|
+
function findToolErrorLoop(windowEvents) {
|
|
403
|
+
let chain = null;
|
|
404
|
+
for (const ev of windowEvents) {
|
|
405
|
+
// A failing command is not a refusing tool: it RAN. That is
|
|
406
|
+
// findCommandFailureLoop's business, and counting it twice would double the
|
|
407
|
+
// nudges for one problem.
|
|
408
|
+
if (ev.isRun && ev.result.ok === true) continue;
|
|
409
|
+
|
|
410
|
+
if (ev.result.ok === true) {
|
|
411
|
+
if (chain && chain.tool === ev.name) chain = null;
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (ev.result.ok !== false) continue;
|
|
415
|
+
|
|
416
|
+
const error = normMessage(ev.result.error);
|
|
417
|
+
if (!error) continue;
|
|
418
|
+
|
|
419
|
+
if (chain && chain.tool === ev.name && chain.error === error) {
|
|
420
|
+
chain.count += 1;
|
|
421
|
+
chain.rounds.push(ev.label);
|
|
422
|
+
} else {
|
|
423
|
+
chain = { tool: ev.name, error, count: 1, rounds: [ev.label] };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (chain.count >= TOOL_ERROR_LIMIT) {
|
|
427
|
+
return {
|
|
428
|
+
pattern: 'tool-error-loop',
|
|
429
|
+
evidence: {
|
|
430
|
+
key: `tool-error-loop:${ev.name}:${error}`,
|
|
431
|
+
tool: ev.name,
|
|
432
|
+
error,
|
|
433
|
+
count: chain.count,
|
|
434
|
+
rounds: chain.rounds.slice(),
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Rounds that wrote nothing, ran nothing, and asked nothing new.
|
|
444
|
+
*
|
|
445
|
+
* ⚠️⚠️ "NO WRITES" IS NOT THE TEST, AND THIS IS THE TRAP THE BRIEF WARNS ABOUT
|
|
446
|
+
* DIRECTLY. A long research phase writes nothing for many rounds and is exactly
|
|
447
|
+
* how a good agent starts a hard task. What makes a round inert is that it
|
|
448
|
+
* produced NO NEW OBSERVATION EITHER — every read, search and listing in it had
|
|
449
|
+
* already been made earlier in the same run. Reading ten different files is
|
|
450
|
+
* work; reading the same file for the fourth time while writing nothing is not.
|
|
451
|
+
*/
|
|
452
|
+
function findNoProgress(events, roundMeta, windowRounds, inertLimit) {
|
|
453
|
+
if (roundMeta.length < inertLimit) return null;
|
|
454
|
+
|
|
455
|
+
const activeByRound = new Map();
|
|
456
|
+
for (const ev of events) {
|
|
457
|
+
const active = ev.changedDisk || ev.mutated || ev.isRun || ev.newProbe;
|
|
458
|
+
if (active) activeByRound.set(ev.roundIndex, true);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const tail = roundMeta.slice(-inertLimit);
|
|
462
|
+
if (tail.length < inertLimit) return null;
|
|
463
|
+
if (tail[0].roundIndex < roundMeta.length - windowRounds) return null;
|
|
464
|
+
for (const meta of tail) {
|
|
465
|
+
if (activeByRound.get(meta.roundIndex)) return null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
pattern: 'no-progress',
|
|
470
|
+
evidence: {
|
|
471
|
+
key: `no-progress:from-${tail[0].label}`,
|
|
472
|
+
count: tail.length,
|
|
473
|
+
rounds: tail.map((m) => m.label),
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/* ══════════════════════════════════════════════════════════════════════════
|
|
479
|
+
* suggestions — the field that is actually worth anything
|
|
480
|
+
* ══════════════════════════════════════════════════════════════════════════ */
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* ⭐ WRITTEN FOR A MODEL TO ACT ON, NOT FOR A HUMAN TO READ IN A LOG.
|
|
484
|
+
*
|
|
485
|
+
* Three rules, all learned the hard way in this repo:
|
|
486
|
+
* · NAME THE ARTIFACT. "You appear to be stuck" is unactionable; "you have
|
|
487
|
+
* written the identical bytes to lib/mode.js twice" points at a thing.
|
|
488
|
+
* · PROPOSE A MOVE. An observation with no next step gets acknowledged and
|
|
489
|
+
* then ignored.
|
|
490
|
+
* · NEVER SCOLD, AND NEVER IMPLY THE RUN IS OVER. An error string is an
|
|
491
|
+
* instruction — this package already watched "try once more" make a model
|
|
492
|
+
* retry a dead service until the session died. "You have failed" reads as
|
|
493
|
+
* permission to hand back half a job.
|
|
494
|
+
*/
|
|
495
|
+
function suggestionFor(pattern, evidence) {
|
|
496
|
+
switch (pattern) {
|
|
497
|
+
case 'long-cycle':
|
|
498
|
+
return `The same ${evidence.length} steps have now repeated ${evidence.repeats} times without changing `
|
|
499
|
+
+ `(${evidence.verbs.join(' → ')}), on the same arguments each time. Repeating them again will produce the `
|
|
500
|
+
+ `same result, because nothing between the rounds is different. Stop and state what you EXPECTED to change `
|
|
501
|
+
+ `and what actually did — then either read the last failure output properly, or try a different approach `
|
|
502
|
+
+ `entirely rather than another pass of the same loop.`;
|
|
503
|
+
|
|
504
|
+
case 'thrashing':
|
|
505
|
+
return `\`${evidence.path}\` has just been flipped back to a version already tried `
|
|
506
|
+
+ `(A → B → A across rounds ${evidence.rounds.join(', ')}). Alternating between two answers will not settle it, `
|
|
507
|
+
+ `because whatever rejected the first version has not been addressed yet. Read the most recent failure output `
|
|
508
|
+
+ `closely and change one specific thing on purpose, rather than reverting.`;
|
|
509
|
+
|
|
510
|
+
case 'repeated-identical-edit':
|
|
511
|
+
return `The identical ${evidence.bytes} bytes have now been written to \`${evidence.path}\` ${evidence.count} times, `
|
|
512
|
+
+ `so the later write changed nothing on disk. Before writing it again, check that this is really the path the `
|
|
513
|
+
+ `failing command loads — a near-miss path, or a build output directory, would look exactly like this — and read `
|
|
514
|
+
+ `the file back to confirm what is actually there.`;
|
|
515
|
+
|
|
516
|
+
case 'repeated-command-failure':
|
|
517
|
+
return `\`${evidence.command}\` has failed ${evidence.count} times with the same exit code (${evidence.exitCode}) `
|
|
518
|
+
+ `and the same first line: "${evidence.errorLine}". No file changed between those runs, so running it again `
|
|
519
|
+
+ `will print the same thing. Read further into the output than the first line, or run something smaller that `
|
|
520
|
+
+ `isolates which part fails.`;
|
|
521
|
+
|
|
522
|
+
case 'tool-error-loop':
|
|
523
|
+
if (evidence.tool === 'edit_file') {
|
|
524
|
+
return `\`edit_file\` has refused ${evidence.count} times in a row with the same message: "${evidence.error}". `
|
|
525
|
+
+ `The call is what needs to change, not the number of attempts: read the file first and copy the exact text `
|
|
526
|
+
+ `into old_string — including indentation and line endings — or write the whole file if the span is hard to `
|
|
527
|
+
+ `quote precisely.`;
|
|
528
|
+
}
|
|
529
|
+
return `\`${evidence.tool}\` has refused ${evidence.count} times in a row with the same message: "${evidence.error}". `
|
|
530
|
+
+ `Repeating the same call will get the same answer, so read that message closely and change the arguments, or `
|
|
531
|
+
+ `reach the same goal with a different tool.`;
|
|
532
|
+
|
|
533
|
+
case 'no-progress':
|
|
534
|
+
return `The last ${evidence.count} rounds wrote no files, ran no commands, and only repeated lookups already made `
|
|
535
|
+
+ `earlier in this run. If there is enough information to act, make the change now; if something is genuinely `
|
|
536
|
+
+ `blocking it, say what is blocking it before looking again.`;
|
|
537
|
+
|
|
538
|
+
default:
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/* ══════════════════════════════════════════════════════════════════════════
|
|
544
|
+
* the entry point
|
|
545
|
+
* ══════════════════════════════════════════════════════════════════════════ */
|
|
546
|
+
|
|
547
|
+
const CLEAN = Object.freeze({ stuck: false, pattern: null, evidence: null, suggestion: null });
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Is this run going in circles?
|
|
551
|
+
*
|
|
552
|
+
* @param {Array} rounds the history `runSession` already keeps, oldest first.
|
|
553
|
+
* @param {object} [options]
|
|
554
|
+
* @param {number} [options.window=4] how many recent rounds to examine.
|
|
555
|
+
* @returns {{stuck:boolean, pattern:string|null, evidence:object|null, suggestion:string|null}}
|
|
556
|
+
*
|
|
557
|
+
* ⚠️ A CLEAN RESULT IS FULLY NULL, never partially populated. A caller that
|
|
558
|
+
* reads `.suggestion` without checking `.stuck` must get nothing rather than a
|
|
559
|
+
* stale hint — this repo has already shipped one detector whose "all clear"
|
|
560
|
+
* carried a verdict about a page it had never seen.
|
|
561
|
+
*/
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* ── ⭐⭐ THE LOOP A FOUR-ROUND WINDOW CANNOT SEE ─────────────────────────────
|
|
565
|
+
*
|
|
566
|
+
* Everything above examines `DEFAULT_WINDOW = 4` rounds. That is the right size
|
|
567
|
+
* for the patterns it names — two identical writes, an A→B→A flip — but it makes
|
|
568
|
+
* one whole family invisible by arithmetic: a cycle of PERIOD 3 (read X, edit X,
|
|
569
|
+
* run tests, read X, edit X, run tests) never fits two repetitions inside four
|
|
570
|
+
* rounds, so it can run until the round cap and never be reported.
|
|
571
|
+
*
|
|
572
|
+
* ⚠️ IT IS REACHABLE WITH OUR OWN BUDGET — 24 rounds by default, 64 by ceiling,
|
|
573
|
+
* 200 under `--until-done`. And MAST (1,600+ traces, κ=0.88) measured **step
|
|
574
|
+
* repetition as the single largest failure mode at 17.14%**, larger than any
|
|
575
|
+
* other category.
|
|
576
|
+
*
|
|
577
|
+
* ⭐ ZERO TOKENS: string comparison over history we already hold.
|
|
578
|
+
*
|
|
579
|
+
* ⚠️ THE SIGNATURE INCLUDES THE ARGUMENTS, and that is what keeps it honest.
|
|
580
|
+
* read → edit → run repeated over DIFFERENT files is exactly what a competent
|
|
581
|
+
* refactor looks like; only a byte-identical cycle counts.
|
|
582
|
+
*/
|
|
583
|
+
export const LONG_CYCLE_HISTORY = 24;
|
|
584
|
+
export const LONG_CYCLE_MAX_LEN = 5;
|
|
585
|
+
export const LONG_CYCLE_REPEATS = 3;
|
|
586
|
+
|
|
587
|
+
/** What makes two rounds "the same step" — the verb plus what it was aimed at. */
|
|
588
|
+
function roundSignature(round) {
|
|
589
|
+
const executed = Array.isArray(round?.executed) ? round.executed : [];
|
|
590
|
+
return executed
|
|
591
|
+
.filter((r) => r && typeof r.name === 'string')
|
|
592
|
+
.map((r) => {
|
|
593
|
+
let args = '';
|
|
594
|
+
try { args = JSON.stringify(r.args ?? {}); } catch { args = ''; }
|
|
595
|
+
return `${r.name}(${args.slice(0, 400)})`;
|
|
596
|
+
})
|
|
597
|
+
.join('|');
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* ⚠️ EXPORTED FOR THE TESTS, AND THE REASON IS ITSELF A FINDING. The
|
|
602
|
+
* `new Set(cycle).size < 2` guard below could not be reached through
|
|
603
|
+
* `detectStuck`: a run of identical reads trips `no-progress` first, so a
|
|
604
|
+
* mutation deleting the guard left every test green. A guard no test can
|
|
605
|
+
* reach is indistinguishable from dead code — so the function is exported and
|
|
606
|
+
* the guard is exercised directly rather than deleted or left unproven.
|
|
607
|
+
*/
|
|
608
|
+
export function findLongCycle(rounds) {
|
|
609
|
+
const recent = rounds.slice(-LONG_CYCLE_HISTORY);
|
|
610
|
+
const sigs = recent.map(roundSignature).filter((x) => x !== '');
|
|
611
|
+
if (sigs.length < LONG_CYCLE_REPEATS * 2) return null;
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* ⚠️ LONGEST PERIOD FIRST. A period-4 loop also contains a period-2 one when
|
|
615
|
+
* its halves happen to match; reporting the shorter one would name a smaller
|
|
616
|
+
* loop than the model is actually running.
|
|
617
|
+
*/
|
|
618
|
+
for (let len = LONG_CYCLE_MAX_LEN; len >= 2; len -= 1) {
|
|
619
|
+
if (sigs.length < len * LONG_CYCLE_REPEATS) continue;
|
|
620
|
+
const tail = sigs.slice(-len * LONG_CYCLE_REPEATS);
|
|
621
|
+
const cycle = tail.slice(0, len);
|
|
622
|
+
let matches = true;
|
|
623
|
+
for (let i = 0; i < tail.length; i += 1) {
|
|
624
|
+
if (tail[i] !== cycle[i % len]) { matches = false; break; }
|
|
625
|
+
}
|
|
626
|
+
if (!matches) continue;
|
|
627
|
+
/**
|
|
628
|
+
* ⚠️ A CYCLE OF ONE DISTINCT STEP IS NOT A CYCLE — it is the repeated-call
|
|
629
|
+
* case the detectors above already state more precisely.
|
|
630
|
+
*/
|
|
631
|
+
if (new Set(cycle).size < 2) continue;
|
|
632
|
+
const verbs = [...new Set(cycle.flatMap((sig) => sig.split('|').map((c) => c.split('(')[0])))];
|
|
633
|
+
return { pattern: 'long-cycle', evidence: { length: len, repeats: LONG_CYCLE_REPEATS, verbs } };
|
|
634
|
+
}
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export function detectStuck(rounds, { window = DEFAULT_WINDOW } = {}) {
|
|
639
|
+
if (!Array.isArray(rounds) || rounds.length === 0) return { ...CLEAN };
|
|
640
|
+
|
|
641
|
+
const windowRounds = Number.isFinite(window) && window >= 1 ? Math.floor(window) : DEFAULT_WINDOW;
|
|
642
|
+
const inertLimit = Math.min(INERT_ROUND_LIMIT, windowRounds);
|
|
643
|
+
|
|
644
|
+
const { events, roundMeta } = flatten(rounds);
|
|
645
|
+
annotate(events);
|
|
646
|
+
|
|
647
|
+
const firstWindowRound = Math.max(0, roundMeta.length - windowRounds);
|
|
648
|
+
const windowEvents = events.filter((ev) => ev.roundIndex >= firstWindowRound);
|
|
649
|
+
const windowMeta = roundMeta.filter((m) => m.roundIndex >= firstWindowRound);
|
|
650
|
+
|
|
651
|
+
const hit = findThrashing(windowEvents)
|
|
652
|
+
?? findIdenticalWrite(windowEvents)
|
|
653
|
+
?? findToolErrorLoop(windowEvents)
|
|
654
|
+
?? findCommandFailureLoop(windowEvents)
|
|
655
|
+
?? (windowMeta.length >= inertLimit ? findNoProgress(events, roundMeta, windowRounds, inertLimit) : null)
|
|
656
|
+
?? findLongCycle(rounds);
|
|
657
|
+
|
|
658
|
+
if (!hit) return { ...CLEAN };
|
|
659
|
+
|
|
660
|
+
const suggestion = suggestionFor(hit.pattern, hit.evidence);
|
|
661
|
+
if (!suggestion) return { ...CLEAN };
|
|
662
|
+
|
|
663
|
+
return { stuck: true, pattern: hit.pattern, evidence: hit.evidence, suggestion };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* The exact text to append to the conversation, or null when there is nothing
|
|
668
|
+
* to say.
|
|
669
|
+
*
|
|
670
|
+
* ⚠️ IT ANNOUNCES ITSELF AS MACHINERY, NOT AS THE USER. A bare instruction
|
|
671
|
+
* arriving in the `user` role is indistinguishable from the human changing
|
|
672
|
+
* their mind, and a model that believes the user just spoke will re-plan the
|
|
673
|
+
* whole task around it. Naming the source keeps it a hint about HOW to continue
|
|
674
|
+
* rather than a new instruction about WHAT to do.
|
|
675
|
+
*/
|
|
676
|
+
export function nudgeMessage(result) {
|
|
677
|
+
if (!result || result.stuck !== true || typeof result.suggestion !== 'string') return null;
|
|
678
|
+
return `[loop watcher — automatic, not from the user] ${result.suggestion}`;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* ── ⭐ HOW TO WIRE THIS (the whole point of the module) ──────────────────────
|
|
683
|
+
*
|
|
684
|
+
* In `lib/turn.mjs`, at the top:
|
|
685
|
+
*
|
|
686
|
+
* import { detectStuck, nudgeMessage } from './stuck.mjs';
|
|
687
|
+
*
|
|
688
|
+
* and in `runSession`, immediately after the existing `rounds.push({ round, ... })`:
|
|
689
|
+
*
|
|
690
|
+
* const circling = detectStuck(rounds);
|
|
691
|
+
* if (circling.stuck && !nudged.has(circling.evidence.key)) {
|
|
692
|
+
* nudged.add(circling.evidence.key);
|
|
693
|
+
* messages.push({ role: 'user', content: nudgeMessage(circling) });
|
|
694
|
+
* onEvent({ type: 'stuck', round, pattern: circling.pattern, evidence: circling.evidence });
|
|
695
|
+
* }
|
|
696
|
+
*
|
|
697
|
+
* with one declaration beside `const rounds = []`:
|
|
698
|
+
*
|
|
699
|
+
* const nudged = new Set();
|
|
700
|
+
*
|
|
701
|
+
* ⚠️ `nudged` IS NOT OPTIONAL. Without it the same loop re-nudges every round,
|
|
702
|
+
* which changes the prompt prefix every round and destroys the byte-identical
|
|
703
|
+
* cache hit worth 3.05x on DeepSeek — a loop detector that triples the bill is
|
|
704
|
+
* not a saving. `evidence.key` is stable for as long as one loop persists and
|
|
705
|
+
* differs between distinct loops, which is exactly what a dedupe set needs.
|
|
706
|
+
*
|
|
707
|
+
* ⭐ NUDGING IS THE DEFAULT ACTION, NOT STOPPING. The model gets one hint and
|
|
708
|
+
* keeps its budget; a caller that wants a hard stop can compare
|
|
709
|
+
* `circling.evidence.key` across rounds itself and give up on the second sighting.
|
|
710
|
+
* Ending a run automatically is the expensive mistake this module was written to
|
|
711
|
+
* avoid making.
|
|
712
|
+
*/
|