atom-agent 1.0.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/dist/adapters.js CHANGED
@@ -262,6 +262,58 @@ export function buildGeminiBody(history, _model, opts) {
262
262
  }
263
263
  return body;
264
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
+ }
265
317
  async function collectSSEText(res) {
266
318
  const body = res.body;
267
319
  const decoder = new TextDecoder();
@@ -275,9 +327,20 @@ async function collectSSEText(res) {
275
327
  for (;;) {
276
328
  let chunk;
277
329
  try {
278
- chunk = await reader.read();
330
+ chunk = await readWithStall(() => reader.read());
279
331
  }
280
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
+ }
281
344
  throw new Error(`Truncated stream from model (connection aborted: ${e instanceof Error ? e.message : String(e)}).`);
282
345
  }
283
346
  if (chunk.done)
@@ -299,11 +362,26 @@ async function collectSSEText(res) {
299
362
  }
300
363
  }
301
364
  else if (typeof body[Symbol.asyncIterator] === "function") {
302
- for await (const v of body) {
303
- rawText +=
304
- typeof v === "string"
305
- ? v
306
- : 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
+ }
307
385
  }
308
386
  }
309
387
  else {
@@ -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
+ }