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.
Files changed (46) hide show
  1. package/CHANGELOG.md +62 -2
  2. package/README.md +17 -16
  3. package/dist/App.js +1010 -77
  4. package/dist/adapters.js +108 -8
  5. package/dist/agent/gates.js +14 -1
  6. package/dist/agent/loop-guard.js +182 -0
  7. package/dist/agent/loop.js +781 -329
  8. package/dist/agent/normalize.js +151 -0
  9. package/dist/cli.js +16 -2
  10. package/dist/compact.js +128 -2
  11. package/dist/env-block.js +43 -5
  12. package/dist/scheduler.js +101 -21
  13. package/dist/sessions.js +524 -0
  14. package/dist/system.js +89 -12
  15. package/dist/telemetry-dashboard.js +19 -1
  16. package/dist/telemetry.js +55 -0
  17. package/dist/tools/dir-cache.js +214 -0
  18. package/dist/tools/filesystem.js +43 -3
  19. package/dist/tools/read-cache.js +160 -0
  20. package/dist/tools/registry.js +80 -0
  21. package/dist/tools/ripgrep.js +256 -0
  22. package/dist/tools/search.js +147 -80
  23. package/dist/tools/shared.js +39 -0
  24. package/dist/tools/shell.js +26 -5
  25. package/dist/tools/todo.js +1 -1
  26. package/dist/tools/web.js +6 -6
  27. package/dist/tools.js +3 -0
  28. package/dist/ui/diff-panel.js +55 -0
  29. package/dist/ui/diff-view.js +117 -0
  30. package/dist/ui/diff.js +422 -0
  31. package/dist/ui/highlight.js +120 -0
  32. package/dist/ui/live-host.js +18 -0
  33. package/dist/ui/live-tail.js +9 -3
  34. package/dist/ui/markdown.js +26 -2
  35. package/dist/ui/modals.js +22 -5
  36. package/dist/ui/palette.js +12 -2
  37. package/dist/ui/side-by-side.js +144 -0
  38. package/dist/ui/status-bar.js +20 -4
  39. package/dist/ui/status-host.js +22 -0
  40. package/dist/ui/stream-store.js +48 -0
  41. package/dist/ui/theme.js +6 -0
  42. package/dist/ui/todo-panel.js +10 -2
  43. package/dist/ui/tool-inspector.js +7 -1
  44. package/dist/ui/transcript.js +105 -39
  45. package/dist/zen.js +97 -20
  46. package/package.json +1 -1
@@ -1,38 +1,37 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  // Transcript leaves: the committed <Static> scrollback, its item renderer,
3
3
  // and the startup banner. Prop-driven + memoized (see comments) so App state
4
4
  // churn never repaints them. Turn is the display-transcript entry shape.
5
5
  // All paint comes from ui/theme tokens — no literal colors or glyphs here.
6
6
  import React from "react";
7
- import { Box, Text } from "ink";
7
+ import { Box, Static, Text } from "ink";
8
+ import { SideBySideDiffView, TRANSCRIPT_DIFF_MAX_LINES } from "./side-by-side.js";
8
9
  import { ErrorCard, classifyToolError } from "./errors.js";
9
10
  import { MarkdownText, ToolLine } from "./markdown.js";
10
11
  import { theme } from "./theme.js";
11
- // Scrollback viewport: the committed transcript renders as a windowed
12
- // slice of turns in a live Box (NOT <Static> — Static is append-only with
13
- // no scroll API, so PgUp/Home/follow modes are impossible on it).
12
+ // Commit frontier model: the committed transcript prints to terminal
13
+ // scrollback ONCE via <Static> and is never rewritten (this is what keeps
14
+ // a full-page transcript from flashing on every keystroke Ink takes a
15
+ // clearTerminal + full-reprint path for fullscreen dynamic frames).
14
16
  //
