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.
Files changed (61) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +83 -32
  3. package/dist/App.js +2178 -318
  4. package/dist/adapters.js +146 -15
  5. package/dist/agent/gates.js +153 -0
  6. package/dist/agent/loop-guard.js +184 -0
  7. package/dist/agent/loop.js +908 -0
  8. package/dist/agent/normalize.js +144 -0
  9. package/dist/agent/types.js +1 -0
  10. package/dist/auth.js +2 -1
  11. package/dist/cli.js +68 -6
  12. package/dist/compact.js +6 -48
  13. package/dist/config.js +171 -0
  14. package/dist/context-manager.js +564 -0
  15. package/dist/kilo.js +343 -0
  16. package/dist/local-discovery.js +308 -0
  17. package/dist/policy.js +286 -0
  18. package/dist/prompt-cache.js +99 -0
  19. package/dist/providers.js +183 -2
  20. package/dist/rollback.js +21 -0
  21. package/dist/scheduler.js +247 -0
  22. package/dist/session.js +35 -3
  23. package/dist/skills.js +214 -43
  24. package/dist/snapshots.js +57 -2
  25. package/dist/system.js +8 -1
  26. package/dist/telemetry-dashboard.js +589 -0
  27. package/dist/telemetry-server.js +301 -0
  28. package/dist/telemetry.js +1056 -0
  29. package/dist/tools/dir-cache.js +207 -0
  30. package/dist/tools/filesystem.js +149 -0
  31. package/dist/tools/fingerprints.js +33 -0
  32. package/dist/tools/overflow.js +76 -0
  33. package/dist/tools/read-cache.js +160 -0
  34. package/dist/tools/registry.js +802 -0
  35. package/dist/tools/search.js +242 -0
  36. package/dist/tools/shared.js +31 -0
  37. package/dist/tools/shell.js +273 -0
  38. package/dist/tools/todo.js +191 -0
  39. package/dist/tools/web.js +454 -0
  40. package/dist/tools.js +17 -1863
  41. package/dist/ui/activity.js +51 -0
  42. package/dist/ui/diff-panel.js +55 -0
  43. package/dist/ui/diff-view.js +112 -0
  44. package/dist/ui/diff.js +422 -0
  45. package/dist/ui/errors.js +129 -0
  46. package/dist/ui/highlight.js +120 -0
  47. package/dist/ui/input-model.js +115 -0
  48. package/dist/ui/input.js +40 -0
  49. package/dist/ui/live-tail.js +15 -0
  50. package/dist/ui/markdown.js +525 -0
  51. package/dist/ui/modals.js +47 -0
  52. package/dist/ui/palette.js +70 -0
  53. package/dist/ui/pickers.js +32 -0
  54. package/dist/ui/side-by-side.js +144 -0
  55. package/dist/ui/status-bar.js +75 -0
  56. package/dist/ui/theme.js +128 -0
  57. package/dist/ui/todo-panel.js +30 -0
  58. package/dist/ui/tool-inspector.js +59 -0
  59. package/dist/ui/transcript.js +128 -0
  60. package/dist/zen.js +145 -666
  61. 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
- body.tools = toolDefs().map((t) => ({
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
- body.system_instruction = { parts: [{ text: systems.join("\n\n") }] };
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
- for await (const v of body) {
271
- rawText +=
272
- typeof v === "string"
273
- ? v
274
- : decoder.decode(v, { stream: true });
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
+ }