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/ui/transcript.js
CHANGED
|
@@ -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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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 =
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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 (
|
|
842
|
-
// execute each tool locally, append {role:'tool'}
|
|
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/
|
|
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,
|
|
1225
|
+
// order, disjoint batches concurrently, results committed in call order).
|
|
1151
1226
|
//
|
|
1152
|
-
// Parallel-safe = batchable reads
|
|
1153
|
-
//
|
|
1154
|
-
//
|
|
1155
|
-
//
|
|
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