15
- // Model: E = viewed end index (items visible: (E-WIN, E]). E === turns.length
16
- // means follow mode — new turns extend the view automatically. Any E < len
17
- // is manual mode: the view freezes while new turns accumulate below, and a
18
- // `↓ N new` indicator offers the jump back. Clamping makes list replacement
19
- // (/clear, /resume, /new) re-follow for free (E > len collapses to len).
20
- // Banner shows only when the window touches the top.
17
+ // Model: E = committed end index (null = follow: commit everything). Any
18
+ // E < len is manual mode — new turns accumulate below the frontier and a
19
+ // `↓ N new` indicator offers the jump back. Printed output can never
20
+ // retract, so E below the committed count holds back future commits only;
21
+ // deep history lives in terminal scrollback. Banner shows on fresh mounts.
22
+ // List replacement (/clear, /resume, /new, /rewind) bumps clearGen, which
23
+ // resets the Static buffer via the identity below.
21
24
  export const SCROLLBACK_WINDOW = 300;
22
25
  export const SCROLL_PAGE_ITEMS = 10;
23
- export function resolveViewport(len, end, win = SCROLLBACK_WINDOW) {
24
- const e = Math.max(0, Math.min(end ?? len, len));
25
- const follow = e >= len;
26
- return { start: Math.max(0, e - win), end: e, pending: len - e, follow };
27
- }
28
26
  export function applyScrollAction(end, len, action) {
29
27
  const e = end ?? len;
30
28
  switch (action.kind) {
31
29
  case "pageUp":
32
- // Short sessions (everything fits the window) have no window to move:
33
- // freeze at the bottom instead of no-op-ing, so PgUp always engages
34
- // the held view (live output stops growing; the terminal stops
35
- // yanking). Long sessions move the window up a page, as before.
30
+ // Freeze the commit frontier instead of no-op-ing, so PgUp always
31
+ // engages the held view (new output stops printing below; the live
32
+ // tail stops growing; the terminal stops yanking). Values below the
33
+ // already-committed count hold back future commits only printed
34
+ // output lives in terminal scrollback and can never retract.
36
35
  if (len <= SCROLLBACK_WINDOW)
37
36
  return len;
38
37
  return Math.max(Math.min(len, SCROLLBACK_WINDOW), e - SCROLL_PAGE_ITEMS);
@@ -54,6 +53,11 @@ export function renderTranscriptItem(item) {
54
53
  return _jsx(StartupBanner, {}, item.id);
55
54
  const t = item.turn;
56
55
  const i = item.id;
56
+ // Committed thinking blocks read as quiet annotations (never confused
57
+ // with answers): dim label plus the raw reasoning text, verbatim.
58
+ if (t.thinking === true) {
59
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [theme.symbol.thinking, " thinking"] }), _jsx(Text, { dimColor: true, children: t.content })] }, i));
60
+ }
57
61
  // Conversation turns (user/assistant) breathe: one blank line after each,
58
62
  // so the eye lands on the next turn. Tool/status lines stay dense — they
59
63
  // read as lightweight annotations woven between turns, not blocks.
@@ -65,37 +69,99 @@ export function renderTranscriptItem(item) {
65
69
  if (classified) {
66
70
  // Paired cards keep the verbatim audit line above the card (pinned
67
71
  // `⚙ name target` text for tests/scanning) and name the failure in
68
- // the card title. Lone details render the card alone.
69
- return (_jsxs(React.Fragment, { children: [item.label ? _jsx(ToolLine, { content: item.label.content, ms: item.label.ms }) : null, _jsx(ErrorCard, { classified: classified })] }, i));
72
+ // the card title. Lone details render the card alone. A successful
73
+ // write/edit label swallowed by pairing (success line immediately
74
+ // followed by an error line) keeps its committed diff above the card.
75
+ const labelDiff = item.label?.diff;
76
+ return (_jsxs(React.Fragment, { children: [item.label ? _jsx(ToolLine, { content: item.label.content, ms: item.label.ms }) : null, labelDiff && !item.label?.error ? (_jsx(SideBySideDiffView, { oldText: labelDiff.oldText, newText: labelDiff.newText, lang: labelDiff.lang, maxRows: TRANSCRIPT_DIFF_MAX_LINES })) : null, _jsx(ErrorCard, { classified: classified })] }, i));
70
77
  }
71
- return _jsx(ToolLine, { content: t.content, error: t.error, ms: t.ms }, i);
78
+ return (_jsxs(React.Fragment, { children: [_jsx(ToolLine, { content: t.content, error: t.error, ms: t.ms }), t.diff && !t.error ? (_jsx(SideBySideDiffView, { oldText: t.diff.oldText, newText: t.diff.newText, lang: t.diff.lang, maxRows: TRANSCRIPT_DIFF_MAX_LINES })) : null] }, i));
72
79
  }
