atom-agent 0.3.0 → 1.1.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 +82 -0
- package/README.md +83 -32
- package/dist/App.js +2178 -318
- package/dist/adapters.js +146 -15
- package/dist/agent/gates.js +153 -0
- package/dist/agent/loop-guard.js +184 -0
- package/dist/agent/loop.js +908 -0
- package/dist/agent/normalize.js +144 -0
- package/dist/agent/types.js +1 -0
- package/dist/auth.js +2 -1
- package/dist/cli.js +68 -6
- package/dist/compact.js +6 -48
- package/dist/config.js +171 -0
- package/dist/context-manager.js +564 -0
- package/dist/kilo.js +343 -0
- package/dist/local-discovery.js +308 -0
- package/dist/policy.js +286 -0
- package/dist/prompt-cache.js +99 -0
- package/dist/providers.js +183 -2
- package/dist/rollback.js +21 -0
- package/dist/scheduler.js +247 -0
- package/dist/session.js +35 -3
- package/dist/skills.js +214 -43
- package/dist/snapshots.js +57 -2
- package/dist/system.js +8 -1
- package/dist/telemetry-dashboard.js +589 -0
- package/dist/telemetry-server.js +301 -0
- package/dist/telemetry.js +1056 -0
- package/dist/tools/dir-cache.js +207 -0
- package/dist/tools/filesystem.js +149 -0
- package/dist/tools/fingerprints.js +33 -0
- package/dist/tools/overflow.js +76 -0
- package/dist/tools/read-cache.js +160 -0
- package/dist/tools/registry.js +802 -0
- package/dist/tools/search.js +242 -0
- package/dist/tools/shared.js +31 -0
- package/dist/tools/shell.js +273 -0
- package/dist/tools/todo.js +191 -0
- package/dist/tools/web.js +454 -0
- package/dist/tools.js +17 -1863
- package/dist/ui/activity.js +51 -0
- package/dist/ui/diff-panel.js +55 -0
- package/dist/ui/diff-view.js +112 -0
- package/dist/ui/diff.js +422 -0
- package/dist/ui/errors.js +129 -0
- package/dist/ui/highlight.js +120 -0
- package/dist/ui/input-model.js +115 -0
- package/dist/ui/input.js +40 -0
- package/dist/ui/live-tail.js +15 -0
- package/dist/ui/markdown.js +525 -0
- package/dist/ui/modals.js +47 -0
- package/dist/ui/palette.js +70 -0
- package/dist/ui/pickers.js +32 -0
- package/dist/ui/side-by-side.js +144 -0
- package/dist/ui/status-bar.js +75 -0
- package/dist/ui/theme.js +128 -0
- package/dist/ui/todo-panel.js +30 -0
- package/dist/ui/tool-inspector.js +59 -0
- package/dist/ui/transcript.js +128 -0
- package/dist/zen.js +145 -666
- package/package.json +1 -1
package/dist/adapters.js
CHANGED
|
@@ -24,6 +24,7 @@ function parseArgsObject(raw) {
|
|
|
24
24
|
return {};
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
+
import { ephemeralBreakpoint, assemblePrefix } from "./prompt-cache.js";
|
|
27
28
|
export function buildAnthropicBody(history, model, opts) {
|
|
28
29
|
const systems = [];
|
|
29
30
|
const messages = [];
|
|
@@ -83,15 +84,37 @@ export function buildAnthropicBody(history, model, opts) {
|
|
|
83
84
|
// Compaction path (includeTools:false) omits `tools` + `tool_choice`
|
|
84
85
|
// entirely — asserted in tests as "no `tools` key".
|
|
85
86
|
if (includeTools) {
|
|
86
|
-
|
|
87
|
+
const defs = toolDefs().map((t) => ({
|
|
87
88
|
name: t.function.name,
|
|
88
89
|
description: t.function.description,
|
|
89
90
|
input_schema: t.function.parameters,
|
|
90
91
|
}));
|
|
92
|
+
// Stable-prefix boundary (prompt-cache architecture): the env tail below
|
|
93
|
+
// decides. With a split, the stable block + full tools array are
|
|
94
|
+
// breakpointed (system head, then last tool — the two long-lived cache
|
|
95
|
+
// entries); without one, the legacy string shape is preserved exactly.
|
|
96
|
+
const joined = systems.join("\n\n");
|
|
97
|
+
const prefix = systems.length > 0 ? assemblePrefix({ systemContent: joined }) : null;
|
|
98
|
+
const dynamicTail = prefix?.dynamicSystem ?? null;
|
|
99
|
+
const stableHead = prefix && dynamicTail !== null ? prefix.stableSystem : "";
|
|
100
|
+
if (prefix !== null && dynamicTail !== null && stableHead.trim().length > 0) {
|
|
101
|
+
body.system = [
|
|
102
|
+
{ type: "text", text: stableHead, cache_control: ephemeralBreakpoint() },
|
|
103
|
+
{ type: "text", text: dynamicTail },
|
|
104
|
+
];
|
|
105
|
+
if (defs.length > 0) {
|
|
106
|
+
defs[defs.length - 1].cache_control = ephemeralBreakpoint();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
else if (systems.length > 0) {
|
|
110
|
+
body.system = joined;
|
|
111
|
+
}
|
|
112
|
+
body.tools = defs;
|
|
91
113
|
body.tool_choice = { type: "auto" };
|
|
92
114
|
}
|
|
93
|
-
if (systems.length > 0)
|
|
115
|
+
else if (systems.length > 0) {
|
|
94
116
|
body.system = systems.join("\n\n");
|
|
117
|
+
}
|
|
95
118
|
return body;
|
|
96
119
|
}
|
|
97
120
|
export function anthropicHeaders(apiKey) {
|
|
@@ -226,10 +249,71 @@ export function buildGeminiBody(history, _model, opts) {
|
|
|
226
249
|
body.generationConfig = { maxOutputTokens: Math.floor(opts.maxOutputTokens) };
|
|
227
250
|
}
|
|
228
251
|
if (systems.length > 0) {
|
|
229
|
-
|
|
252
|
+
// Stable-prefix split (prompt-cache architecture): a trailing env block
|
|
253
|
+
// becomes its own part so the stable head stays byte-identical across
|
|
254
|
+
// POSTs for implicit prefix caching. No env tail → the legacy single
|
|
255
|
+
// part, byte-identical to before.
|
|
256
|
+
const joined = systems.join("\n\n");
|
|
257
|
+
const prefix = assemblePrefix({ systemContent: joined });
|
|
258
|
+
body.system_instruction =
|
|
259
|
+
prefix.dynamicSystem !== null && prefix.stableSystem.trim().length > 0
|
|
260
|
+
? { parts: [{ text: prefix.stableSystem }, { text: prefix.dynamicSystem }] }
|
|
261
|
+
: { parts: [{ text: joined }] };
|
|
230
262
|
}
|
|
231
263
|
return body;
|
|
232
264
|
}
|
|
265
|
+
// ---- SSE stall timeout (live-proven: a 200-OK stream can stop emitting
|
|
266
|
+
// bytes mid-generation — e.g. free-tier routers stalling on tool-heavy
|
|
267
|
+
// requests — and hang the turn until the socket dies minutes later) ----
|
|
268
|
+
//
|
|
269
|
+
// Every `reader.read()` / iterator step races this clock; silence longer
|
|
270
|
+
// than the budget fails the turn LOUDLY with a permanent Truncated-stream
|
|
271
|
+
// error (same contract as a dead connection: the caller rolls back, the App
|
|
272
|
+
// keeps the streamed partial, the user resends). The clock resets on every
|
|
273
|
+
// received chunk — slow models are fine, dead sockets are not.
|
|
274
|
+
//
|
|
275
|
+
// Budget: env ATOM_STALL_TIMEOUT_MS when a finite value > 0 (max-clamped to
|
|
276
|
+
// 5min; an explicitly tiny value is the operator's choice, and lets tests
|
|
277
|
+
// use millisecond budgets), else the 60s default. The hung read is left to
|
|
278
|
+
// settle — callers cancel/release the reader on the way out as before.
|
|
279
|
+
export const DEFAULT_SSE_STALL_TIMEOUT_MS = 60_000;
|
|
280
|
+
export const MAX_SSE_STALL_TIMEOUT_MS = 300_000;
|
|
281
|
+
export function sseStallTimeoutMs() {
|
|
282
|
+
const raw = process.env.ATOM_STALL_TIMEOUT_MS;
|
|
283
|
+
if (raw !== undefined) {
|
|
284
|
+
const n = Number(raw.trim());
|
|
285
|
+
if (Number.isFinite(n) && n > 0)
|
|
286
|
+
return Math.min(Math.floor(n), MAX_SSE_STALL_TIMEOUT_MS);
|
|
287
|
+
}
|
|
288
|
+
return DEFAULT_SSE_STALL_TIMEOUT_MS;
|
|
289
|
+
}
|
|
290
|
+
export function isStallError(e) {
|
|
291
|
+
return e instanceof Error && e.message.startsWith("Truncated stream from model (stall:");
|
|
292
|
+
}
|
|
293
|
+
export async function readWithStall(read, ms) {
|
|
294
|
+
const limit = typeof ms === "number" && Number.isFinite(ms) && ms > 0 ? Math.floor(ms) : sseStallTimeoutMs();
|
|
295
|
+
let timer = null;
|
|
296
|
+
try {
|
|
297
|
+
const pending = read();
|
|
298
|
+
const timeout = new Promise((_, reject) => {
|
|
299
|
+
timer = setTimeout(() => {
|
|
300
|
+
reject(new Error(`Truncated stream from model (stall: no bytes for ${limit}ms before [DONE]).`));
|
|
301
|
+
}, limit);
|
|
302
|
+
// An unref'd timer must never hold the process open for a settled read.
|
|
303
|
+
try {
|
|
304
|
+
timer.unref?.();
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
// ignore — environments without unref (browsers) proceed regardless
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
return await Promise.race([pending, timeout]);
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
if (timer)
|
|
314
|
+
clearTimeout(timer);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
233
317
|
async function collectSSEText(res) {
|
|
234
318
|
const body = res.body;
|
|
235
319
|
const decoder = new TextDecoder();
|
|
@@ -243,9 +327,20 @@ async function collectSSEText(res) {
|
|
|
243
327
|
for (;;) {
|
|
244
328
|
let chunk;
|
|
245
329
|
try {
|
|
246
|
-
chunk = await reader.read();
|
|
330
|
+
chunk = await readWithStall(() => reader.read());
|
|
247
331
|
}
|
|
248
332
|
catch (e) {
|
|
333
|
+
if (isStallError(e)) {
|
|
334
|
+
// Free the dead socket on the way out, then surface the stall
|
|
335
|
+
// unchanged (permanent Truncated contract — never retried).
|
|
336
|
+
try {
|
|
337
|
+
await reader.cancel?.();
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
// ignore cancel errors
|
|
341
|
+
}
|
|
342
|
+
throw e;
|
|
343
|
+
}
|
|
249
344
|
throw new Error(`Truncated stream from model (connection aborted: ${e instanceof Error ? e.message : String(e)}).`);
|
|
250
345
|
}
|
|
251
346
|
if (chunk.done)
|
|
@@ -267,11 +362,26 @@ async function collectSSEText(res) {
|
|
|
267
362
|
}
|
|
268
363
|
}
|
|
269
364
|
else if (typeof body[Symbol.asyncIterator] === "function") {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
365
|
+
const it = body[Symbol.asyncIterator]();
|
|
366
|
+
try {
|
|
367
|
+
for (;;) {
|
|
368
|
+
const step = await readWithStall(() => it.next());
|
|
369
|
+
if (step.done)
|
|
370
|
+
break;
|
|
371
|
+
const v = step.value;
|
|
372
|
+
rawText +=
|
|
373
|
+
typeof v === "string"
|
|
374
|
+
? v
|
|
375
|
+
: decoder.decode(v, { stream: true });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
finally {
|
|
379
|
+
try {
|
|
380
|
+
await it.return?.();
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
// ignore — the stream is over either way
|
|
384
|
+
}
|
|
275
385
|
}
|
|
276
386
|
}
|
|
277
387
|
else {
|
|
@@ -318,7 +428,7 @@ function finiteCount(value) {
|
|
|
318
428
|
? Math.floor(value)
|
|
319
429
|
: undefined;
|
|
320
430
|
}
|
|
321
|
-
function openAIUsage(prompt, completion) {
|
|
431
|
+
function openAIUsage(prompt, completion, cache) {
|
|
322
432
|
const out = {};
|
|
323
433
|
const p = finiteCount(prompt);
|
|
324
434
|
if (p !== undefined)
|
|
@@ -328,6 +438,14 @@ function openAIUsage(prompt, completion) {
|
|
|
328
438
|
out.completion_tokens = c;
|
|
329
439
|
if (p !== undefined && c !== undefined)
|
|
330
440
|
out.total_tokens = p + c;
|
|
441
|
+
// Provider-reported cache counters ride alongside (Anthropic
|
|
442
|
+
// cache_read/_creation, Gemini cachedContentTokenCount) — present-only.
|
|
443
|
+
const read = finiteCount(cache?.read);
|
|
444
|
+
if (read !== undefined)
|
|
445
|
+
out.cacheReadTokens = read;
|
|
446
|
+
const write = finiteCount(cache?.write);
|
|
447
|
+
if (write !== undefined)
|
|
448
|
+
out.cacheWriteTokens = write;
|
|
331
449
|
return out.prompt_tokens !== undefined ||
|
|
332
450
|
out.completion_tokens !== undefined ||
|
|
333
451
|
out.total_tokens !== undefined
|
|
@@ -406,7 +524,10 @@ export async function readAnthropicSSEMessage(res, opts) {
|
|
|
406
524
|
const msg = o["message"];
|
|
407
525
|
const u = msg?.["usage"];
|
|
408
526
|
if (u) {
|
|
409
|
-
const hit = openAIUsage(u["input_tokens"], u["output_tokens"]
|
|
527
|
+
const hit = openAIUsage(u["input_tokens"], u["output_tokens"], {
|
|
528
|
+
read: u["cache_read_input_tokens"],
|
|
529
|
+
write: u["cache_creation_input_tokens"],
|
|
530
|
+
});
|
|
410
531
|
const merged = mergeUsage(usage, hit, { recomputeTotal: true });
|
|
411
532
|
if (merged !== undefined)
|
|
412
533
|
usage = merged;
|
|
@@ -488,7 +609,10 @@ export async function readAnthropicSSEMessage(res, opts) {
|
|
|
488
609
|
const inputSrc = u?.["input_tokens"] !== undefined ? u["input_tokens"] : o["input_tokens"];
|
|
489
610
|
const outputSrc = u?.["output_tokens"] !== undefined ? u["output_tokens"] : o["output_tokens"];
|
|
490
611
|
if (u !== undefined || o["input_tokens"] !== undefined || o["output_tokens"] !== undefined) {
|
|
491
|
-
const hit = openAIUsage(inputSrc, outputSrc
|
|
612
|
+
const hit = openAIUsage(inputSrc, outputSrc, {
|
|
613
|
+
read: u?.["cache_read_input_tokens"] ?? o["cache_read_input_tokens"],
|
|
614
|
+
write: u?.["cache_creation_input_tokens"] ?? o["cache_creation_input_tokens"],
|
|
615
|
+
});
|
|
492
616
|
const merged = mergeUsage(usage, hit, { recomputeTotal: true });
|
|
493
617
|
if (merged !== undefined)
|
|
494
618
|
usage = merged;
|
|
@@ -607,7 +731,10 @@ export function parseAnthropicJson(data) {
|
|
|
607
731
|
};
|
|
608
732
|
const u = o["usage"];
|
|
609
733
|
if (u) {
|
|
610
|
-
const hit = openAIUsage(u["input_tokens"], u["output_tokens"]
|
|
734
|
+
const hit = openAIUsage(u["input_tokens"], u["output_tokens"], {
|
|
735
|
+
read: u["cache_read_input_tokens"],
|
|
736
|
+
write: u["cache_creation_input_tokens"],
|
|
737
|
+
});
|
|
611
738
|
if (hit)
|
|
612
739
|
result.usage = hit;
|
|
613
740
|
}
|
|
@@ -712,7 +839,9 @@ export async function readGeminiSSEMessage(res, opts) {
|
|
|
712
839
|
}
|
|
713
840
|
const um = o["usageMetadata"];
|
|
714
841
|
if (um) {
|
|
715
|
-
const hit = openAIUsage(um["promptTokenCount"], um["candidatesTokenCount"]
|
|
842
|
+
const hit = openAIUsage(um["promptTokenCount"], um["candidatesTokenCount"], {
|
|
843
|
+
read: um["cachedContentTokenCount"],
|
|
844
|
+
});
|
|
716
845
|
const total = finiteCount(um["totalTokenCount"]);
|
|
717
846
|
const merged = mergeUsage(usage, hit, { total });
|
|
718
847
|
if (merged !== undefined)
|
|
@@ -802,7 +931,9 @@ export function parseGeminiJson(data) {
|
|
|
802
931
|
};
|
|
803
932
|
const um = o["usageMetadata"];
|
|
804
933
|
if (um) {
|
|
805
|
-
const hit = openAIUsage(um["promptTokenCount"], um["candidatesTokenCount"]
|
|
934
|
+
const hit = openAIUsage(um["promptTokenCount"], um["candidatesTokenCount"], {
|
|
935
|
+
read: um["cachedContentTokenCount"],
|
|
936
|
+
});
|
|
806
937
|
const total = finiteCount(um["totalTokenCount"]);
|
|
807
938
|
const merged = mergeUsage(undefined, hit, { total });
|
|
808
939
|
if (merged !== undefined)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Turn-end gates: the runtime-enforced finish conditions every turn must
|
|
2
|
+
// satisfy before final text ends it (todo completion, verification). Pure
|
|
3
|
+
// functions over an explicit context — no loop state, no I/O, no UI.
|
|
4
|
+
// Moved verbatim from src/zen.ts; zen.ts re-exports the stable surface.
|
|
5
|
+
import { getTodos } from "../tools.js";
|
|
6
|
+
// Todo-completion guard: the turn may not end with final text while todos
|
|
7
|
+
// are open. With budget left, record the attempt and feed back a guard
|
|
8
|
+
// message as a user follow-up so the model must continue with tool calls or
|
|
9
|
+
// explicitly resolve the todos. With the step budget spent, end with an
|
|
10
|
+
// explicit blocked statement naming the unfinished items instead.
|
|
11
|
+
export function todoCompletionGate(finalText, ctx) {
|
|
12
|
+
const open = getTodos().filter((t) => t.status !== "completed");
|
|
13
|
+
if (open.length === 0)
|
|
14
|
+
return { action: "pass" };
|
|
15
|
+
const items = open.map((t, i) => `${i + 1}. [${t.status}] ${t.content}`).join("\n");
|
|
16
|
+
if (ctx.step >= ctx.maxSteps) {
|
|
17
|
+
return {
|
|
18
|
+
action: "end",
|
|
19
|
+
finalText: `${finalText}${finalText ? "\n" : ""}(blocked: ${open.length} open todo(s) — resolve with todo_update/todowrite before ending the turn:\n${items})`,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
action: "continue",
|
|
24
|
+
assistantText: finalText,
|
|
25
|
+
followUp: `(todo guard: ${open.length} open todo(s) — do not end the turn with final text. Continue with tool calls, or resolve them with todo_update/todowrite:\n${items})`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
// Task 7 verification gate: code files were written but no test/typecheck/
|
|
29
|
+
// build command has PASSED since. The turn CONTINUES (never ends on a mere
|
|
30
|
+
// report — the system prompt forbids unverified finishes, so the runtime
|
|
31
|
+
// must not terminate while just labeling): the attempt is recorded and a
|
|
32
|
+
// verification follow-up re-enters the loop, exactly like the todo guard.
|
|
33
|
+
// Two bounded exits: spent step budget, or MAX_VERIFY_ROUNDS nag cycles
|
|
34
|
+
// without a passing run — both end with an explicit labeled statement naming
|
|
35
|
+
// what is unverified and why the loop stopped. Turns with no code writes
|
|
36
|
+
// (questions, docs, explanations, read-only work) are unaffected.
|
|
37
|
+
export const MAX_VERIFY_ROUNDS = 3;
|
|
38
|
+
// Source-code extensions whose writes require a passing verification run.
|
|
39
|
+
// Curated heuristic boundary (not a parser): docs, configs, data, and
|
|
40
|
+
// extensionless files never arm the gate, so a README edit finishes clean.
|
|
41
|
+
// Case-insensitive; dotfiles and trailing dots never match.
|
|
42
|
+
const CODE_EXTENSIONS = new Set([
|
|
43
|
+
"ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs",
|
|
44
|
+
"py", "pyw", "rb", "go", "rs", "java", "kt", "kts",
|
|
45
|
+
"swift", "c", "h", "cpp", "hpp", "cc", "cxx", "cs",
|
|
46
|
+
"php", "scala", "sh", "bash", "lua", "r", "dart",
|
|
47
|
+
"vue", "svelte", "astro", "sql", "pl", "pm",
|
|
48
|
+
]);
|
|
49
|
+
export function isCodePath(p) {
|
|
50
|
+
if (typeof p !== "string" || p.length === 0)
|
|
51
|
+
return false;
|
|
52
|
+
const base = p.split(/[\\/]/).pop() ?? "";
|
|
53
|
+
const dot = base.lastIndexOf(".");
|
|
54
|
+
if (dot <= 0 || dot === base.length - 1)
|
|
55
|
+
return false;
|
|
56
|
+
return CODE_EXTENSIONS.has(base.slice(dot + 1).toLowerCase());
|
|
57
|
+
}
|
|
58
|
+
function verifyFilesLabel(paths) {
|
|
59
|
+
if (paths.length === 0)
|
|
60
|
+
return "changed files";
|
|
61
|
+
const shown = paths.slice(0, 5);
|
|
62
|
+
const extra = paths.length - shown.length;
|
|
63
|
+
return shown.join(", ") + (extra > 0 ? ` (+${extra} more)` : "");
|
|
64
|
+
}
|
|
65
|
+
export function verificationGate(finalText, ctx) {
|
|
66
|
+
const needs = ctx.needsVerification ?? ctx.filesWritten;
|
|
67
|
+
if (!needs || ctx.verifiedAfterWrite)
|
|
68
|
+
return { action: "pass" };
|
|
69
|
+
const files = verifyFilesLabel(ctx.unverifiedPaths ?? []);
|
|
70
|
+
if (ctx.step >= ctx.maxSteps) {
|
|
71
|
+
return {
|
|
72
|
+
action: "end",
|
|
73
|
+
finalText: `${finalText}${finalText ? "\n" : ""}(blocked: turn budget spent (${ctx.maxSteps} tool steps) with ${files} still unverified — run the verification and report its pass/fail lines, or name why it cannot run.)`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if ((ctx.verifyRounds ?? 0) >= MAX_VERIFY_ROUNDS) {
|
|
77
|
+
return {
|
|
78
|
+
action: "end",
|
|
79
|
+
finalText: `${finalText}${finalText ? "\n" : ""}(unverified: ${files} changed without a passing verification run — verification was requested ${MAX_VERIFY_ROUNDS}× and never ran. Run \`npm test\` and \`npm run typecheck\` and report their pass/fail lines, or name the blocker explicitly.)`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
action: "continue",
|
|
84
|
+
assistantText: finalText,
|
|
85
|
+
followUp: `(verification required: ${files} changed since the last passing check. Run the repo's verification (e.g. \`npm test\`, \`npm run typecheck\`) and report pass/fail before finishing — final text ends the turn only after a check passes.)`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export const TURN_END_GATES = [todoCompletionGate, verificationGate];
|
|
89
|
+
export function evaluateTurnEnd(finalText, ctx, gates = TURN_END_GATES) {
|
|
90
|
+
for (let i = 0; i < gates.length; i++) {
|
|
91
|
+
const gate = gates[i];
|
|
92
|
+
const decision = gate(finalText, ctx);
|
|
93
|
+
if (decision.action === "pass")
|
|
94
|
+
continue;
|
|
95
|
+
if (decision.action === "continue") {
|
|
96
|
+
// Which gate continued (the loop bounds verification nag cycles):
|
|
97
|
+
// the verification gate by name, anything else by function name.
|
|
98
|
+
const via = gate === verificationGate ? "verification" : gate.name || `gate-${i}`;
|
|
99
|
+
return { kind: "continue", assistantText: decision.assistantText, followUp: decision.followUp, via };
|
|
100
|
+
}
|
|
101
|
+
return { kind: "end", finalText: decision.finalText };
|
|
102
|
+
}
|
|
103
|
+
return { kind: "end", finalText };
|
|
104
|
+
}
|
|
105
|
+
// Task 7 verification gate: a bash command counts as a verification run
|
|
106
|
+
// when it names a common test/typecheck/build entry point. This is a word
|
|
107
|
+
// heuristic, not a parser — a miss only appends a non-blocking
|
|
108
|
+
// informational flag (never stops the turn), and the list is pinned by
|
|
109
|
+
// tests/loop-verification-gate.test.ts.
|
|
110
|
+
export function isVerificationCommand(command) {
|
|
111
|
+
return /\b(vitest|jest|mocha|pytest|typecheck|tsc|verify|check|build|tests?)\b/i.test(command);
|
|
112
|
+
}
|
|
113
|
+
// Explicit verification outcome: the bash executor's JSON envelope carries
|
|
114
|
+
// exitCode. 0 = the check passed (evidence); non-zero = it ran and FAILED
|
|
115
|
+
// (not evidence — the gate stays armed so the model sees the failure and
|
|
116
|
+
// fixes forward). Unparseable results (custom executors, test fakes
|
|
117
|
+
// returning plain strings) keep the legacy conservative behavior: a
|
|
118
|
+
// reporting runner counts as a pass. Never throws.
|
|
119
|
+
export function bashExitCode(result) {
|
|
120
|
+
try {
|
|
121
|
+
const v = JSON.parse(result);
|
|
122
|
+
if (typeof v === "object" && v !== null) {
|
|
123
|
+
const code = v["exitCode"];
|
|
124
|
+
if (typeof code === "number" && Number.isFinite(code))
|
|
125
|
+
return Math.floor(code);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// non-JSON runners keep legacy behavior (see caller)
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
// Current open todo texts (content + activeForm) via the shared getTodos
|
|
134
|
+
// read path — no duplicated state. Completed items never pin (their echoes
|
|
135
|
+
// are stale context). Never throws: on any failure there is simply nothing
|
|
136
|
+
// todo-pinned and truncation falls back to task-prompt + latest-turn pinning.
|
|
137
|
+
// Exported so manager-based trimmers (App submit) pin the same live todos.
|
|
138
|
+
export function openTodoNeedles() {
|
|
139
|
+
try {
|
|
140
|
+
const open = getTodos().filter((t) => t.status !== "completed");
|
|
141
|
+
const out = [];
|
|
142
|
+
for (const t of open) {
|
|
143
|
+
if (typeof t.content === "string" && t.content.length > 0)
|
|
144
|
+
out.push(t.content);
|
|
145
|
+
if (typeof t.activeForm === "string" && t.activeForm.length > 0)
|
|
146
|
+
out.push(t.activeForm);
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Loop-guard: repetition/runaway detection + error-streak recovery for the
|
|
2
|
+
// agentic loop. Pure state machines, no I/O, never throw.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists: maxSteps (30 tool rounds) is the ultimate backstop, but
|
|
5
|
+
// a model stuck calling `read <same path>` 30 times burns 30 POSTs before it
|
|
6
|
+
// trips. The guard spots the pattern early (consecutive identical signatures)
|
|
7
|
+
// and the loop nudges the model toward a different approach with a bounded
|
|
8
|
+
// follow-up — then stops hard if the pattern survives the nudges. Error
|
|
9
|
+
// streaks get the same treatment: ending on 3+ unaddressed `Error:` results
|
|
10
|
+
// is almost always premature, so the loop asks for a fix-forward attempt
|
|
11
|
+
// before accepting final text.
|
|
12
|
+
//
|
|
13
|
+
// Defaults preserve the pinned maxSteps contract: repetition intervention is
|
|
14
|
+
// OPT-IN (maxRepeatedCalls set by the caller; unset = track-only for stats),
|
|
15
|
+
// because the existing suites pin "always same call → 31 POSTs → stopped
|
|
16
|
+
// notice". Error-streak recovery defaults to 3 (single errors still end
|
|
17
|
+
// normally — the model may be reporting a blocker).
|
|
18
|
+
//
|
|
19
|
+
// All thresholds clamp to sane minima; every method is safe to call with any
|
|
20
|
+
// input.
|
|
21
|
+
// Tools whose identical repeats are legitimate polling, never runaway:
|
|
22
|
+
// bash_output re-polls the same taskId while a background task runs (each
|
|
23
|
+
// poll can return growing output). Excluded calls still break other tools'
|
|
24
|
+
// consecutive streaks — a poll between two identical reads means the reads
|
|
25
|
+
// were not consecutive.
|
|
26
|
+
export const POLLING_TOOLS = new Set(["bash_output"]);
|
|
27
|
+
export class RepetitionGuard {
|
|
28
|
+
maxConsecutive;
|
|
29
|
+
maxTotal;
|
|
30
|
+
maxNudges;
|
|
31
|
+
consecutiveSig = null;
|
|
32
|
+
consecutiveCount = 0;
|
|
33
|
+
totals = new Map();
|
|
34
|
+
nudges = 0;
|
|
35
|
+
excluded;
|
|
36
|
+
hits = 0;
|
|
37
|
+
constructor(opts = {}) {
|
|
38
|
+
const mc = opts.maxRepeatedCalls;
|
|
39
|
+
this.maxConsecutive =
|
|
40
|
+
typeof mc === "number" && Number.isFinite(mc) ? Math.max(2, Math.floor(mc)) : null;
|
|
41
|
+
const mt = opts.maxTotalRepeats;
|
|
42
|
+
this.maxTotal =
|
|
43
|
+
typeof mt === "number" && Number.isFinite(mt)
|
|
44
|
+
? Math.max(2, Math.floor(mt))
|
|
45
|
+
: this.maxConsecutive !== null
|
|
46
|
+
? this.maxConsecutive * 3
|
|
47
|
+
: null;
|
|
48
|
+
const mn = opts.maxNudges;
|
|
49
|
+
this.maxNudges =
|
|
50
|
+
typeof mn === "number" && Number.isFinite(mn) ? Math.max(1, Math.floor(mn)) : 2;
|
|
51
|
+
this.excluded = new Set(POLLING_TOOLS);
|
|
52
|
+
try {
|
|
53
|
+
if (opts.excludedTools) {
|
|
54
|
+
for (const t of opts.excludedTools) {
|
|
55
|
+
if (typeof t === "string" && t.length > 0)
|
|
56
|
+
this.excluded.add(t);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// custom exclusions are best-effort; defaults still apply
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
isExcluded(toolName) {
|
|
65
|
+
return typeof toolName === "string" && this.excluded.has(toolName);
|
|
66
|
+
}
|
|
67
|
+
note(signature, toolName) {
|
|
68
|
+
const sig = typeof signature === "string" ? signature : String(signature ?? "");
|
|
69
|
+
const name = typeof toolName === "string" && toolName.length > 0
|
|
70
|
+
? toolName
|
|
71
|
+
: sig.includes(" ")
|
|
72
|
+
? sig.slice(0, sig.indexOf(" "))
|
|
73
|
+
: sig;
|
|
74
|
+
// Polling tools never count: they still break other tools' streaks (a
|
|
75
|
+
// poll between two identical reads means the reads were not consecutive).
|
|
76
|
+
if (this.isExcluded(name)) {
|
|
77
|
+
this.resetStreak();
|
|
78
|
+
return { signature: sig, consecutive: 0, total: this.totals.get(sig) ?? 0, intervened: false, excluded: true };
|
|
79
|
+
}
|
|
80
|
+
const total = (this.totals.get(sig) ?? 0) + 1;
|
|
81
|
+
this.totals.set(sig, total);
|
|
82
|
+
if (this.consecutiveSig === sig)
|
|
83
|
+
this.consecutiveCount += 1;
|
|
84
|
+
else {
|
|
85
|
+
this.consecutiveSig = sig;
|
|
86
|
+
this.consecutiveCount = 1;
|
|
87
|
+
}
|
|
88
|
+
const intervened = this.shouldIntervene();
|
|
89
|
+
if (intervened)
|
|
90
|
+
this.hits += 1;
|
|
91
|
+
return { signature: sig, consecutive: this.consecutiveCount, total, intervened, excluded: false };
|
|
92
|
+
}
|
|
93
|
+
shouldIntervene() {
|
|
94
|
+
if (this.maxConsecutive === null)
|
|
95
|
+
return false;
|
|
96
|
+
if (this.consecutiveCount >= this.maxConsecutive)
|
|
97
|
+
return true;
|
|
98
|
+
if (this.maxTotal !== null && this.consecutiveSig !== null) {
|
|
99
|
+
if ((this.totals.get(this.consecutiveSig) ?? 0) >= this.maxTotal)
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
// Nudge budget: true while guidance follow-ups remain (each consumes one).
|
|
105
|
+
// When exhausted the caller stops hard — the pattern survived coaching.
|
|
106
|
+
consumeNudge() {
|
|
107
|
+
if (this.nudges >= this.maxNudges)
|
|
108
|
+
return false;
|
|
109
|
+
this.nudges += 1;
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
get nudgeCount() {
|
|
113
|
+
return this.nudges;
|
|
114
|
+
}
|
|
115
|
+
get hitCount() {
|
|
116
|
+
return this.hits;
|
|
117
|
+
}
|
|
118
|
+
resetStreak() {
|
|
119
|
+
this.consecutiveSig = null;
|
|
120
|
+
this.consecutiveCount = 0;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export function repetitionFollowUp(signature, consecutive) {
|
|
124
|
+
const short = signature.length > 160 ? `${signature.slice(0, 160)}…` : signature;
|
|
125
|
+
return (`(loop guard: the identical tool call repeated ${consecutive}× consecutively (${short}). ` +
|
|
126
|
+
`The current approach is not making progress — try a different tool, different arguments, ` +
|
|
127
|
+
`or report the blocker with its evidence instead of retrying the same call.)`);
|
|
128
|
+
}
|
|
129
|
+
export function repetitionStopNotice(signature, consecutive) {
|
|
130
|
+
const short = signature.length > 160 ? `${signature.slice(0, 160)}…` : signature;
|
|
131
|
+
return (`(stopped: identical tool call repeated ${consecutive}× (${short}) — ` +
|
|
132
|
+
`loop-guard halted the runaway instead of burning the remaining tool budget.)`);
|
|
133
|
+
}
|
|
134
|
+
// Error-streak tracker: consecutive `Error:` results. The loop asks whether
|
|
135
|
+
// final text should be accepted (streak < threshold → yes) or nudged once
|
|
136
|
+
// (streak >= threshold → continue, bounded per turn by the caller).
|
|
137
|
+
export class ErrorStreakTracker {
|
|
138
|
+
threshold;
|
|
139
|
+
streak = 0;
|
|
140
|
+
nudges = 0;
|
|
141
|
+
constructor(threshold) {
|
|
142
|
+
this.threshold =
|
|
143
|
+
typeof threshold === "number" && Number.isFinite(threshold) && threshold > 0
|
|
144
|
+
? Math.floor(threshold)
|
|
145
|
+
: threshold === 0
|
|
146
|
+
? 0
|
|
147
|
+
: 3;
|
|
148
|
+
}
|
|
149
|
+
get enabled() {
|
|
150
|
+
return this.threshold > 0;
|
|
151
|
+
}
|
|
152
|
+
noteResult(isError) {
|
|
153
|
+
if (isError)
|
|
154
|
+
this.streak += 1;
|
|
155
|
+
else
|
|
156
|
+
this.streak = 0;
|
|
157
|
+
}
|
|
158
|
+
noteResults(results) {
|
|
159
|
+
for (const e of results)
|
|
160
|
+
this.noteResult(e === true);
|
|
161
|
+
}
|
|
162
|
+
get current() {
|
|
163
|
+
return this.streak;
|
|
164
|
+
}
|
|
165
|
+
// True when final text should be held for a fix-forward attempt. Consumes
|
|
166
|
+
// one nudge per true (the caller bounds total nudges per turn).
|
|
167
|
+
shouldHoldFinal(maxNudgesPerTurn) {
|
|
168
|
+
if (!this.enabled || this.streak < this.threshold)
|
|
169
|
+
return false;
|
|
170
|
+
if (this.nudges >= maxNudgesPerTurn)
|
|
171
|
+
return false;
|
|
172
|
+
this.nudges += 1;
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
reset() {
|
|
176
|
+
this.streak = 0;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
export function errorStreakFollowUp(streak) {
|
|
180
|
+
return (`(recovery: the last ${streak} tool result(s) were errors and the turn tried to end. ` +
|
|
181
|
+
`Do not end on unaddressed failures — read the error text, fix the arguments or replan ` +
|
|
182
|
+
`around the failure, and continue with tool calls. If it cannot be fixed, end by naming ` +
|
|
183
|
+
`the blocker with its evidence.)`);
|
|
184
|
+
}
|