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.
- package/CHANGELOG.md +82 -0
- package/README.md +83 -32
- package/dist/App.js +2178 -318
- package/dist/adapters.js +146 -15
- package/dist/agent/gates.js +153 -0
- package/dist/agent/loop-guard.js +184 -0
- package/dist/agent/loop.js +908 -0
- package/dist/agent/normalize.js +144 -0
- package/dist/agent/types.js +1 -0
- package/dist/auth.js +2 -1
- package/dist/cli.js +68 -6
- package/dist/compact.js +6 -48
- package/dist/config.js +171 -0
- package/dist/context-manager.js +564 -0
- package/dist/kilo.js +343 -0
- package/dist/local-discovery.js +308 -0
- package/dist/policy.js +286 -0
- package/dist/prompt-cache.js +99 -0
- package/dist/providers.js +183 -2
- package/dist/rollback.js +21 -0
- package/dist/scheduler.js +247 -0
- package/dist/session.js +35 -3
- package/dist/skills.js +214 -43
- package/dist/snapshots.js +57 -2
- package/dist/system.js +8 -1
- package/dist/telemetry-dashboard.js +589 -0
- package/dist/telemetry-server.js +301 -0
- package/dist/telemetry.js +1056 -0
- package/dist/tools/dir-cache.js +207 -0
- package/dist/tools/filesystem.js +149 -0
- package/dist/tools/fingerprints.js +33 -0
- package/dist/tools/overflow.js +76 -0
- package/dist/tools/read-cache.js +160 -0
- package/dist/tools/registry.js +802 -0
- package/dist/tools/search.js +242 -0
- package/dist/tools/shared.js +31 -0
- package/dist/tools/shell.js +273 -0
- package/dist/tools/todo.js +191 -0
- package/dist/tools/web.js +454 -0
- package/dist/tools.js +17 -1863
- package/dist/ui/activity.js +51 -0
- package/dist/ui/diff-panel.js +55 -0
- package/dist/ui/diff-view.js +112 -0
- package/dist/ui/diff.js +422 -0
- package/dist/ui/errors.js +129 -0
- package/dist/ui/highlight.js +120 -0
- package/dist/ui/input-model.js +115 -0
- package/dist/ui/input.js +40 -0
- package/dist/ui/live-tail.js +15 -0
- package/dist/ui/markdown.js +525 -0
- package/dist/ui/modals.js +47 -0
- package/dist/ui/palette.js +70 -0
- package/dist/ui/pickers.js +32 -0
- package/dist/ui/side-by-side.js +144 -0
- package/dist/ui/status-bar.js +75 -0
- package/dist/ui/theme.js +128 -0
- package/dist/ui/todo-panel.js +30 -0
- package/dist/ui/tool-inspector.js +59 -0
- package/dist/ui/transcript.js +128 -0
- package/dist/zen.js +145 -666
- package/package.json +1 -1
package/dist/zen.js
CHANGED
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
// different Zen request shapes and are out of scope.
|
|
6
6
|
import { existsSync, readFileSync } from "node:fs";
|
|
7
7
|
import * as path from "node:path";
|
|
8
|
-
import { MAX_TOOL_STEPS, TOOL_DEFINITIONS,
|
|
9
|
-
import { chatEndpointFor, getProvider, modelsUrlForProvider, providerLabel, } from "./providers.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
8
|
+
import { MAX_TOOL_STEPS, TOOL_DEFINITIONS, } from "./tools.js";
|
|
9
|
+
import { chatEndpointFor, getProvider, isLocalProviderId, modelsUrlForProvider, providerLabel, } from "./providers.js";
|
|
10
|
+
import { discoverLocalProvider } from "./local-discovery.js";
|
|
11
|
+
import { ANTHROPIC_VERSION, anthropicHeaders, buildAnthropicBody, buildGeminiBody, geminiChatUrl, geminiGenerateUrl, geminiHeaders, parseAnthropicJson, parseAnthropicModelsList, parseGeminiJson, parseGeminiModelsList, parseOpenAIModelsList, readAnthropicSSEMessage, readGeminiSSEMessage, isStallError, readWithStall, } from "./adapters.js";
|
|
12
|
+
export { isStallError, readWithStall, sseStallTimeoutMs } from "./adapters.js";
|
|
13
|
+
import { KILO_FALLBACK_MODELS, fetchKiloModelsWithStatus, normalizeKiloChatError, } from "./kilo.js";
|
|
14
|
+
import { splitSystemHead } from "./prompt-cache.js";
|
|
12
15
|
import { SYSTEM_PROMPT } from "./system.js";
|
|
13
16
|
export { MAX_TOOL_STEPS };
|
|
14
17
|
// Re-exported so existing `SYSTEM_PROMPT` imports keep working; the
|
|
@@ -26,36 +29,18 @@ export const AGENTS_CHAR_CAP = 12 * 1024;
|
|
|
26
29
|
// ---- Conversation-history budget (deterministic, no extra model calls) ----
|
|
27
30
|
// Long sessions can't bloat context, cost, and latency: the shared loop core
|
|
28
31
|
// trims history to BOTH caps before every POST (uniform across providers).
|
|
29
|
-
//
|
|
32
|
+
// The caps themselves live in the ContextManager module (re-exported here so
|
|
33
|
+
// existing importers keep working); precedence per knob stays env override
|
|
34
|
+
// (when valid) → project atom.json → global atom.json → compiled default:
|
|
30
35
|
// - ATOM_MAX_HISTORY_MESSAGES, clamped to 10–1000 (default 100)
|
|
31
36
|
// - ATOM_MAX_HISTORY_CHARS, clamped to 10_000–2_000_000 (default 200_000)
|
|
32
|
-
export
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return fallback;
|
|
40
|
-
const n = Number(text);
|
|
41
|
-
if (!Number.isFinite(n))
|
|
42
|
-
return fallback;
|
|
43
|
-
return Math.min(Math.max(Math.floor(n), min), max);
|
|
44
|
-
}
|
|
45
|
-
// Message-count cap for history (env override clamped 10–1000).
|
|
46
|
-
export function historyMessageBudget() {
|
|
47
|
-
return clampEnvInt(process.env.ATOM_MAX_HISTORY_MESSAGES, 10, 1000, MAX_HISTORY_MESSAGES);
|
|
48
|
-
}
|
|
49
|
-
// Total-chars cap for history (env override clamped 10_000–2_000_000).
|
|
50
|
-
export function historyCharBudget() {
|
|
51
|
-
return clampEnvInt(process.env.ATOM_MAX_HISTORY_CHARS, 10_000, 2_000_000, MAX_HISTORY_CHARS);
|
|
52
|
-
}
|
|
53
|
-
// Tool-round budget for one agentic turn (env override clamped 5–100).
|
|
54
|
-
// A real explore → implement → verify task needs 15–30 tool rounds, so the
|
|
55
|
-
// default is 30; an explicit `opts.maxSteps` still wins (tests inject it).
|
|
56
|
-
export function toolStepBudget() {
|
|
57
|
-
return clampEnvInt(process.env.ATOM_MAX_TOOL_STEPS, 5, 100, MAX_TOOL_STEPS);
|
|
58
|
-
}
|
|
37
|
+
export { toolStepBudget } from "./agent/loop.js";
|
|
38
|
+
// Reasoning effort (session state in the App, default "default").
|
|
39
|
+
// Wire values are exactly default/low/medium/high/max. "default" never
|
|
40
|
+
// sends a param. NOTE: the user asked for `xhigh`, but the only VERIFIED
|
|
41
|
+
// valid values (OpenCode Zen docs/changelog: Thinking Effort
|
|
42
|
+
// Default/Max/High/Medium/Low, sent as `reasoning_effort`) use `Max`, so
|
|
43
|
+
// the top setting is `Max`, sent on the wire as `max`.
|
|
59
44
|
export const EFFORT_OPTIONS = [
|
|
60
45
|
"default",
|
|
61
46
|
"low",
|
|
@@ -89,158 +74,9 @@ export function reasoningEffortParam(effort, model) {
|
|
|
89
74
|
}
|
|
90
75
|
return undefined;
|
|
91
76
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
export function messageChars(m) {
|
|
96
|
-
let n = 0;
|
|
97
|
-
const content = m.content;
|
|
98
|
-
if (typeof content === "string") {
|
|
99
|
-
n += content.length;
|
|
100
|
-
}
|
|
101
|
-
else if (content !== null && content !== undefined) {
|
|
102
|
-
n += JSON.stringify(content).length;
|
|
103
|
-
}
|
|
104
|
-
if (m.role === "assistant") {
|
|
105
|
-
if (m.tool_calls !== undefined)
|
|
106
|
-
n += JSON.stringify(m.tool_calls).length;
|
|
107
|
-
}
|
|
108
|
-
else if (m.role === "tool") {
|
|
109
|
-
n += m.tool_call_id.length;
|
|
110
|
-
}
|
|
111
|
-
return n;
|
|
112
|
-
}
|
|
113
|
-
export function historyChars(history) {
|
|
114
|
-
let total = 0;
|
|
115
|
-
for (const m of history)
|
|
116
|
-
total += messageChars(m);
|
|
117
|
-
return total;
|
|
118
|
-
}
|
|
119
|
-
// Current open todo texts (content + activeForm) via the shared getTodos
|
|
120
|
-
// read path — no duplicated state. Completed items never pin (their echoes
|
|
121
|
-
// are stale context). Never throws: on any failure there is simply nothing
|
|
122
|
-
// todo-pinned and truncation falls back to task-prompt + latest-turn pinning.
|
|
123
|
-
function openTodoNeedles() {
|
|
124
|
-
try {
|
|
125
|
-
const open = getTodos().filter((t) => t.status !== "completed");
|
|
126
|
-
const out = [];
|
|
127
|
-
for (const t of open) {
|
|
128
|
-
if (typeof t.content === "string" && t.content.length > 0)
|
|
129
|
-
out.push(t.content);
|
|
130
|
-
if (typeof t.activeForm === "string" && t.activeForm.length > 0)
|
|
131
|
-
out.push(t.activeForm);
|
|
132
|
-
}
|
|
133
|
-
return out;
|
|
134
|
-
}
|
|
135
|
-
catch {
|
|
136
|
-
return [];
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
// Searchable text for todo matching: message content plus the assistant's
|
|
140
|
-
// tool_calls payload (todowrite CALLS carry the list, tool RESULTS echo it).
|
|
141
|
-
// Tool call ids are NOT searched — they are pairing keys, not goal text, so
|
|
142
|
-
// a todo that reads like an id can never false-pin a turn.
|
|
143
|
-
function todoHaystack(m) {
|
|
144
|
-
let hay = "";
|
|
145
|
-
const content = m.content;
|
|
146
|
-
if (typeof content === "string")
|
|
147
|
-
hay += content;
|
|
148
|
-
if (m.role === "assistant" && m.tool_calls !== undefined) {
|
|
149
|
-
try {
|
|
150
|
-
hay += JSON.stringify(m.tool_calls);
|
|
151
|
-
}
|
|
152
|
-
catch {
|
|
153
|
-
// unstringifiable payload pins nothing
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
return hay;
|
|
157
|
-
}
|
|
158
|
-
function turnMentionsTodo(history, start, end, needles) {
|
|
159
|
-
for (let i = start; i < end; i++) {
|
|
160
|
-
const hay = todoHaystack(history[i]);
|
|
161
|
-
if (hay.length === 0)
|
|
162
|
-
continue;
|
|
163
|
-
for (const n of needles) {
|
|
164
|
-
if (n.length > 0 && hay.includes(n))
|
|
165
|
-
return true;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
return false;
|
|
169
|
-
}
|
|
170
|
-
// Drop oldest user-turns until history fits BOTH budget caps (message count
|
|
171
|
-
// AND total chars, each plus the caller's `reserve` headroom for a message
|
|
172
|
-
// it is about to push). A user turn = the `user` message plus all following
|
|
173
|
-
// messages up to (excluding) the next `user` message, so assistant
|
|
174
|
-
// tool_calls always stay paired with their tool results across all three
|
|
175
|
-
// wire formats. NEVER drops history[0] (system prompt), the first user turn
|
|
176
|
-
// (the task prompt — the goal a long run must never forget), any turn that
|
|
177
|
-
// still quotes a CURRENT open todo (via getTodos, so completed/stale items
|
|
178
|
-
// don't pin), or the latest turn (the one being sent/built). Budget-aware
|
|
179
|
-
// edge: when the pinned content alone (first turn + todo turns + latest)
|
|
180
|
-
// already exceeds a cap, there is nothing left to drop — stop and still send
|
|
181
|
-
// (same never-drop-the-live-turn principle). Mutates `history` in place via
|
|
182
|
-
// splice (so caller indices captured after this call stay valid) and, when at
|
|
183
|
-
// least one turn dropped, fires ONE `notify` (the caller surfaces it dim in
|
|
184
|
-
// the TUI); silence otherwise. Returns what was dropped.
|
|
185
|
-
export function truncateHistory(history, notify, reserve) {
|
|
186
|
-
const result = { droppedTurns: 0, droppedMessages: 0 };
|
|
187
|
-
if (history.length <= 1)
|
|
188
|
-
return result;
|
|
189
|
-
const maxMessages = historyMessageBudget();
|
|
190
|
-
const maxChars = historyCharBudget();
|
|
191
|
-
const roomMessages = reserve?.messages !== undefined && Number.isFinite(reserve.messages)
|
|
192
|
-
? Math.max(0, Math.floor(reserve.messages))
|
|
193
|
-
: 0;
|
|
194
|
-
const roomChars = reserve?.chars !== undefined && Number.isFinite(reserve.chars)
|
|
195
|
-
? Math.max(0, reserve.chars)
|
|
196
|
-
: 0;
|
|
197
|
-
const needles = openTodoNeedles();
|
|
198
|
-
for (;;) {
|
|
199
|
-
const over = history.length + roomMessages > maxMessages ||
|
|
200
|
-
historyChars(history) + roomChars > maxChars;
|
|
201
|
-
if (!over)
|
|
202
|
-
break;
|
|
203
|
-
// Turn boundaries over history[1..]: each turn starts at a `user`
|
|
204
|
-
// message (the oldest slice starts at 1 even when it isn't one, matching
|
|
205
|
-
// the pre-pin drop unit). Whole-turn drops keep assistant/tool pairing.
|
|
206
|
-
const starts = [1];
|
|
207
|
-
for (let i = 2; i < history.length; i++) {
|
|
208
|
-
if (history[i]?.role === "user")
|
|
209
|
-
starts.push(i);
|
|
210
|
-
}
|
|
211
|
-
// Oldest NON-pinned, non-latest turn goes first: the first turn (task
|
|
212
|
-
// prompt) and any turn still quoting a current open todo stay, and the
|
|
213
|
-
// latest turn is never dropped. No candidate means pinned content alone
|
|
214
|
-
// is over budget — stop and send it as-is (see edge above).
|
|
215
|
-
let drop = -1;
|
|
216
|
-
for (let t = 0; t < starts.length; t++) {
|
|
217
|
-
if (t === starts.length - 1)
|
|
218
|
-
continue; // latest turn
|
|
219
|
-
if (t === 0)
|
|
220
|
-
continue; // task prompt
|
|
221
|
-
const end = t + 1 < starts.length ? starts[t + 1] : history.length;
|
|
222
|
-
if (needles.length > 0 && turnMentionsTodo(history, starts[t], end, needles))
|
|
223
|
-
continue;
|
|
224
|
-
drop = t;
|
|
225
|
-
break;
|
|
226
|
-
}
|
|
227
|
-
if (drop === -1)
|
|
228
|
-
break;
|
|
229
|
-
const end = drop + 1 < starts.length ? starts[drop + 1] : history.length;
|
|
230
|
-
const removed = history.splice(starts[drop], end - starts[drop]);
|
|
231
|
-
result.droppedTurns += 1;
|
|
232
|
-
result.droppedMessages += removed.length;
|
|
233
|
-
}
|
|
234
|
-
if (result.droppedTurns > 0) {
|
|
235
|
-
try {
|
|
236
|
-
notify?.(`(history truncated: dropped ${result.droppedTurns} oldest turn(s))`);
|
|
237
|
-
}
|
|
238
|
-
catch {
|
|
239
|
-
// observer errors never break the loop
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
return result;
|
|
243
|
-
}
|
|
77
|
+
export { CHARS_PER_TOKEN, MAX_HISTORY_CHARS, MAX_HISTORY_MESSAGES, createContextManager, estimateTokensForChars, historyCharBudget, historyChars, historyMessageBudget, messageChars, truncateHistoryWithCaps, } from "./context-manager.js";
|
|
78
|
+
export { openTodoNeedles } from "./agent/gates.js";
|
|
79
|
+
export { truncateHistory } from "./agent/loop.js";
|
|
244
80
|
function finiteCount(value) {
|
|
245
81
|
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
246
82
|
? Math.floor(value)
|
|
@@ -262,6 +98,22 @@ export function parseUsage(value) {
|
|
|
262
98
|
const total = finiteCount(o["total_tokens"]);
|
|
263
99
|
if (total !== undefined)
|
|
264
100
|
out.total_tokens = total;
|
|
101
|
+
// Provider-reported prefix-cache counters (present-only, like everything
|
|
102
|
+
// else here): OpenAI prompt_tokens_details.cached_tokens (+cache_write
|
|
103
|
+
// when sent) and DeepSeek prompt_cache_hit_tokens (official docs shapes).
|
|
104
|
+
const details = o["prompt_tokens_details"];
|
|
105
|
+
if (typeof details === "object" && details !== null) {
|
|
106
|
+
const d = details;
|
|
107
|
+
const cached = finiteCount(d["cached_tokens"]);
|
|
108
|
+
if (cached !== undefined)
|
|
109
|
+
out.cacheReadTokens = cached;
|
|
110
|
+
const written = finiteCount(d["cache_write_tokens"]);
|
|
111
|
+
if (written !== undefined)
|
|
112
|
+
out.cacheWriteTokens = written;
|
|
113
|
+
}
|
|
114
|
+
const hit = finiteCount(o["prompt_cache_hit_tokens"]);
|
|
115
|
+
if (hit !== undefined)
|
|
116
|
+
out.cacheReadTokens = hit;
|
|
265
117
|
return out.prompt_tokens !== undefined ||
|
|
266
118
|
out.completion_tokens !== undefined ||
|
|
267
119
|
out.total_tokens !== undefined
|
|
@@ -298,43 +150,24 @@ export function parseReasoningLabel(value) {
|
|
|
298
150
|
return "present";
|
|
299
151
|
return undefined;
|
|
300
152
|
}
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
|
|
305
|
-
export
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
export function isCancelError(e) {
|
|
312
|
-
if (e instanceof LoopCancelledError)
|
|
313
|
-
return true;
|
|
314
|
-
if (e instanceof Error && e.name === "LoopCancelledError")
|
|
315
|
-
return true;
|
|
316
|
-
// fetch abort surfaces as DOMException AbortError (or Error with that name
|
|
317
|
-
// in mocks). Treat any AbortError as a cancellation, never a retry.
|
|
318
|
-
if (e instanceof Error && e.name === "AbortError")
|
|
319
|
-
return true;
|
|
320
|
-
if (typeof DOMException !== "undefined" && e instanceof DOMException && e.name === "AbortError") {
|
|
321
|
-
return true;
|
|
322
|
-
}
|
|
323
|
-
return false;
|
|
324
|
-
}
|
|
325
|
-
function throwIfCancelled(signal) {
|
|
326
|
-
if (signal?.aborted)
|
|
327
|
-
throw new LoopCancelledError();
|
|
328
|
-
}
|
|
329
|
-
export const MAX_RETRIES = 2;
|
|
153
|
+
// Live execution phases surfaced to the TUI via onPhase. `detail` carries
|
|
154
|
+
// the tool name for "tool" and a retry summary (attempt/delay/status) for
|
|
155
|
+
// "retry"; it is empty for the other phases.
|
|
156
|
+
import { isCancelError, LoopCancelledError, throwIfCancelled } from "./agent/loop.js";
|
|
157
|
+
export { isCancelError, LoopCancelledError } from "./agent/loop.js";
|
|
158
|
+
// Ten retries (eleven total attempts): provider rate limits (429 with
|
|
159
|
+
// Retry-After) and weak-network throws both ride this policy. Cancellation
|
|
160
|
+
// never retries. Delays grow exponentially under a 30s cap, so a fully dead
|
|
161
|
+
// endpoint costs ~3.5min worst case before the turn fails loudly.
|
|
162
|
+
export const MAX_RETRIES = 10;
|
|
330
163
|
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
|
|
331
164
|
const RETRY_AFTER_CAP_MS = 30_000;
|
|
332
165
|
export function defaultSleep(ms) {
|
|
333
166
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
334
167
|
}
|
|
335
|
-
// Exponential backoff 1s
|
|
336
|
-
// capped at 30s. `attempt` is the 0-based index of the failure
|
|
337
|
-
// (0 => first failure => 1s).
|
|
168
|
+
// Exponential backoff 1s → 2s → 4s …, honoring Retry-After (seconds or
|
|
169
|
+
// HTTP date) capped at 30s. `attempt` is the 0-based index of the failure
|
|
170
|
+
// just seen (0 => first failure => 1s).
|
|
338
171
|
export function getRetryDelay(attempt, res) {
|
|
339
172
|
try {
|
|
340
173
|
const raw = res
|
|
@@ -357,7 +190,7 @@ export function getRetryDelay(attempt, res) {
|
|
|
357
190
|
catch {
|
|
358
191
|
// fall through to backoff
|
|
359
192
|
}
|
|
360
|
-
return
|
|
193
|
+
return Math.min(1000 * 2 ** attempt, RETRY_AFTER_CAP_MS);
|
|
361
194
|
}
|
|
362
195
|
async function safeErrorText(res) {
|
|
363
196
|
try {
|
|
@@ -504,6 +337,11 @@ export async function fetchModels(endpoint, apiKey) {
|
|
|
504
337
|
// - Slots with an id but no name at [DONE] are dropped with an onWarning
|
|
505
338
|
// message and never returned (keeps assistant/tool pairing valid).
|
|
506
339
|
// - A stream that ends without [DONE] throws a truncation error.
|
|
340
|
+
// - A stream silent longer than the stall budget (env ATOM_STALL_TIMEOUT_MS,
|
|
341
|
+
// default 60s; the clock resets on every received chunk) throws a
|
|
342
|
+
// Truncated-stream stall error — permanent, never retried, same contract
|
|
343
|
+
// as a dead connection (verified live: free-tier routers can stall a
|
|
344
|
+
// 200-OK stream mid-generation for minutes).
|
|
507
345
|
// - A stream with zero "data:" lines is treated as a non-SSE JSON payload
|
|
508
346
|
// (tolerance for bodies that are really single-shot JSON) and parsed as
|
|
509
347
|
// choices[0].message like the non-streaming fallback.
|
|
@@ -681,9 +519,11 @@ export async function readSSEMessage(res, opts) {
|
|
|
681
519
|
for (;;) {
|
|
682
520
|
let chunk;
|
|
683
521
|
try {
|
|
684
|
-
chunk = await reader.read();
|
|
522
|
+
chunk = await readWithStall(() => reader.read());
|
|
685
523
|
}
|
|
686
524
|
catch (e) {
|
|
525
|
+
if (isStallError(e))
|
|
526
|
+
throw e;
|
|
687
527
|
throw new Error(`Truncated stream from model (connection aborted: ${e instanceof Error ? e.message : String(e)}).`);
|
|
688
528
|
}
|
|
689
529
|
if (chunk.done)
|
|
@@ -718,13 +558,28 @@ export async function readSSEMessage(res, opts) {
|
|
|
718
558
|
}
|
|
719
559
|
}
|
|
720
560
|
else if (typeof body[Symbol.asyncIterator] === "function") {
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
561
|
+
const it = body[Symbol.asyncIterator]();
|
|
562
|
+
try {
|
|
563
|
+
for (;;) {
|
|
564
|
+
const step = await readWithStall(() => it.next());
|
|
565
|
+
if (step.done)
|
|
566
|
+
break;
|
|
567
|
+
const v = step.value;
|
|
568
|
+
const text = typeof v === "string" ? v : decoder.decode(v, { stream: true });
|
|
569
|
+
rawText += text;
|
|
570
|
+
buffer += text;
|
|
571
|
+
drainBuffer();
|
|
572
|
+
if (sawDone)
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
finally {
|
|
577
|
+
try {
|
|
578
|
+
await it.return?.();
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
// ignore — the stream is over either way
|
|
582
|
+
}
|
|
728
583
|
}
|
|
729
584
|
if (!sawDone && buffer.length > 0) {
|
|
730
585
|
processLine(buffer);
|
|
@@ -838,8 +693,9 @@ export async function readSSEMessage(res, opts) {
|
|
|
838
693
|
// the response actually carried them (usage: top-level `usage` on JSON or
|
|
839
694
|
// SSE final chunks; reasoning: message/delta reasoning metadata).
|
|
840
695
|
// Throws on HTTP error, empty reply, or a truncated stream.
|
|
841
|
-
// - Network throws and HTTP 429/500/502/503/504 are retried up to
|
|
842
|
-
// (
|
|
696
|
+
// - Network throws and HTTP 429/500/502/503/504 are retried up to
|
|
697
|
+
// MAX_RETRIES (10) with 1s→2s→4s… backoff, honoring Retry-After capped
|
|
698
|
+
// at 30s.
|
|
843
699
|
// Each retry emits onPhase("retry", detail). Other 4xx fail fast with
|
|
844
700
|
// the existing `Zen HTTP {status}` message.
|
|
845
701
|
// - Callers must roll back the user turn on failure (see App submit).
|
|
@@ -862,9 +718,16 @@ export async function chatCompletion(endpoint, apiKey, model, history, opts, err
|
|
|
862
718
|
}
|
|
863
719
|
const effortParam = reasoningEffortParam(opts?.reasoningEffort, model);
|
|
864
720
|
const summaryOpts = opts;
|
|
721
|
+
// Stable-prefix split (prompt-cache architecture): history[0]'s env
|
|
722
|
+
// tail becomes its own system message so the stable head + tools stay
|
|
723
|
+
// byte-identical across POSTs for implicit prefix caching. Consecutive
|
|
724
|
+
// system messages concatenate on every OpenAI-protocol server, so this
|
|
725
|
+
// is content-neutral. No env tail (tests, old saves) → history passes
|
|
726
|
+
// through untouched, byte-identical to before.
|
|
727
|
+
const messages = splitSystemHead(history);
|
|
865
728
|
const payload = {
|
|
866
729
|
model,
|
|
867
|
-
messages
|
|
730
|
+
messages,
|
|
868
731
|
stream: true,
|
|
869
732
|
};
|
|
870
733
|
// Compaction path only: tools disabled means NO `tools` key at all
|
|
@@ -882,9 +745,13 @@ export async function chatCompletion(endpoint, apiKey, model, history, opts, err
|
|
|
882
745
|
payload["reasoning_effort"] = effortParam;
|
|
883
746
|
const res = await fetch(endpoint, {
|
|
884
747
|
method: "POST",
|
|
748
|
+
// Anonymous-capable providers (Kilo free models) omit Authorization
|
|
749
|
+
// when no key is configured — never an empty `Bearer `. Keyed
|
|
750
|
+
// providers always pass a key (gated by providerNeedsKey), so their
|
|
751
|
+
// behavior is unchanged.
|
|
885
752
|
headers: {
|
|
886
753
|
"Content-Type": "application/json",
|
|
887
|
-
Authorization: `Bearer ${apiKey}
|
|
754
|
+
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
888
755
|
},
|
|
889
756
|
body: JSON.stringify(payload),
|
|
890
757
|
...(signal ? { signal } : {}),
|
|
@@ -1002,7 +869,8 @@ export async function chatCompletion(endpoint, apiKey, model, history, opts, err
|
|
|
1002
869
|
// ends the loop (graceful fallback for models without tool support). Tool
|
|
1003
870
|
// errors are results the model sees — NOTHING is rolled back here; only a
|
|
1004
871
|
// POST failure (HTTP/network/empty/truncated) throws (and the caller rolls
|
|
1005
|
-
// back the user turn, as before
|
|
872
|
+
// back the user turn, as before; the caller preserves any streamed partial
|
|
873
|
+
// on display).
|
|
1006
874
|
export async function runAgenticLoop(endpoint, apiKey, model, history, opts) {
|
|
1007
875
|
return runLoopWithChat((h, o) => chatCompletion(endpoint, apiKey, model, h, {
|
|
1008
876
|
onToken: o?.onToken,
|
|
@@ -1015,94 +883,6 @@ export async function runAgenticLoop(endpoint, apiKey, model, history, opts) {
|
|
|
1015
883
|
signal: o?.signal,
|
|
1016
884
|
}), history, opts);
|
|
1017
885
|
}
|
|
1018
|
-
// Execute one parsed tool call through validation + permission +
|
|
1019
|
-
// ask_question gates. Model mistakes (unknown name, invalid args) return
|
|
1020
|
-
// repairs-oriented results WITHOUT executing; cancellations propagate as
|
|
1021
|
-
// LoopCancelledError (never a result, never retried). Everything else
|
|
1022
|
-
// returns a result string fed back to the model:
|
|
1023
|
-
// - ask_question never needs approval; without an askUser hook it resolves
|
|
1024
|
-
// to "Error: ask_question has no UI hook".
|
|
1025
|
-
// - write/edit/bash consult the approve hook when one is provided; a "no"
|
|
1026
|
-
// resolves to "Error: denied by user: <tool>" (final, no retry/rollback).
|
|
1027
|
-
// Without a hook every tool executes immediately.
|
|
1028
|
-
async function runOneTool(call, parsed, opts, execute) {
|
|
1029
|
-
const name = call?.function?.name ?? "(unknown)";
|
|
1030
|
-
// Unknown tool: model mistake — list actual names, never execute.
|
|
1031
|
-
if (!toolNames().includes(name)) {
|
|
1032
|
-
return `Error: unknown tool "${name}". Available: ${toolNames().join(", ")}`;
|
|
1033
|
-
}
|
|
1034
|
-
// Argument validation BEFORE approval/execution: model mistake, never runs.
|
|
1035
|
-
const detail = validateToolArgs(name, parsed);
|
|
1036
|
-
if (detail) {
|
|
1037
|
-
return invalidCall(detail);
|
|
1038
|
-
}
|
|
1039
|
-
if (name === "ask_question") {
|
|
1040
|
-
throwIfCancelled(opts?.signal);
|
|
1041
|
-
// If the signal aborts during the modal, runAskQuestion rejects with
|
|
1042
|
-
// LoopCancelledError (no result). If it resolves just as the signal
|
|
1043
|
-
// aborts, return the result — the loop records it, then stops before
|
|
1044
|
-
// the next POST (no new POSTs, pairing stays valid until rollback).
|
|
1045
|
-
return runAskQuestion(parsed, opts?.askUser, opts?.signal);
|
|
1046
|
-
}
|
|
1047
|
-
if (opts?.approve && needsApproval(name)) {
|
|
1048
|
-
let decision;
|
|
1049
|
-
try {
|
|
1050
|
-
decision = await opts.approve(name, parsed);
|
|
1051
|
-
}
|
|
1052
|
-
catch (e) {
|
|
1053
|
-
// Whole-turn cancellation must propagate (Ctrl+C cancels the turn,
|
|
1054
|
-
// not just deny one call). Anything else is a denial.
|
|
1055
|
-
if (isCancelError(e) || opts?.signal?.aborted)
|
|
1056
|
-
throw new LoopCancelledError();
|
|
1057
|
-
decision = "no";
|
|
1058
|
-
}
|
|
1059
|
-
// Abort that lands as a resolved denial still cancels the whole turn.
|
|
1060
|
-
throwIfCancelled(opts?.signal);
|
|
1061
|
-
if (decision === "no") {
|
|
1062
|
-
return `Error: denied by user: ${name}`;
|
|
1063
|
-
}
|
|
1064
|
-
// "once" runs this call; "always" runs it too (the caller caches the
|
|
1065
|
-
// always-allowed set session-wide so later calls skip the prompt).
|
|
1066
|
-
}
|
|
1067
|
-
// No new executions after a cancel: stop after the current tool finishes.
|
|
1068
|
-
// The current tool (if already running) is awaited to completion and its
|
|
1069
|
-
// result IS recorded — the loop then stops before the next tool/POST, so
|
|
1070
|
-
// assistant/tool pairing stays valid until the caller rolls back.
|
|
1071
|
-
throwIfCancelled(opts?.signal);
|
|
1072
|
-
try {
|
|
1073
|
-
return await execute(name, parsed);
|
|
1074
|
-
}
|
|
1075
|
-
catch (e) {
|
|
1076
|
-
if (isCancelError(e) || opts?.signal?.aborted)
|
|
1077
|
-
throw new LoopCancelledError();
|
|
1078
|
-
throw e;
|
|
1079
|
-
}
|
|
1080
|
-
}
|
|
1081
|
-
async function runAskQuestion(parsed, askUser, signal) {
|
|
1082
|
-
const invalid = validateAskQuestionArgs(parsed);
|
|
1083
|
-
if (invalid)
|
|
1084
|
-
return invalid;
|
|
1085
|
-
if (!askUser)
|
|
1086
|
-
return "Error: ask_question has no UI hook";
|
|
1087
|
-
const q = parsed;
|
|
1088
|
-
const allowCustom = q.allowCustom === true;
|
|
1089
|
-
try {
|
|
1090
|
-
const answer = await askUser(q.question, q.options, allowCustom);
|
|
1091
|
-
if (typeof answer === "string" && answer.startsWith("Error:"))
|
|
1092
|
-
return answer;
|
|
1093
|
-
return JSON.stringify({ answer });
|
|
1094
|
-
}
|
|
1095
|
-
catch (e) {
|
|
1096
|
-
// Whole-turn cancellation (Ctrl+C) propagates — it is NOT the Esc
|
|
1097
|
-
// question-cancel result below.
|
|
1098
|
-
if (isCancelError(e) || signal?.aborted)
|
|
1099
|
-
throw new LoopCancelledError();
|
|
1100
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
1101
|
-
if (/cancel/i.test(msg))
|
|
1102
|
-
return "Error: question cancelled by user";
|
|
1103
|
-
return `Error: ${msg}`;
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
886
|
// AGENTS.md loading: <cwd>/AGENTS.md (or $OPENCODE_AGENTS_PATH when set)
|
|
1107
887
|
// is appended to the system prompt at startup, capped at 12KB.
|
|
1108
888
|
export function agentsFilePath(cwd = process.cwd()) {
|
|
@@ -1357,7 +1137,7 @@ export async function chatCompletionForProvider(provider, apiKey, model, history
|
|
|
1357
1137
|
? (opts?.endpointOverride ?? chatEndpointFor(provider, opts?.baseURL))
|
|
1358
1138
|
: chatEndpointFor(provider, opts?.baseURL);
|
|
1359
1139
|
const effortOpts = provider === "opencode-zen" ? { reasoningEffort: opts?.reasoningEffort } : {};
|
|
1360
|
-
|
|
1140
|
+
const chatOpts = {
|
|
1361
1141
|
onToken: opts?.onToken,
|
|
1362
1142
|
onPhase: opts?.onPhase,
|
|
1363
1143
|
onToolDelta: opts?.onToolDelta,
|
|
@@ -1371,363 +1151,46 @@ export async function chatCompletionForProvider(provider, apiKey, model, history
|
|
|
1371
1151
|
...(opts?.maxOutputTokens !== undefined
|
|
1372
1152
|
? { maxOutputTokens: opts.maxOutputTokens }
|
|
1373
1153
|
: {}),
|
|
1374
|
-
}, providerLabel(provider));
|
|
1375
|
-
}
|
|
1376
|
-
// Todo-completion guard: the turn may not end with final text while todos
|
|
1377
|
-
// are open. With budget left, record the attempt and feed back a guard
|
|
1378
|
-
// message as a user follow-up so the model must continue with tool calls or
|
|
1379
|
-
// explicitly resolve the todos. With the step budget spent, end with an
|
|
1380
|
-
// explicit blocked statement naming the unfinished items instead.
|
|
1381
|
-
export function todoCompletionGate(finalText, ctx) {
|
|
1382
|
-
const open = getTodos().filter((t) => t.status !== "completed");
|
|
1383
|
-
if (open.length === 0)
|
|
1384
|
-
return { action: "pass" };
|
|
1385
|
-
const items = open.map((t, i) => `${i + 1}. [${t.status}] ${t.content}`).join("\n");
|
|
1386
|
-
if (ctx.step >= ctx.maxSteps) {
|
|
1387
|
-
return {
|
|
1388
|
-
action: "end",
|
|
1389
|
-
finalText: `${finalText}${finalText ? "\n" : ""}(blocked: ${open.length} open todo(s) — resolve with todo_update/todowrite before ending the turn:\n${items})`,
|
|
1390
|
-
};
|
|
1391
|
-
}
|
|
1392
|
-
return {
|
|
1393
|
-
action: "continue",
|
|
1394
|
-
assistantText: finalText,
|
|
1395
|
-
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})`,
|
|
1396
1154
|
};
|
|
1397
|
-
|
|
1398
|
-
//
|
|
1399
|
-
//
|
|
1400
|
-
//
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
action: "end",
|
|
1408
|
-
finalText: `${finalText}${finalText ? "\n" : ""}(unverified: files were written but no test/typecheck command ran after the last write — run \`npm test\` and \`npm run typecheck\` and report their pass/fail lines, or name the blocker explicitly.)`,
|
|
1409
|
-
};
|
|
1410
|
-
}
|
|
1411
|
-
export const TURN_END_GATES = [todoCompletionGate, verificationGate];
|
|
1412
|
-
export function evaluateTurnEnd(finalText, ctx, gates = TURN_END_GATES) {
|
|
1413
|
-
for (const gate of gates) {
|
|
1414
|
-
const decision = gate(finalText, ctx);
|
|
1415
|
-
if (decision.action === "pass")
|
|
1416
|
-
continue;
|
|
1417
|
-
if (decision.action === "continue") {
|
|
1418
|
-
return { kind: "continue", assistantText: decision.assistantText, followUp: decision.followUp };
|
|
1155
|
+
// Kilo rides the shared OpenAI-chat path (streaming, tool reconstruction,
|
|
1156
|
+
// retry) with its registry endpoint + label; HTTP failures are reframed
|
|
1157
|
+
// into concise actionable Kilo errors (see src/kilo.ts). reasoning_effort
|
|
1158
|
+
// is never attached (zen-only gating above).
|
|
1159
|
+
if (provider === "kilo") {
|
|
1160
|
+
try {
|
|
1161
|
+
return await chatCompletion(endpoint, apiKey, model, history, chatOpts, providerLabel(provider));
|
|
1162
|
+
}
|
|
1163
|
+
catch (e) {
|
|
1164
|
+
throw normalizeKiloChatError(e, apiKey);
|
|
1419
1165
|
}
|
|
1420
|
-
return { kind: "end", finalText: decision.finalText };
|
|
1421
1166
|
}
|
|
1422
|
-
return
|
|
1167
|
+
return chatCompletion(endpoint, apiKey, model, history, chatOpts, providerLabel(provider));
|
|
1423
1168
|
}
|
|
1424
|
-
|
|
1425
|
-
//
|
|
1426
|
-
//
|
|
1427
|
-
//
|
|
1428
|
-
//
|
|
1429
|
-
function isVerificationCommand(command) {
|
|
1430
|
-
return /\b(vitest|jest|mocha|pytest|typecheck|tsc|verify|check|build|tests?)\b/i.test(command);
|
|
1431
|
-
}
|
|
1432
|
-
// Parallel independent tool calls (ticket 05): read-only, non-overlapping
|
|
1433
|
-
// calls in one model turn execute concurrently (roughly one round-trip
|
|
1434
|
-
// instead of N) with results re-paired in call order. Batching re-pairs at
|
|
1435
|
-
// the commit point the turn-continuation seam defines (one transcript entry
|
|
1436
|
-
// per call, in order), so the seam's pairing guarantee is unaffected.
|
|
1169
|
+
export { evaluateTurnEnd, isCodePath, MAX_VERIFY_ROUNDS, todoCompletionGate, TURN_END_GATES, verificationGate, } from "./agent/gates.js";
|
|
1170
|
+
// Parallel independent tool calls: batch PLANNING lives in src/scheduler.ts
|
|
1171
|
+
// (effect metadata + conflict rules, no per-tool branches); this module only
|
|
1172
|
+
// plans via planToolBatches below and executes (serial singletons in program
|
|
1173
|
+
// order, read batches concurrently, results committed in call order).
|
|
1437
1174
|
//
|
|
1438
|
-
// Parallel-safe =
|
|
1439
|
-
//
|
|
1440
|
-
// - write/edit/bash
|
|
1441
|
-
// anything, so no footprint check could clear it);
|
|
1175
|
+
// Parallel-safe = batchable reads only (see TOOL_EFFECTS in scheduler.ts).
|
|
1176
|
+
// Excluded on purpose:
|
|
1177
|
+
// - write/edit/bash mutate or spawn with an unbounded footprint (bash can
|
|
1178
|
+
// touch anything, so no footprint check could clear it) — always singletons;
|
|
1442
1179
|
// - ask_question blocks on a UI modal (parallel prompts make no sense);
|
|
1443
1180
|
// - todowrite/todo_update share module-global todo state (read-modify-write
|
|
1444
1181
|
// races); todo_get is pure but sub-millisecond, so batching it buys
|
|
1445
|
-
// nothing and it stays serial too
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
"websearch",
|
|
1452
|
-
"bash_output",
|
|
1453
|
-
]);
|
|
1454
|
-
// Overlap key for two parallel-safe calls: same tool over the same primary
|
|
1455
|
-
// target (path/pattern/command/URL/query/taskId — the same primary
|
|
1456
|
-
// describeToolCall shows). Same-key calls serialize (conservative: e.g. two
|
|
1457
|
-
// reads of one path); different keys — including different tools naming the
|
|
1458
|
-
// same string — are disjoint read-only footprints and run together. Returns
|
|
1459
|
-
// null when the call must stay serial: unknown name, malformed JSON,
|
|
1460
|
-
// failed validation (all inline-error paths), or an empty primary (unknown
|
|
1461
|
-
// footprint — never batch what you cannot see).
|
|
1462
|
-
export function parallelKeyFor(name, parsed) {
|
|
1463
|
-
if (!PARALLEL_SAFE_TOOLS.has(name))
|
|
1464
|
-
return null;
|
|
1465
|
-
if (!toolNames().includes(name))
|
|
1466
|
-
return null;
|
|
1467
|
-
if (validateToolArgs(name, parsed))
|
|
1468
|
-
return null;
|
|
1469
|
-
const primary = primaryTarget(name, parsed);
|
|
1470
|
-
if (primary.length === 0)
|
|
1471
|
-
return null;
|
|
1472
|
-
return `${name}${primary}`;
|
|
1473
|
-
}
|
|
1474
|
-
// Partition one assistant message's tool_calls into commit batches,
|
|
1475
|
-
// preserving program order: consecutive batchable calls with pairwise
|
|
1476
|
-
// disjoint keys form one batch; any serial-only call — and any call whose
|
|
1477
|
-
// key already appears in the open batch — closes the batch and runs as a
|
|
1478
|
-
// strict serial singleton. A later batch never moves ahead of an earlier
|
|
1479
|
-
// serial call (read-after-write stays ordered), and batches never span the
|
|
1480
|
-
// block boundary.
|
|
1182
|
+
// nothing and it stays serial too.
|
|
1183
|
+
import { planBatches } from "./scheduler.js";
|
|
1184
|
+
// Partition one assistant message's tool_calls into commit batches —
|
|
1185
|
+
// effect-aware (see planBatches), preserving program order and the commit
|
|
1186
|
+
// contract the turn-continuation seam defines (one transcript entry per
|
|
1187
|
+
// call, in order). Kept under this name/signature for callers and tests.
|
|
1481
1188
|
export function planToolBatches(calls) {
|
|
1482
|
-
|
|
1483
|
-
let open = [];
|
|
1484
|
-
const keys = new Set();
|
|
1485
|
-
const flush = () => {
|
|
1486
|
-
if (open.length > 0) {
|
|
1487
|
-
batches.push(open);
|
|
1488
|
-
open = [];
|
|
1489
|
-
keys.clear();
|
|
1490
|
-
}
|
|
1491
|
-
};
|
|
1492
|
-
for (const call of calls) {
|
|
1493
|
-
let parsed;
|
|
1494
|
-
let malformed = false;
|
|
1495
|
-
try {
|
|
1496
|
-
const raw = call?.function?.arguments ?? "{}";
|
|
1497
|
-
const v = JSON.parse(typeof raw === "string" ? raw : "{}");
|
|
1498
|
-
parsed = typeof v === "object" && v !== null ? v : {};
|
|
1499
|
-
}
|
|
1500
|
-
catch {
|
|
1501
|
-
parsed = {};
|
|
1502
|
-
malformed = true;
|
|
1503
|
-
}
|
|
1504
|
-
const name = call?.function?.name ?? "(unknown)";
|
|
1505
|
-
const key = malformed ? null : parallelKeyFor(name, parsed);
|
|
1506
|
-
if (key === null || keys.has(key)) {
|
|
1507
|
-
flush();
|
|
1508
|
-
batches.push([{ call, parsed, parallelKey: null }]);
|
|
1509
|
-
}
|
|
1510
|
-
else {
|
|
1511
|
-
keys.add(key);
|
|
1512
|
-
open.push({ call, parsed, parallelKey: key });
|
|
1513
|
-
}
|
|
1514
|
-
}
|
|
1515
|
-
flush();
|
|
1516
|
-
return batches;
|
|
1517
|
-
}
|
|
1518
|
-
// Shared agentic-loop core: the SINGLE loop implementation backing both
|
|
1519
|
-
// runAgenticLoop and runAgenticLoopForProvider (same tool/rollback contract).
|
|
1520
|
-
// Sequencing: each assistant message's tool_calls block is partitioned by
|
|
1521
|
-
// planToolBatches — a batch of parallel-safe calls runs concurrently and its
|
|
1522
|
-
// results commit in call order (re-paired by index, one transcript entry per
|
|
1523
|
-
// call); everything else executes strictly serially in program order. A
|
|
1524
|
-
// failure in one call NEVER skips the remaining commits of its block when
|
|
1525
|
-
// the results are values (each result pairs with its tool_call_id in
|
|
1526
|
-
// order); malformed calls (bad JSON, unknown name, failed validation) yield
|
|
1527
|
-
// their error result inline and the block continues. Validation/unknown/
|
|
1528
|
-
// denial/cancel are never retried — only transient transport failures retry
|
|
1529
|
-
// (inside chatCompletion). A thrown execution error (or cancel) aborts the
|
|
1530
|
-
// turn exactly as the old serial loop did — the caller rolls the partial
|
|
1531
|
-
// turn back, so assistant/tool pairing stays valid.
|
|
1532
|
-
export async function runLoopWithChat(chatFn, history, opts) {
|
|
1533
|
-
const execute = opts?.execute ?? executeTool;
|
|
1534
|
-
const maxSteps = opts?.maxSteps ?? toolStepBudget();
|
|
1535
|
-
const signal = opts?.signal ?? null;
|
|
1536
|
-
// Task 7 verification gate: whether this turn wrote files and whether a
|
|
1537
|
-
// test/typecheck/build command ran after the last write. Only evidence
|
|
1538
|
-
// AFTER the last write counts, so each new write resets the flag.
|
|
1539
|
-
let filesWritten = false;
|
|
1540
|
-
let verifiedAfterWrite = false;
|
|
1541
|
-
// At most one truncation notice per turn; silence when nothing dropped.
|
|
1542
|
-
let truncationNoticed = false;
|
|
1543
|
-
for (let step = 0;; step++) {
|
|
1544
|
-
throwIfCancelled(signal);
|
|
1545
|
-
// History budget (uniform for all providers — every POST flows through
|
|
1546
|
-
// here): trim oldest user-turns first before each send.
|
|
1547
|
-
const trimmed = truncateHistory(history, truncationNoticed
|
|
1548
|
-
? undefined
|
|
1549
|
-
: (notice) => {
|
|
1550
|
-
try {
|
|
1551
|
-
opts?.onWarning?.(notice);
|
|
1552
|
-
}
|
|
1553
|
-
catch {
|
|
1554
|
-
// ignore observer errors
|
|
1555
|
-
}
|
|
1556
|
-
});
|
|
1557
|
-
if (trimmed.droppedTurns > 0)
|
|
1558
|
-
truncationNoticed = true;
|
|
1559
|
-
let msg;
|
|
1560
|
-
try {
|
|
1561
|
-
msg = await chatFn(history, {
|
|
1562
|
-
onToken: opts?.onToken,
|
|
1563
|
-
onPhase: opts?.onPhase,
|
|
1564
|
-
onToolDelta: opts?.onToolDelta,
|
|
1565
|
-
onWarning: opts?.onWarning,
|
|
1566
|
-
onThinking: opts?.onThinking,
|
|
1567
|
-
sleep: opts?.sleep,
|
|
1568
|
-
reasoningEffort: opts?.reasoningEffort,
|
|
1569
|
-
signal,
|
|
1570
|
-
});
|
|
1571
|
-
}
|
|
1572
|
-
catch (e) {
|
|
1573
|
-
if (isCancelError(e) || signal?.aborted)
|
|
1574
|
-
throw new LoopCancelledError();
|
|
1575
|
-
throw e;
|
|
1576
|
-
}
|
|
1577
|
-
throwIfCancelled(signal);
|
|
1578
|
-
if (msg.usage !== undefined) {
|
|
1579
|
-
// Spend accounting: EVERY POST that reports usage forwards it, and the
|
|
1580
|
-
// caller accumulates each report as billed spend — tool-round POSTs,
|
|
1581
|
-
// summary POSTs, and successful retries each count once. Attempts that
|
|
1582
|
-
// fail (HTTP/network/truncation) report no usage, so there is nothing
|
|
1583
|
-
// to dedupe: each attempt that reached the provider and reported counts
|
|
1584
|
-
// exactly once. Usage is never synthesized or estimated here.
|
|
1585
|
-
try {
|
|
1586
|
-
opts?.onUsage?.(msg.usage);
|
|
1587
|
-
}
|
|
1588
|
-
catch {
|
|
1589
|
-
// ignore
|
|
1590
|
-
}
|
|
1591
|
-
}
|
|
1592
|
-
if (msg.reasoning !== undefined) {
|
|
1593
|
-
try {
|
|
1594
|
-
opts?.onReasoning?.(msg.reasoning);
|
|
1595
|
-
}
|
|
1596
|
-
catch {
|
|
1597
|
-
// ignore
|
|
1598
|
-
}
|
|
1599
|
-
}
|
|
1600
|
-
const calls = msg.tool_calls ?? [];
|
|
1601
|
-
if (calls.length === 0) {
|
|
1602
|
-
// Turn-continuation seam (ticket 03): the todo guard and verification
|
|
1603
|
-
// gate run as entries in TURN_END_GATES — one chain, one commit point.
|
|
1604
|
-
// Behavior is byte-identical to the two inline blocks this replaced.
|
|
1605
|
-
const outcome = evaluateTurnEnd(msg.content ?? "", { step, maxSteps, filesWritten, verifiedAfterWrite });
|
|
1606
|
-
if (outcome.kind === "continue") {
|
|
1607
|
-
history.push({ role: "assistant", content: outcome.assistantText });
|
|
1608
|
-
history.push({ role: "user", content: outcome.followUp });
|
|
1609
|
-
continue;
|
|
1610
|
-
}
|
|
1611
|
-
history.push({ role: "assistant", content: outcome.finalText });
|
|
1612
|
-
try {
|
|
1613
|
-
opts?.onPhase?.("done");
|
|
1614
|
-
}
|
|
1615
|
-
catch {
|
|
1616
|
-
// ignore
|
|
1617
|
-
}
|
|
1618
|
-
return outcome.finalText;
|
|
1619
|
-
}
|
|
1620
|
-
if (step >= maxSteps) {
|
|
1621
|
-
const base = msg.content ?? "";
|
|
1622
|
-
const notice = `${base}${base ? "\n" : ""}(stopped: too many tool steps) (limit is ${maxSteps}; raise with ATOM_MAX_TOOL_STEPS=<n>)`;
|
|
1623
|
-
history.push({ role: "assistant", content: notice });
|
|
1624
|
-
try {
|
|
1625
|
-
opts?.onPhase?.("done");
|
|
1626
|
-
}
|
|
1627
|
-
catch {
|
|
1628
|
-
// ignore
|
|
1629
|
-
}
|
|
1630
|
-
return notice;
|
|
1631
|
-
}
|
|
1632
|
-
history.push({ role: "assistant", content: msg.content ?? null, tool_calls: calls });
|
|
1633
|
-
// Commit helper shared by the serial and parallel paths: Task 7
|
|
1634
|
-
// bookkeeping + one ordered transcript entry per call. Only successful
|
|
1635
|
-
// executions count — denials, validation errors, and unknown tools (all
|
|
1636
|
-
// `Error:` results) never ran, so they neither arm nor clear the gate.
|
|
1637
|
-
const commitToolResult = (name, parsed, call, result) => {
|
|
1638
|
-
const isError = typeof result === "string" && result.startsWith("Error");
|
|
1639
|
-
if (!isError && (name === "write" || name === "edit")) {
|
|
1640
|
-
filesWritten = true;
|
|
1641
|
-
verifiedAfterWrite = false;
|
|
1642
|
-
}
|
|
1643
|
-
else if (!isError && name === "bash") {
|
|
1644
|
-
const command = parsed["command"];
|
|
1645
|
-
if (typeof command === "string" && isVerificationCommand(command) && filesWritten) {
|
|
1646
|
-
verifiedAfterWrite = true;
|
|
1647
|
-
}
|
|
1648
|
-
}
|
|
1649
|
-
history.push({ role: "tool", tool_call_id: call?.id ?? "", content: result });
|
|
1650
|
-
try {
|
|
1651
|
-
opts?.onToolActivity?.(describeToolCall(name, parsed), result, isError);
|
|
1652
|
-
}
|
|
1653
|
-
catch {
|
|
1654
|
-
// ignore observer errors
|
|
1655
|
-
}
|
|
1656
|
-
};
|
|
1657
|
-
for (const batch of planToolBatches(calls)) {
|
|
1658
|
-
// No new executions after a cancel: the current tool (if any) already
|
|
1659
|
-
// finished; stop before starting the next batch.
|
|
1660
|
-
throwIfCancelled(signal);
|
|
1661
|
-
if (batch.length === 1) {
|
|
1662
|
-
// Serial path: byte-identical to the pre-05 loop body.
|
|
1663
|
-
const call = batch[0].call;
|
|
1664
|
-
const name = call?.function?.name ?? "(unknown)";
|
|
1665
|
-
try {
|
|
1666
|
-
opts?.onPhase?.("tool", name);
|
|
1667
|
-
}
|
|
1668
|
-
catch {
|
|
1669
|
-
// ignore
|
|
1670
|
-
}
|
|
1671
|
-
let parsed;
|
|
1672
|
-
try {
|
|
1673
|
-
const raw = call?.function?.arguments ?? "{}";
|
|
1674
|
-
const v = JSON.parse(typeof raw === "string" ? raw : "{}");
|
|
1675
|
-
parsed = typeof v === "object" && v !== null ? v : {};
|
|
1676
|
-
}
|
|
1677
|
-
catch {
|
|
1678
|
-
parsed = {};
|
|
1679
|
-
const result = `Error: invalid call: invalid JSON arguments for tool "${name}" (arguments must be valid JSON). Fix the arguments and retry.`;
|
|
1680
|
-
history.push({ role: "tool", tool_call_id: call?.id ?? "", content: result });
|
|
1681
|
-
try {
|
|
1682
|
-
opts?.onToolActivity?.(describeToolCall(name, {}), result, true);
|
|
1683
|
-
}
|
|
1684
|
-
catch {
|
|
1685
|
-
// ignore observer errors
|
|
1686
|
-
}
|
|
1687
|
-
continue;
|
|
1688
|
-
}
|
|
1689
|
-
let result;
|
|
1690
|
-
try {
|
|
1691
|
-
result = await runOneTool(call, parsed, opts, execute);
|
|
1692
|
-
}
|
|
1693
|
-
catch (e) {
|
|
1694
|
-
if (isCancelError(e) || signal?.aborted)
|
|
1695
|
-
throw new LoopCancelledError();
|
|
1696
|
-
throw e;
|
|
1697
|
-
}
|
|
1698
|
-
commitToolResult(name, parsed, call, result);
|
|
1699
|
-
continue;
|
|
1700
|
-
}
|
|
1701
|
-
// Parallel batch: every member is pre-validated parallel-safe (see
|
|
1702
|
-
// planToolBatches), so runOneTool neither prompts nor blocks here.
|
|
1703
|
-
// Phases fire upfront in call order; results commit in call order, so
|
|
1704
|
-
// each call still shows separately and tool_call_ids re-pair by index.
|
|
1705
|
-
// A throw (cancel or execution error) aborts the turn exactly like the
|
|
1706
|
-
// serial path — the caller rolls the partial turn back.
|
|
1707
|
-
for (const member of batch) {
|
|
1708
|
-
try {
|
|
1709
|
-
opts?.onPhase?.("tool", member.call?.function?.name ?? "(unknown)");
|
|
1710
|
-
}
|
|
1711
|
-
catch {
|
|
1712
|
-
// ignore
|
|
1713
|
-
}
|
|
1714
|
-
}
|
|
1715
|
-
let results;
|
|
1716
|
-
try {
|
|
1717
|
-
results = await Promise.all(batch.map((member) => runOneTool(member.call, member.parsed, opts, execute)));
|
|
1718
|
-
}
|
|
1719
|
-
catch (e) {
|
|
1720
|
-
if (isCancelError(e) || signal?.aborted)
|
|
1721
|
-
throw new LoopCancelledError();
|
|
1722
|
-
throw e;
|
|
1723
|
-
}
|
|
1724
|
-
for (let i = 0; i < batch.length; i++) {
|
|
1725
|
-
const member = batch[i];
|
|
1726
|
-
commitToolResult(member.call?.function?.name ?? "(unknown)", member.parsed, member.call, results[i]);
|
|
1727
|
-
}
|
|
1728
|
-
}
|
|
1729
|
-
}
|
|
1189
|
+
return planBatches(calls);
|
|
1730
1190
|
}
|
|
1191
|
+
import { runLoopWithChat } from "./agent/loop.js";
|
|
1192
|
+
export { runLoopWithChat } from "./agent/loop.js";
|
|
1193
|
+
export { DEFAULT_MAX_TOTAL_TOOL_CALLS, DEFAULT_TOOL_TIMEOUT_MS, executeWithTimeout, resolveMaxTotalToolCalls, resolveToolTimeoutMs, } from "./agent/loop.js";
|
|
1731
1194
|
export async function runAgenticLoopForProvider(provider, apiKey, model, history, opts) {
|
|
1732
1195
|
return runLoopWithChat((h, o) => chatCompletionForProvider(provider, apiKey, model, h, {
|
|
1733
1196
|
onToken: o?.onToken,
|
|
@@ -1790,6 +1253,22 @@ export async function fetchModelsForProviderWithStatus(provider, apiKey, baseURL
|
|
|
1790
1253
|
return { models: [], ok: false };
|
|
1791
1254
|
const fallback = [...def.fallbackModels];
|
|
1792
1255
|
try {
|
|
1256
|
+
// Local runtimes: probe the loopback server (short timeout, never
|
|
1257
|
+
// throws) instead of a keyed /models fetch. Chat itself still flows
|
|
1258
|
+
// through the normal openai-chat path below.
|
|
1259
|
+
if (isLocalProviderId(provider)) {
|
|
1260
|
+
const res = await discoverLocalProvider(provider, { baseURL });
|
|
1261
|
+
return { models: res.models.map((m) => m.id), ok: res.ok };
|
|
1262
|
+
}
|
|
1263
|
+
if (provider === "kilo") {
|
|
1264
|
+
// Dynamic catalog via the Kilo gateway (anonymous when apiKey is "",
|
|
1265
|
+
// authenticated otherwise). TTL-cached inside src/kilo.ts; failures
|
|
1266
|
+
// return the offline placeholder uncached, exactly like other kinds.
|
|
1267
|
+
const res = await fetchKiloModelsWithStatus(apiKey);
|
|
1268
|
+
if (!res.ok)
|
|
1269
|
+
return { models: [...KILO_FALLBACK_MODELS], ok: false };
|
|
1270
|
+
return { models: res.models, ok: true };
|
|
1271
|
+
}
|
|
1793
1272
|
if (provider === "opencode-zen") {
|
|
1794
1273
|
// Byte-identical rule: reuse fetchModels (compatibility-filtered).
|
|
1795
1274
|
const endpoint = zenEndpointOverride ?? chatEndpointFor(provider, baseURL);
|