73
80
  return (_jsxs(Box, { flexDirection: "column", marginBottom: theme.spacing.turnGap, children: [_jsx(Text, { children: _jsxs(Text, { color: theme.color.assistant, bold: true, children: [theme.symbol.speakerAssistant, " "] }) }), _jsx(MarkdownText, { text: t.content })] }, i));
74
81
  }
75
82
  // Render-count probe for the timer-isolation test: incremented on every
76
83
  // TranscriptView render (a 1s timer tick must leave it unchanged).
77
84
  export const transcriptRenderProbe = { count: 0 };
78
- export const TranscriptView = React.memo(function TranscriptView({ turns, clearGen, renderItem, end, windowSize, held, }) {
79
- transcriptRenderProbe.count += 1;
80
- const render = renderItem ?? renderTranscriptItem;
81
- const win = windowSize ?? SCROLLBACK_WINDOW;
82
- const vp = resolveViewport(turns.length, end, win);
83
- // Pairing ([audit label, error detail] one card) runs over the VISIBLE
84
- // slice only pairing is positional, and off-window turns never mount.
85
- // Keys stay global (`turn-${idx}`) so scrolling never remounts rows.
86
- const body = [];
87
- for (let idx = vp.start; idx < vp.end; idx++) {
85
+ // Render-count probe for row isolation: incremented per mounted row paint
86
+ // (appending one turn must paint exactly one new row, never the window).
87
+ export const transcriptRowRenderProbe = { count: 0 };
88
+ // Committed turns are immutable once appended (diffs attach pre-commit in
89
+ // onToolActivity, never post-append), and keys stay global (`turn-${idx}`),
90
+ // so a row whose item identity is unchanged can skip rendering entirely.
91
+ // Custom compare: the body array is rebuilt per TranscriptView render with
92
+ // fresh wrappers around the SAME turn refs shallow compare would always
93
+ // miss, hence id + turn/label identity. An unstable render fn falls back to
94
+ // today's behavior (re-render) rather than going stale.
95
+ function transcriptRowEqual(a, b) {
96
+ return (a.render === b.render &&
97
+ a.item.id === b.item.id &&
98
+ a.item.turn === b.item.turn &&
99
+ a.item.label === b.item.label);
100
+ }
101
+ const TranscriptRow = React.memo(function TranscriptRow({ item, render }) {
102
+ transcriptRowRenderProbe.count += 1;
103
+ return _jsx(React.Fragment, { children: render(item) });
104
+ }, transcriptRowEqual);
105
+ // Monotonic static admission: convert record turns [from, to) into Static
106
+ // items, pairing adjacent [audit label, error detail] within the batch and
107
+ // permanently skipping hidden thinking turns. ALWAYS returns next ===
108
+ // clamped `to` (even when everything skips) so the frontier only moves
109
+ // forward — shrinking or reordering same-identity items would misalign
110
+ // Ink's append-only Static buffer and duplicate terminal scrollback.
111
+ // List replacements (/clear, /resume, /rewind) bump clearGen instead, which
112
+ // resets the buffer via the Static identity below.
113
+ export function admitStaticBatch(turns, from, to, showThinking) {
114
+ const end = Math.max(from, Math.min(to, turns.length));
115
+ const items = [];
116
+ let idx = from;
117
+ while (idx < end) {
88
118
  const turn = turns[idx];
89
- const next = idx + 1 < vp.end ? turns[idx + 1] : undefined;
90
- if (isAuditLabel(turn) && next !== undefined && next.role === "tool" && next.error === true) {
91
- body.push({ id: `turn-${idx}`, turn: next, label: turn });
119
+ if (turn.thinking === true && !showThinking) {
92
120
  idx += 1;
93
121
  continue;
94
122
  }
95
- body.push({ id: `turn-${idx}`, turn });
123
+ const next = idx + 1 < end ? turns[idx + 1] : undefined;
124
+ if (isAuditLabel(turn) && next !== undefined && next.role === "tool" && next.error === true) {
125
+ items.push({ id: `turn-${idx}`, turn: next, label: turn });
126
+ idx += 2;
127
+ continue;
128
+ }
129
+ items.push({ id: `turn-${idx}`, turn });
130
+ idx += 1;
131
+ }
132
+ return { items, next: end };
133
+ }
134
+ export const TranscriptView = React.memo(function TranscriptView({ turns, clearGen, renderItem, end, held, showThinking = true, }) {
135
+ transcriptRenderProbe.count += 1;
136
+ const render = renderItem ?? renderTranscriptItem;
137
+ const frontier = end ?? turns.length;
138
+ // Committed static state: full reset on clearGen (list replacements bump
139
+ // it — replacements must never reuse the buffer), suffix-only advance
140
+ // otherwise (setState-during-render derived-state pattern; the extra pass
141
+ // runs only when genuinely new items commit, never on ticks/keystrokes).
142
+ const [committed, setCommitted] = React.useState(() => {
143
+ const base = clearGen === 0 ? [{ id: "banner" }] : [];
144
+ const batch = admitStaticBatch(turns, 0, frontier, showThinking);
145
+ return { gen: clearGen, items: [...base, ...batch.items], next: batch.next };
146
+ });
147
+ if (committed.gen !== clearGen) {
148
+ const base = clearGen === 0 ? [{ id: "banner" }] : [];
149
+ const batch = admitStaticBatch(turns, 0, end ?? turns.length, showThinking);
150
+ setCommitted({ gen: clearGen, items: [...base, ...batch.items], next: batch.next });
151
+ }
152
+ else {
153
+ const batch = admitStaticBatch(turns, committed.next, frontier, showThinking);
154
+ if (batch.items.length > 0 || batch.next !== committed.next) {
155
+ setCommitted({
156
+ gen: clearGen,
157
+ items: [...committed.items, ...batch.items],
158
+ next: batch.next,
159
+ });
160
+ }
96
161
  }
97
- const items = clearGen === 0 && vp.start === 0 ? [{ id: "banner" }, ...body] : body;
98
- return (_jsxs(Box, { flexDirection: "column", children: [items.map((item) => (_jsx(React.Fragment, { children: render(item) }, item.id))), vp.pending > 0 ? (_jsxs(Text, { dimColor: true, children: ["\u2193 ", vp.pending, " new \u2014 End for latest"] })) : held ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, " held \u2014 End to follow"] })) : null] }));
162
+ // Backlog below the committed frontier (frozen appends, not yet printed).
163
+ const pending = turns.length - committed.next;
164
+ return (_jsxs(_Fragment, { children: [_jsx(Static, { items: committed.items, children: (item) => _jsx(TranscriptRow, { item: item, render: render }, item.id) }, clearGen), pending > 0 ? (_jsxs(Text, { dimColor: true, children: ["\u2193 ", pending, " new \u2014 End for latest"] })) : held ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, " held \u2014 End to follow"] })) : null] }));
99
165
  });
