atom-agent 1.0.0 → 1.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 +62 -2
- package/README.md +17 -16
- package/dist/App.js +1010 -77
- package/dist/adapters.js +108 -8
- package/dist/agent/gates.js +14 -1
- package/dist/agent/loop-guard.js +182 -0
- package/dist/agent/loop.js +781 -329
- package/dist/agent/normalize.js +151 -0
- package/dist/cli.js +16 -2
- package/dist/compact.js +128 -2
- package/dist/env-block.js +43 -5
- package/dist/scheduler.js +101 -21
- package/dist/sessions.js +524 -0
- package/dist/system.js +89 -12
- package/dist/telemetry-dashboard.js +19 -1
- package/dist/telemetry.js +55 -0
- package/dist/tools/dir-cache.js +214 -0
- package/dist/tools/filesystem.js +43 -3
- package/dist/tools/read-cache.js +160 -0
- package/dist/tools/registry.js +80 -0
- package/dist/tools/ripgrep.js +256 -0
- package/dist/tools/search.js +147 -80
- package/dist/tools/shared.js +39 -0
- package/dist/tools/shell.js +26 -5
- package/dist/tools/todo.js +1 -1
- package/dist/tools/web.js +6 -6
- package/dist/tools.js +3 -0
- package/dist/ui/diff-panel.js +55 -0
- package/dist/ui/diff-view.js +117 -0
- package/dist/ui/diff.js +422 -0
- package/dist/ui/highlight.js +120 -0
- package/dist/ui/live-host.js +18 -0
- package/dist/ui/live-tail.js +9 -3
- package/dist/ui/markdown.js +26 -2
- package/dist/ui/modals.js +22 -5
- package/dist/ui/palette.js +12 -2
- package/dist/ui/side-by-side.js +144 -0
- package/dist/ui/status-bar.js +20 -4
- package/dist/ui/status-host.js +22 -0
- package/dist/ui/stream-store.js +48 -0
- package/dist/ui/theme.js +6 -0
- package/dist/ui/todo-panel.js +10 -2
- package/dist/ui/tool-inspector.js +7 -1
- package/dist/ui/transcript.js +105 -39
- package/dist/zen.js +97 -20
- package/package.json +1 -1
package/dist/adapters.js
CHANGED
|
@@ -262,10 +262,80 @@ 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();
|
|
268
320
|
let rawText = "";
|
|
321
|
+
// Data-silence tracking (mirrors zen.readSSEMessage): queue comments and
|
|
322
|
+
// keep-alives carry bytes but no model output, so only chunks containing a
|
|
323
|
+
// `data:` line start refresh the clock. The tail window catches a marker
|
|
324
|
+
// split across chunk boundaries.
|
|
325
|
+
let lastDataAt = Date.now();
|
|
326
|
+
let tail = "";
|
|
327
|
+
const noteChunk = (chunkText) => {
|
|
328
|
+
const joined = tail + chunkText;
|
|
329
|
+
if (/(?:^|\n)data:/.test(joined))
|
|
330
|
+
lastDataAt = Date.now();
|
|
331
|
+
tail = joined.slice(-8);
|
|
332
|
+
};
|
|
333
|
+
const throwIfDataStalled = () => {
|
|
334
|
+
const budget = sseStallTimeoutMs();
|
|
335
|
+
if (Date.now() - lastDataAt > budget) {
|
|
336
|
+
throw new Error(`Truncated stream from model (stall: no output for ${budget}ms — queued or stalled upstream; resend to retry).`);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
269
339
|
if (body == null)
|
|
270
340
|
return { rawText, events: [] };
|
|
271
341
|
try {
|
|
@@ -275,18 +345,31 @@ async function collectSSEText(res) {
|
|
|
275
345
|
for (;;) {
|
|
276
346
|
let chunk;
|
|
277
347
|
try {
|
|
278
|
-
chunk = await reader.read();
|
|
348
|
+
chunk = await readWithStall(() => reader.read());
|
|
279
349
|
}
|
|
280
350
|
catch (e) {
|
|
351
|
+
if (isStallError(e)) {
|
|
352
|
+
// Free the dead socket on the way out, then surface the stall
|
|
353
|
+
// unchanged (permanent Truncated contract — never retried).
|
|
354
|
+
try {
|
|
355
|
+
await reader.cancel?.();
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
// ignore cancel errors
|
|
359
|
+
}
|
|
360
|
+
throw e;
|
|
361
|
+
}
|
|
281
362
|
throw new Error(`Truncated stream from model (connection aborted: ${e instanceof Error ? e.message : String(e)}).`);
|
|
282
363
|
}
|
|
283
364
|
if (chunk.done)
|
|
284
365
|
break;
|
|
285
366
|
const v = chunk.value;
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
367
|
+
const textPart = typeof v === "string"
|
|
368
|
+
? v
|
|
369
|
+
: decoder.decode(v, { stream: true });
|
|
370
|
+
rawText += textPart;
|
|
371
|
+
noteChunk(textPart);
|
|
372
|
+
throwIfDataStalled();
|
|
290
373
|
}
|
|
291
374
|
}
|
|
292
375
|
finally {
|
|
@@ -299,11 +382,28 @@ async function collectSSEText(res) {
|
|
|
299
382
|
}
|
|
300
383
|
}
|
|
301
384
|
else if (typeof body[Symbol.asyncIterator] === "function") {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
385
|
+
const it = body[Symbol.asyncIterator]();
|
|
386
|
+
try {
|
|
387
|
+
for (;;) {
|
|
388
|
+
const step = await readWithStall(() => it.next());
|
|
389
|
+
if (step.done)
|
|
390
|
+
break;
|
|
391
|
+
const v = step.value;
|
|
392
|
+
const textPart = typeof v === "string"
|
|
305
393
|
? v
|
|
306
394
|
: decoder.decode(v, { stream: true });
|
|
395
|
+
rawText += textPart;
|
|
396
|
+
noteChunk(textPart);
|
|
397
|
+
throwIfDataStalled();
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
finally {
|
|
401
|
+
try {
|
|
402
|
+
await it.return?.();
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
// ignore — the stream is over either way
|
|
406
|
+
}
|
|
307
407
|
}
|
|
308
408
|
}
|
|
309
409
|
else {
|
package/dist/agent/gates.js
CHANGED
|
@@ -19,6 +19,15 @@ export function todoCompletionGate(finalText, ctx) {
|
|
|
19
19
|
finalText: `${finalText}${finalText ? "\n" : ""}(blocked: ${open.length} open todo(s) — resolve with todo_update/todowrite before ending the turn:\n${items})`,
|
|
20
20
|
};
|
|
21
21
|
}
|
|
22
|
+
// Guard cycles are bounded (like the verification gate): a model that
|
|
23
|
+
// keeps answering without resolving todos ends with a blocked statement
|
|
24
|
+
// instead of looping forever. Normal flows resolve within a round or two.
|
|
25
|
+
if ((ctx.todoRounds ?? 0) >= MAX_TODO_ROUNDS) {
|
|
26
|
+
return {
|
|
27
|
+
action: "end",
|
|
28
|
+
finalText: `${finalText}${finalText ? "\n" : ""}(blocked: ${open.length} open todo(s) remain after ${MAX_TODO_ROUNDS} guard rounds — resolve with todo_update/todowrite before ending the turn:\n${items})`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
22
31
|
return {
|
|
23
32
|
action: "continue",
|
|
24
33
|
assistantText: finalText,
|
|
@@ -30,11 +39,15 @@ export function todoCompletionGate(finalText, ctx) {
|
|
|
30
39
|
// report — the system prompt forbids unverified finishes, so the runtime
|
|
31
40
|
// must not terminate while just labeling): the attempt is recorded and a
|
|
32
41
|
// verification follow-up re-enters the loop, exactly like the todo guard.
|
|
33
|
-
// Two bounded exits:
|
|
42
|
+
// Two bounded exits: an explicit step budget spent, or MAX_VERIFY_ROUNDS nag
|
|
34
43
|
// without a passing run — both end with an explicit labeled statement naming
|
|
35
44
|
// what is unverified and why the loop stopped. Turns with no code writes
|
|
36
45
|
// (questions, docs, explanations, read-only work) are unaffected.
|
|
37
46
|
export const MAX_VERIFY_ROUNDS = 3;
|
|
47
|
+
// Todo-guard continues before the turn ends blocked: a model that keeps
|
|
48
|
+
// answering final text without resolving open todos is sent back at most
|
|
49
|
+
// this many times. Mirrors MAX_VERIFY_ROUNDS so no gate can spin forever.
|
|
50
|
+
export const MAX_TODO_ROUNDS = 3;
|
|
38
51
|
// Source-code extensions whose writes require a passing verification run.
|
|
39
52
|
// Curated heuristic boundary (not a parser): docs, configs, data, and
|
|
40
53
|
// extensionless files never arm the gate, so a README edit finishes clean.
|
|
@@ -0,0 +1,182 @@
|
|
|
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: turns are uncapped by default, so a model stuck calling
|
|
5
|
+
// `read <same path>` forever burns POSTs without end. The guard spots the
|
|
6
|
+
// pattern early (consecutive identical signatures) and the loop nudges the
|
|
7
|
+
// model toward a different approach with a bounded follow-up — then stops
|
|
8
|
+
// hard if the pattern survives the nudges. Error streaks get the same
|
|
9
|
+
// treatment: ending on 3+ unaddressed `Error:` results is almost always
|
|
10
|
+
// premature, so the loop asks for a fix-forward attempt before accepting
|
|
11
|
+
// final text.
|
|
12
|
+
//
|
|
13
|
+
// Repetition intervention is OPT-IN (maxRepeatedCalls set by the caller;
|
|
14
|
+
// unset = track-only for stats). Error-streak recovery defaults to 3
|
|
15
|
+
// (single errors still end normally — the model may be reporting a blocker).
|
|
16
|
+
//
|
|
17
|
+
// All thresholds clamp to sane minima; every method is safe to call with any
|
|
18
|
+
// input.
|
|
19
|
+
// Tools whose identical repeats are legitimate polling, never runaway:
|
|
20
|
+
// bash_output re-polls the same taskId while a background task runs (each
|
|
21
|
+
// poll can return growing output). Excluded calls still break other tools'
|
|
22
|
+
// consecutive streaks — a poll between two identical reads means the reads
|
|
23
|
+
// were not consecutive.
|
|
24
|
+
export const POLLING_TOOLS = new Set(["bash_output"]);
|
|
25
|
+
export class RepetitionGuard {
|
|
26
|
+
maxConsecutive;
|
|
27
|
+
maxTotal;
|
|
28
|
+
maxNudges;
|
|
29
|
+
consecutiveSig = null;
|
|
30
|
+
consecutiveCount = 0;
|
|
31
|
+
totals = new Map();
|
|
32
|
+
nudges = 0;
|
|
33
|
+
excluded;
|
|
34
|
+
hits = 0;
|
|
35
|
+
constructor(opts = {}) {
|
|
36
|
+
const mc = opts.maxRepeatedCalls;
|
|
37
|
+
this.maxConsecutive =
|
|
38
|
+
typeof mc === "number" && Number.isFinite(mc) ? Math.max(2, Math.floor(mc)) : null;
|
|
39
|
+
const mt = opts.maxTotalRepeats;
|
|
40
|
+
this.maxTotal =
|
|
41
|
+
typeof mt === "number" && Number.isFinite(mt)
|
|
42
|
+
? Math.max(2, Math.floor(mt))
|
|
43
|
+
: this.maxConsecutive !== null
|
|
44
|
+
? this.maxConsecutive * 3
|
|
45
|
+
: null;
|
|
46
|
+
const mn = opts.maxNudges;
|
|
47
|
+
this.maxNudges =
|
|
48
|
+
typeof mn === "number" && Number.isFinite(mn) ? Math.max(1, Math.floor(mn)) : 2;
|
|
49
|
+
this.excluded = new Set(POLLING_TOOLS);
|
|
50
|
+
try {
|
|
51
|
+
if (opts.excludedTools) {
|
|
52
|
+
for (const t of opts.excludedTools) {
|
|
53
|
+
if (typeof t === "string" && t.length > 0)
|
|
54
|
+
this.excluded.add(t);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// custom exclusions are best-effort; defaults still apply
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
isExcluded(toolName) {
|
|
63
|
+
return typeof toolName === "string" && this.excluded.has(toolName);
|
|
64
|
+
}
|
|
65
|
+
note(signature, toolName) {
|
|
66
|
+
const sig = typeof signature === "string" ? signature : String(signature ?? "");
|
|
67
|
+
const name = typeof toolName === "string" && toolName.length > 0
|
|
68
|
+
? toolName
|
|
69
|
+
: sig.includes(" ")
|
|
70
|
+
? sig.slice(0, sig.indexOf(" "))
|
|
71
|
+
: sig;
|
|
72
|
+
// Polling tools never count: they still break other tools' streaks (a
|
|
73
|
+
// poll between two identical reads means the reads were not consecutive).
|
|
74
|
+
if (this.isExcluded(name)) {
|
|
75
|
+
this.resetStreak();
|
|
76
|
+
return { signature: sig, consecutive: 0, total: this.totals.get(sig) ?? 0, intervened: false, excluded: true };
|
|
77
|
+
}
|
|
78
|
+
const total = (this.totals.get(sig) ?? 0) + 1;
|
|
79
|
+
this.totals.set(sig, total);
|
|
80
|
+
if (this.consecutiveSig === sig)
|
|
81
|
+
this.consecutiveCount += 1;
|
|
82
|
+
else {
|
|
83
|
+
this.consecutiveSig = sig;
|
|
84
|
+
this.consecutiveCount = 1;
|
|
85
|
+
}
|
|
86
|
+
const intervened = this.shouldIntervene();
|
|
87
|
+
if (intervened)
|
|
88
|
+
this.hits += 1;
|
|
89
|
+
return { signature: sig, consecutive: this.consecutiveCount, total, intervened, excluded: false };
|
|
90
|
+
}
|
|
91
|
+
shouldIntervene() {
|
|
92
|
+
if (this.maxConsecutive === null)
|
|
93
|
+
return false;
|
|
94
|
+
if (this.consecutiveCount >= this.maxConsecutive)
|
|
95
|
+
return true;
|
|
96
|
+
if (this.maxTotal !== null && this.consecutiveSig !== null) {
|
|
97
|
+
if ((this.totals.get(this.consecutiveSig) ?? 0) >= this.maxTotal)
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
// Nudge budget: true while guidance follow-ups remain (each consumes one).
|
|
103
|
+
// When exhausted the caller stops hard — the pattern survived coaching.
|
|
104
|
+
consumeNudge() {
|
|
105
|
+
if (this.nudges >= this.maxNudges)
|
|
106
|
+
return false;
|
|
107
|
+
this.nudges += 1;
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
get nudgeCount() {
|
|
111
|
+
return this.nudges;
|
|
112
|
+
}
|
|
113
|
+
get hitCount() {
|
|
114
|
+
return this.hits;
|
|
115
|
+
}
|
|
116
|
+
resetStreak() {
|
|
117
|
+
this.consecutiveSig = null;
|
|
118
|
+
this.consecutiveCount = 0;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export function repetitionFollowUp(signature, consecutive) {
|
|
122
|
+
const short = signature.length > 160 ? `${signature.slice(0, 160)}…` : signature;
|
|
123
|
+
return (`(loop guard: the identical tool call repeated ${consecutive}× consecutively (${short}). ` +
|
|
124
|
+
`The current approach is not making progress — try a different tool, different arguments, ` +
|
|
125
|
+
`or report the blocker with its evidence instead of retrying the same call.)`);
|
|
126
|
+
}
|
|
127
|
+
export function repetitionStopNotice(signature, consecutive) {
|
|
128
|
+
const short = signature.length > 160 ? `${signature.slice(0, 160)}…` : signature;
|
|
129
|
+
return (`(stopped: identical tool call repeated ${consecutive}× (${short}) — ` +
|
|
130
|
+
`loop-guard halted the runaway instead of burning the remaining tool budget.)`);
|
|
131
|
+
}
|
|
132
|
+
// Error-streak tracker: consecutive `Error:` results. The loop asks whether
|
|
133
|
+
// final text should be accepted (streak < threshold → yes) or nudged once
|
|
134
|
+
// (streak >= threshold → continue, bounded per turn by the caller).
|
|
135
|
+
export class ErrorStreakTracker {
|
|
136
|
+
threshold;
|
|
137
|
+
streak = 0;
|
|
138
|
+
nudges = 0;
|
|
139
|
+
constructor(threshold) {
|
|
140
|
+
this.threshold =
|
|
141
|
+
typeof threshold === "number" && Number.isFinite(threshold) && threshold > 0
|
|
142
|
+
? Math.floor(threshold)
|
|
143
|
+
: threshold === 0
|
|
144
|
+
? 0
|
|
145
|
+
: 3;
|
|
146
|
+
}
|
|
147
|
+
get enabled() {
|
|
148
|
+
return this.threshold > 0;
|
|
149
|
+
}
|
|
150
|
+
noteResult(isError) {
|
|
151
|
+
if (isError)
|
|
152
|
+
this.streak += 1;
|
|
153
|
+
else
|
|
154
|
+
this.streak = 0;
|
|
155
|
+
}
|
|
156
|
+
noteResults(results) {
|
|
157
|
+
for (const e of results)
|
|
158
|
+
this.noteResult(e === true);
|
|
159
|
+
}
|
|
160
|
+
get current() {
|
|
161
|
+
return this.streak;
|
|
162
|
+
}
|
|
163
|
+
// True when final text should be held for a fix-forward attempt. Consumes
|
|
164
|
+
// one nudge per true (the caller bounds total nudges per turn).
|
|
165
|
+
shouldHoldFinal(maxNudgesPerTurn) {
|
|
166
|
+
if (!this.enabled || this.streak < this.threshold)
|
|
167
|
+
return false;
|
|
168
|
+
if (this.nudges >= maxNudgesPerTurn)
|
|
169
|
+
return false;
|
|
170
|
+
this.nudges += 1;
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
reset() {
|
|
174
|
+
this.streak = 0;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
export function errorStreakFollowUp(streak) {
|
|
178
|
+
return (`(recovery: the last ${streak} tool result(s) were errors and the turn tried to end. ` +
|
|
179
|
+
`Do not end on unaddressed failures — read the error text, fix the arguments or replan ` +
|
|
180
|
+
`around the failure, and continue with tool calls. If it cannot be fixed, end by naming ` +
|
|
181
|
+
`the blocker with its evidence.)`);
|
|
182
|
+
}
|