100
166
  // Startup banner: the ATOM block-letter art, rendered once at launch inside
101
167
  // <Static> (scrollback, so it scrolls away naturally). FIGlet "ANSI Shadow"
package/dist/zen.js CHANGED
@@ -8,7 +8,8 @@ import * as path from "node:path";
8
8
  import { MAX_TOOL_STEPS, TOOL_DEFINITIONS, } from "./tools.js";
9
9
  import { chatEndpointFor, getProvider, isLocalProviderId, modelsUrlForProvider, providerLabel, } from "./providers.js";
10
10
  import { discoverLocalProvider } from "./local-discovery.js";
11
- import { ANTHROPIC_VERSION, anthropicHeaders, buildAnthropicBody, buildGeminiBody, geminiChatUrl, geminiGenerateUrl, geminiHeaders, parseAnthropicJson, parseAnthropicModelsList, parseGeminiJson, parseGeminiModelsList, parseOpenAIModelsList, readAnthropicSSEMessage, readGeminiSSEMessage, } from "./adapters.js";
11
+ import { ANTHROPIC_VERSION, anthropicHeaders, buildAnthropicBody, buildGeminiBody, geminiChatUrl, geminiGenerateUrl, geminiHeaders, parseAnthropicJson, parseAnthropicModelsList, parseGeminiJson, parseGeminiModelsList, parseOpenAIModelsList, readAnthropicSSEMessage, readGeminiSSEMessage, isStallError, readWithStall, sseStallTimeoutMs, } from "./adapters.js";
12
+ export { isStallError, readWithStall, sseStallTimeoutMs } from "./adapters.js";
12
13
  import { KILO_FALLBACK_MODELS, fetchKiloModelsWithStatus, normalizeKiloChatError, } from "./kilo.js";
13
14
  import { splitSystemHead } from "./prompt-cache.js";
14
15
  import { SYSTEM_PROMPT } from "./system.js";
@@ -336,6 +337,20 @@ export async function fetchModels(endpoint, apiKey) {
336
337
  // - Slots with an id but no name at [DONE] are dropped with an onWarning
337
338
  // message and never returned (keeps assistant/tool pairing valid).
338
339
  // - A stream that ends without [DONE] throws a truncation error.
340
+ // - A response flagged `finish_reason: "length"` (output limit cut the
341
+ // response off, so tool arguments are incomplete) does NOT throw: it
342
+ // returns normally with `truncated: true` so the loop can fail each carried
343
+ // tool call inline and continue the turn. Transport failures (aborted
344
+ // connections, stalls, empty replies) keep throwing.
345
+ // - A stream silent longer than the stall budget (env ATOM_STALL_TIMEOUT_MS,
346
+ // default 60s; the clock resets on every received chunk) throws a
347
+ // Truncated-stream stall error — permanent, never retried, same contract
348
+ // as a dead connection (verified live: free-tier routers can stall a
349
+ // 200-OK stream mid-generation for minutes).
350
+ // - Queue comments (`: ...`) and keep-alives carry bytes but no model output:
351
+ // only `data:` payload lines refresh the data-silence clock, so minutes of
352
+ // `: KILO PROCESSING` while queued fail fast instead of hanging the turn
353
+ // (same budget, same permanent contract).
339
354
  // - A stream with zero "data:" lines is treated as a non-SSE JSON payload
340
355
  // (tolerance for bodies that are really single-shot JSON) and parsed as
341
356
  // choices[0].message like the non-streaming fallback.
@@ -355,9 +370,29 @@ export async function readSSEMessage(res, opts) {
355
370
  // reasoning label seen in any delta.
356
371
  let streamUsage;
357
372
  let streamReasoning;
373
+ // Output-limit flag (see contract above): set when any streamed choice
374
+ // reports `finish_reason: "length"`. Returned on the result — never thrown.
375
+ let lengthTruncated = false;
358
376
  // Accumulated thinking text (see onThinking): kept apart from fullText so
359
377
  // reasoning never leaks into the answer, history, or tool arguments.
360
378
  let fullThinking = "";
379
+ // Data-silence tracking (see throwIfDataStalled): timestamp of the last
380
+ // `data:` payload line. Queue comments (`: KILO PROCESSING`) and keep-alive
381
+ // comments carry bytes but no model output — they advance the raw stream
382
+ // but must NOT extend the stall budget (live-proven: minutes of comments
383
+ // while a free-tier request sits queued).
384
+ let lastDataAt = Date.now();
385
+ // Fail fast when the stream flows (or idles) with no model output: same
386
+ // permanent Truncated contract as a dead connection (passes through every
387
+ // catch below untouched), same env knob as the per-read byte race. Checked
388
+ // after each drained chunk — legitimately slow generations keep emitting
389
+ // `data:` lines, so only true silence trips it.
390
+ function throwIfDataStalled() {
391
+ const budget = sseStallTimeoutMs();
392
+ if (Date.now() - lastDataAt > budget) {
393
+ throw new Error(`Truncated stream from model (stall: no output for ${budget}ms — queued or stalled upstream; resend to retry).`);
394
+ }
395
+ }
361
396
  function announceStreaming() {
362
397
  if (!streamingAnnounced) {
363
398
  streamingAnnounced = true;
@@ -380,6 +415,7 @@ export async function readSSEMessage(res, opts) {
380
415
  if (!line.startsWith("data:"))
381
416
  return; // event:/id:/retry: ignored
382
417
  sawData = true;
418
+ lastDataAt = Date.now();
383
419
  let payload = line.slice("data:".length);
384
420
  if (payload.startsWith(" "))
385
421
  payload = payload.slice(1);
@@ -404,6 +440,10 @@ export async function readSSEMessage(res, opts) {
404
440
  }
405
441
  const choice = evt
406
442
  ?.choices?.[0];
443
+ // Output-limit marker rides on the choice, beside the delta — any chunk
444
+ // reporting it means the tool arguments below are incomplete.
445
+ if (choice?.finish_reason === "length")
446
+ lengthTruncated = true;
407
447
  const delta = (choice?.delta ?? choice?.message);
408
448
  if (typeof delta !== "object" || delta === null)
409
449
  return;
@@ -513,9 +553,11 @@ export async function readSSEMessage(res, opts) {
513
553
  for (;;) {
514
554
  let chunk;
515
555
  try {
516
- chunk = await reader.read();
556
+ chunk = await readWithStall(() => reader.read());
517
557
  }
518
558
  catch (e) {
559
+ if (isStallError(e))
560
+ throw e;
519
561
  throw new Error(`Truncated stream from model (connection aborted: ${e instanceof Error ? e.message : String(e)}).`);
520
562
  }
521
563
  if (chunk.done)
@@ -525,6 +567,7 @@ export async function readSSEMessage(res, opts) {
525
567
  rawText += text;
526
568
  buffer += text;
527
569
  drainBuffer();
570
+ throwIfDataStalled();
528
571
  if (sawDone) {
529
572
  try {
530
573
  await reader.cancel?.();
@@ -550,13 +593,29 @@ export async function readSSEMessage(res, opts) {
550
593
  }
551
594
  }
552
595
  else if (typeof body[Symbol.asyncIterator] === "function") {
553
- for await (const v of body) {
554
- const text = typeof v === "string" ? v : decoder.decode(v, { stream: true });
555
- rawText += text;
556
- buffer += text;
557
- drainBuffer();
558
- if (sawDone)
559
- break;
596
+ const it = body[Symbol.asyncIterator]();
597
+ try {
598
+ for (;;) {
599
+ const step = await readWithStall(() => it.next());
600
+ if (step.done)
601
+ break;
602
+ const v = step.value;
603
+ const text = typeof v === "string" ? v : decoder.decode(v, { stream: true });
604
+ rawText += text;
605
+ buffer += text;
606
+ drainBuffer();
607
+ throwIfDataStalled();
608
+ if (sawDone)
609
+ break;
610
+ }
611
+ }
612
+ finally {
613
+ try {
614
+ await it.return?.();
615
+ }
616
+ catch {
617
+ // ignore — the stream is over either way
618
+ }
560
619
  }
561
620
  if (!sawDone && buffer.length > 0) {
562
621
  processLine(buffer);
@@ -589,6 +648,10 @@ export async function readSSEMessage(res, opts) {
589
648
  throw new Error(`Truncated stream from model (connection aborted: ${e instanceof Error ? e.message : String(e)}).`);
590
649
  }
591
650
  // Tolerance: a body with no SSE data lines is really single-shot JSON.
651
+ // (The data-silence bound applies only once real SSE traffic exists, so
652
+ // whole-body JSON payloads are never false-tripped by it.)
653
+ if (sawData)
654
+ throwIfDataStalled();
592
655
  if (!sawData) {
593
656
  const candidate = rawText.trim();
594
657
  if (candidate.length > 0) {
@@ -605,6 +668,8 @@ export async function readSSEMessage(res, opts) {
605
668
  content,
606
669
  tool_calls: calls.length > 0 ? calls : undefined,
607
670
  };
671
+ if (data?.choices?.[0]?.finish_reason === "length")
672
+ result.truncated = true;
608
673
  const usage = parseUsage(data?.usage);
609
674
  if (usage !== undefined)
610
675
  result.usage = usage;
@@ -652,6 +717,8 @@ export async function readSSEMessage(res, opts) {
652
717
  content: fullText.length > 0 ? fullText : null,
653
718
  tool_calls: calls.length > 0 ? calls : undefined,
654
719
  };
720
+ if (lengthTruncated)
721
+ result.truncated = true;
655
722
  if (streamUsage !== undefined)
656
723
  result.usage = streamUsage;
657
724
  if (streamReasoning !== undefined)
@@ -669,7 +736,10 @@ export async function readSSEMessage(res, opts) {
669
736
  // tool_calls the caller must execute, plus `usage`/`reasoning` only when
670
737
  // the response actually carried them (usage: top-level `usage` on JSON or
671
738
  // SSE final chunks; reasoning: message/delta reasoning metadata).
672
- // Throws on HTTP error, empty reply, or a truncated stream.
739
+ // Throws on HTTP error, empty reply, or a truncated stream (aborted
740
+ // connection / stall / missing [DONE]). A response flagged
741
+ // `finish_reason: "length"` instead returns normally with `truncated: true`
742
+ // (the loop fails its tool calls inline and continues).
673
743
  // - Network throws and HTTP 429/500/502/503/504 are retried up to
674
744
  // MAX_RETRIES (10) with 1s→2s→4s… backoff, honoring Retry-After capped
675
745
  // at 30s.
@@ -788,6 +858,8 @@ export async function chatCompletion(endpoint, apiKey, model, history, opts, err
788
858
  content,
789
859
  tool_calls: calls.length > 0 ? calls : undefined,
790
860
  };
861
+ if (data?.choices?.[0]?.finish_reason === "length")
862
+ result.truncated = true;
791
863
  const usage = parseUsage(data?.usage);
792
864
  if (usage !== undefined)
793
865
  result.usage = usage;
@@ -838,16 +910,19 @@ export async function chatCompletion(endpoint, apiKey, model, history, opts, err
838
910
  }
839
911
  // Agentic loop for one user turn: thin wrapper over the shared runLoopWithChat
840
912
  // core below (single loop implementation). Send → while the response carries
841
- // tool_calls (max MAX_TOOL_STEPS tool rounds), append the assistant message,
842
- // execute each tool locally, append {role:'tool'} results, resend.
913
+ // tool_calls (uncapped by default; explicit opts.maxSteps still caps), append
914
+ // the assistant message, execute each tool locally, append {role:'tool'}
915
+ // results, resend.
843
916
  // Streaming: each POST streams SSE tokens (onToken gets the growing text,
844
917
  // onPhase reports thinking|streaming|tool|retry|done, onToolDelta fires when
845
918
  // a tool name first appears mid-stream). A model that returns no tool_calls
846
919
  // ends the loop (graceful fallback for models without tool support). Tool
847
920
  // errors are results the model sees — NOTHING is rolled back here; only a
848
- // POST failure (HTTP/network/empty/truncated) throws (and the caller rolls
921
+ // POST failure (HTTP/network/empty/stalled-stream) throws (and the caller rolls
849
922
  // back the user turn, as before; the caller preserves any streamed partial
850
- // on display).
923
+ // on display). A length-truncated response (`finish_reason: "length"`) does
924
+ // not throw: the loop fails each carried tool call inline with a repair
925
+ // error and continues to the next model round.
851
926
  export async function runAgenticLoop(endpoint, apiKey, model, history, opts) {
852
927
  return runLoopWithChat((h, o) => chatCompletion(endpoint, apiKey, model, h, {
853
928
  onToken: o?.onToken,
@@ -1143,16 +1218,17 @@ export async function chatCompletionForProvider(provider, apiKey, model, history
1143
1218
  }
1144
1219
  return chatCompletion(endpoint, apiKey, model, history, chatOpts, providerLabel(provider));
1145
1220
  }
1146
- export { evaluateTurnEnd, isCodePath, MAX_VERIFY_ROUNDS, todoCompletionGate, TURN_END_GATES, verificationGate, } from "./agent/gates.js";
1221
+ export { evaluateTurnEnd, isCodePath, MAX_TODO_ROUNDS, MAX_VERIFY_ROUNDS, todoCompletionGate, TURN_END_GATES, verificationGate, } from "./agent/gates.js";
1147
1222
  // Parallel independent tool calls: batch PLANNING lives in src/scheduler.ts
1148
1223
  // (effect metadata + conflict rules, no per-tool branches); this module only
1149
1224
  // plans via planToolBatches below and executes (serial singletons in program
1150
- // order, read batches concurrently, results committed in call order).
1225
+ // order, disjoint batches concurrently, results committed in call order).
1151
1226
  //
1152
- // Parallel-safe = batchable reads only (see TOOL_EFFECTS in scheduler.ts).
1153
- // Excluded on purpose:
1154
- // - write/edit/bash mutate or spawn with an unbounded footprint (bash can
1155
- // touch anything, so no footprint check could clear it) always singletons;
1227
+ // Parallel-safe = batchable reads plus disjoint-file writes (see TOOL_EFFECTS
1228
+ // and canonicalFileKey in scheduler.ts). Approvals for batched writes resolve
1229
+ // serially in call order before any member executes. Excluded on purpose:
1230
+ // - bash mutates/spawns with an unbounded footprint (it can touch anything,
1231
+ // so no footprint check could clear it) — always a singleton;
1156
1232
  // - ask_question blocks on a UI modal (parallel prompts make no sense);
1157
1233
  // - todowrite/todo_update share module-global todo state (read-modify-write
1158
1234
  // races); todo_get is pure but sub-millisecond, so batching it buys
@@ -1167,6 +1243,7 @@ export function planToolBatches(calls) {
1167
1243
  }
1168
1244
  import { runLoopWithChat } from "./agent/loop.js";
1169
1245
  export { runLoopWithChat } from "./agent/loop.js";
1246
+ export { DEFAULT_MAX_TOTAL_TOOL_CALLS, DEFAULT_TOOL_TIMEOUT_MS, executeWithTimeout, emptyResponseFollowUp, isEmptyReplyError, MAX_EMPTY_ROUNDS, resolveMaxTotalToolCalls, resolveToolTimeoutMs, } from "./agent/loop.js";
1170
1247
  export async function runAgenticLoopForProvider(provider, apiKey, model, history, opts) {
1171
1248
  return runLoopWithChat((h, o) => chatCompletionForProvider(provider, apiKey, model, h, {
1172
1249
  onToken: o?.onToken,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atom-agent",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Agentic terminal coding assistant: multi-provider LLM loop with local file/shell/web tools in an Ink (React) TUI.",
5
5
  "type": "module",
6
6
  "license": "MIT",