atom-agent 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/zen.js ADDED
@@ -0,0 +1,1862 @@
1
+ // Minimal OpenCode Zen provider logic (carried over from chat.mjs).
2
+ // Scope: chat/completions-family models ONLY (DeepSeek, Kimi, GLM, MiniMax,
3
+ // Big Pickle, free chat models). Responses-family (/responses: GPT/Grok/Muse
4
+ // Spark), Messages-family (/messages: Claude/Qwen), and Gemini paths use
5
+ // different Zen request shapes and are out of scope.
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import * as path from "node:path";
8
+ import { MAX_TOOL_STEPS, TOOL_DEFINITIONS, describeToolCall, executeTool, getTodos, invalidCall, needsApproval, toolNames, validateAskQuestionArgs, validateToolArgs, } from "./tools.js";
9
+ import { chatEndpointFor, getProvider, modelsUrlForProvider, providerLabel, } from "./providers.js";
10
+ import { ANTHROPIC_VERSION, anthropicHeaders, buildAnthropicBody, buildGeminiBody, geminiChatUrl, geminiGenerateUrl, geminiHeaders, parseAnthropicJson, parseAnthropicModelsList, parseGeminiJson, parseGeminiModelsList, parseOpenAIModelsList, readAnthropicSSEMessage, readGeminiSSEMessage, } from "./adapters.js";
11
+ import { primaryTarget } from "./permissions.js";
12
+ import { SYSTEM_PROMPT } from "./system.js";
13
+ export { MAX_TOOL_STEPS };
14
+ // Re-exported so existing `SYSTEM_PROMPT` imports keep working; the
15
+ // owner-editable source of truth lives in src/system.ts.
16
+ export { SYSTEM_PROMPT };
17
+ export const DEFAULT_ENDPOINT = "https://opencode.ai/zen/v1/chat/completions";
18
+ export const MODELS_URL_DEFAULT = "https://opencode.ai/zen/v1/models";
19
+ // Task 5 default: strongest tool-reliable chat/completions default available,
20
+ // verified against the live /models list + https://opencode.ai/docs/zen on
21
+ // 2026-09-08 (endpoint chat/completions, Tool Calls support, not deprecated,
22
+ // in REASONING_EFFORT_SUPPORTED_MODELS, verified 1M context window). Free
23
+ // models (big-pickle etc.) stay in FALLBACK_MODELS, selectable via /model.
24
+ export const DEFAULT_MODEL = "deepseek-v4-pro";
25
+ export const AGENTS_CHAR_CAP = 12 * 1024;
26
+ // ---- Conversation-history budget (deterministic, no extra model calls) ----
27
+ // Long sessions can't bloat context, cost, and latency: the shared loop core
28
+ // trims history to BOTH caps before every POST (uniform across providers).
29
+ // Optional env overrides (invalid/unset → defaults):
30
+ // - ATOM_MAX_HISTORY_MESSAGES, clamped to 10–1000 (default 100)
31
+ // - ATOM_MAX_HISTORY_CHARS, clamped to 10_000–2_000_000 (default 200_000)
32
+ export const MAX_HISTORY_MESSAGES = 100;
33
+ export const MAX_HISTORY_CHARS = 200_000;
34
+ function clampEnvInt(raw, min, max, fallback) {
35
+ if (raw === undefined)
36
+ return fallback;
37
+ const text = raw.trim();
38
+ if (!/^\d+$/.test(text))
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
+ }
59
+ export const EFFORT_OPTIONS = [
60
+ "default",
61
+ "low",
62
+ "medium",
63
+ "high",
64
+ "max",
65
+ ];
66
+ // Verified-support set for `reasoning_effort`: the chat/completions-family
67
+ // models Zen documents Thinking Effort for. Any other model omits the
68
+ // param (setting kept, warning shown, status shows "(unsupported)").
69
+ export const REASONING_EFFORT_SUPPORTED_MODELS = new Set([
70
+ "kimi-k2.5",
71
+ "kimi-k2.6",
72
+ "glm-5.1",
73
+ "glm-5.2",
74
+ "deepseek-v4-pro",
75
+ "deepseek-v4-flash",
76
+ ]);
77
+ export function isEffortSupported(model) {
78
+ return REASONING_EFFORT_SUPPORTED_MODELS.has(model);
79
+ }
80
+ // Wire value for the POST body, or undefined when the param must be
81
+ // omitted (Default, unsupported model, or unknown effort string).
82
+ export function reasoningEffortParam(effort, model) {
83
+ if (!effort || effort === "default")
84
+ return undefined;
85
+ if (!isEffortSupported(model))
86
+ return undefined;
87
+ if (effort === "low" || effort === "medium" || effort === "high" || effort === "max") {
88
+ return effort;
89
+ }
90
+ return undefined;
91
+ }
92
+ // Deterministic size of one message: string content counts as-is, anything
93
+ // else counts stringified; assistant tool_calls and tool ids count too (they
94
+ // ride on every POST). History chars = the sum over all messages.
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
+ }
244
+ function finiteCount(value) {
245
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
246
+ ? Math.floor(value)
247
+ : undefined;
248
+ }
249
+ // Extract only the token counts the API actually reported. Returns
250
+ // undefined when the payload carries no usable usage numbers.
251
+ export function parseUsage(value) {
252
+ if (typeof value !== "object" || value === null)
253
+ return undefined;
254
+ const o = value;
255
+ const out = {};
256
+ const prompt = finiteCount(o["prompt_tokens"]);
257
+ if (prompt !== undefined)
258
+ out.prompt_tokens = prompt;
259
+ const completion = finiteCount(o["completion_tokens"]);
260
+ if (completion !== undefined)
261
+ out.completion_tokens = completion;
262
+ const total = finiteCount(o["total_tokens"]);
263
+ if (total !== undefined)
264
+ out.total_tokens = total;
265
+ return out.prompt_tokens !== undefined ||
266
+ out.completion_tokens !== undefined ||
267
+ out.total_tokens !== undefined
268
+ ? out
269
+ : undefined;
270
+ }
271
+ // Extract a one-short-segment reasoning label from response metadata the
272
+ // API actually sent (e.g. a reasoning-effort value). A non-empty
273
+ // reasoning_content blob (DeepSeek-style thinking text) is reported as the
274
+ // label "present" rather than inlined. Returns undefined when the payload
275
+ // carries no reasoning metadata.
276
+ export function parseReasoningLabel(value) {
277
+ if (typeof value === "string") {
278
+ const text = value.trim();
279
+ if (text.length === 0)
280
+ return undefined;
281
+ return text.length > 24 ? `${text.slice(0, 24)}…` : text;
282
+ }
283
+ if (typeof value !== "object" || value === null)
284
+ return undefined;
285
+ const o = value;
286
+ for (const key of ["reasoning_effort", "reasoningEffort", "effort"]) {
287
+ const hit = parseReasoningLabel(o[key]);
288
+ if (hit !== undefined)
289
+ return hit;
290
+ }
291
+ if (o["reasoning"] !== undefined) {
292
+ const hit = parseReasoningLabel(o["reasoning"]);
293
+ if (hit !== undefined)
294
+ return hit;
295
+ }
296
+ const content = o["reasoning_content"];
297
+ if (typeof content === "string" && content.trim().length > 0)
298
+ return "present";
299
+ return undefined;
300
+ }
301
+ // Whole-turn cancellation: thrown when the user cancels (Ctrl+C) mid-loop.
302
+ // The App catches it, rolls the partial turn back (same splice contract as
303
+ // POST failure), renders one dim `(cancelled)` line, and returns to a clean
304
+ // input state. Never retried, never a tool result.
305
+ export class LoopCancelledError extends Error {
306
+ constructor() {
307
+ super("(cancelled)");
308
+ this.name = "LoopCancelledError";
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;
330
+ const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
331
+ const RETRY_AFTER_CAP_MS = 30_000;
332
+ export function defaultSleep(ms) {
333
+ return new Promise((resolve) => setTimeout(resolve, ms));
334
+ }
335
+ // Exponential backoff 1s -> 2s, honoring Retry-After (seconds or HTTP date)
336
+ // capped at 30s. `attempt` is the 0-based index of the failure just seen
337
+ // (0 => first failure => 1s).
338
+ export function getRetryDelay(attempt, res) {
339
+ try {
340
+ const raw = res
341
+ ?.headers?.get?.("Retry-After");
342
+ if (typeof raw === "string" && raw.trim().length > 0) {
343
+ const s = raw.trim();
344
+ const secs = Number(s);
345
+ if (Number.isFinite(secs) && !Number.isNaN(secs) && secs >= 0) {
346
+ return Math.min(Math.max(secs * 1000, 0), RETRY_AFTER_CAP_MS);
347
+ }
348
+ const when = Date.parse(s);
349
+ if (!Number.isNaN(when)) {
350
+ const diff = when - Date.now();
351
+ if (diff > 0)
352
+ return Math.min(diff, RETRY_AFTER_CAP_MS);
353
+ return 0;
354
+ }
355
+ }
356
+ }
357
+ catch {
358
+ // fall through to backoff
359
+ }
360
+ return attempt === 0 ? 1000 : 2000;
361
+ }
362
+ async function safeErrorText(res) {
363
+ try {
364
+ const t = await res.text?.();
365
+ return typeof t === "string" ? t : "";
366
+ }
367
+ catch {
368
+ return "";
369
+ }
370
+ }
371
+ function hasStreamBody(res) {
372
+ try {
373
+ return res.body != null;
374
+ }
375
+ catch {
376
+ return false;
377
+ }
378
+ }
379
+ // Curated chat/completions-compatible models, verified from
380
+ // https://opencode.ai/docs/zen. Used when the live model list cannot be
381
+ // fetched or cannot confirm compatibility (network/auth/429/shape issues).
382
+ export const FALLBACK_MODELS = [
383
+ "big-pickle",
384
+ "mimo-v2.5-free",
385
+ "ling-3.0-flash-fin-free",
386
+ "nemotron-3-ultra-free",
387
+ "nemotron-3.5-lightning-free",
388
+ "deepseek-v4-pro",
389
+ "deepseek-v4-flash",
390
+ "deepseek-v4-flash-vision-exp",
391
+ "kimi-k2.5",
392
+ "kimi-k2.6",
393
+ "kimi-k2.7-code",
394
+ "kimi-k3",
395
+ "glm-5.1",
396
+ "glm-5.2",
397
+ "glm-5.3",
398
+ "glm-5.3-flash",
399
+ "minimax-m2.5",
400
+ "minimax-m2.7",
401
+ "minimax-m3",
402
+ ];
403
+ export function endpointConfig() {
404
+ return {
405
+ endpoint: process.env.OPENCODE_ZEN_ENDPOINT ?? DEFAULT_ENDPOINT,
406
+ apiKey: process.env.OPENCODE_ZEN_API_KEY ?? "",
407
+ model: process.env.OPENCODE_ZEN_MODEL ?? DEFAULT_MODEL,
408
+ };
409
+ }
410
+ // Derive the models URL from a (possibly custom) chat/completions endpoint.
411
+ export function modelsUrl(endpoint) {
412
+ const suffix = "/chat/completions";
413
+ if (endpoint.endsWith(suffix)) {
414
+ return endpoint.slice(0, -suffix.length) + "/models";
415
+ }
416
+ return MODELS_URL_DEFAULT;
417
+ }
418
+ // An entry is chat/completions-compatible when its metadata says so.
419
+ // Returns null when the entry carries no usable compatibility metadata.
420
+ function compatibilityHint(entry) {
421
+ if (typeof entry === "string")
422
+ return null;
423
+ if (typeof entry !== "object" || entry === null)
424
+ return null;
425
+ const e = entry;
426
+ const hint = ["family", "type", "api", "endpoint", "path"]
427
+ .map((k) => e[k])
428
+ .filter((v) => typeof v === "string")
429
+ .join(" ")
430
+ .toLowerCase();
431
+ if (!hint)
432
+ return null;
433
+ if (hint.includes("chat") || hint.includes("completions"))
434
+ return true;
435
+ if (hint.includes("responses") || hint.includes("messages"))
436
+ return false;
437
+ return null;
438
+ }
439
+ function entryId(entry) {
440
+ if (typeof entry === "string")
441
+ return entry || null;
442
+ if (typeof entry !== "object" || entry === null)
443
+ return null;
444
+ const e = entry;
445
+ const id = e["id"] ?? e["name"];
446
+ return typeof id === "string" && id.length > 0 ? id : null;
447
+ }
448
+ export async function fetchModelsWithStatus(endpoint, apiKey) {
449
+ try {
450
+ const res = await fetch(modelsUrl(endpoint), {
451
+ headers: { Authorization: `Bearer ${apiKey}` },
452
+ });
453
+ if (!res.ok)
454
+ return { models: [...FALLBACK_MODELS], ok: false };
455
+ const data = await res.json();
456
+ const entries = Array.isArray(data)
457
+ ? data
458
+ : data?.data;
459
+ if (!Array.isArray(entries) || entries.length === 0) {
460
+ return { models: [...FALLBACK_MODELS], ok: false };
461
+ }
462
+ const known = new Set(FALLBACK_MODELS);
463
+ const picked = [];
464
+ for (const entry of entries) {
465
+ const id = entryId(entry);
466
+ if (!id)
467
+ continue;
468
+ const hint = compatibilityHint(entry);
469
+ if (hint === false)
470
+ continue; // known-incompatible family
471
+ if (hint === true) {
472
+ picked.push(id);
473
+ }
474
+ else if (known.has(id)) {
475
+ picked.push(id); // live-confirmed, curated-compatible
476
+ }
477
+ }
478
+ if (picked.length > 0)
479
+ return { models: picked, ok: true };
480
+ return { models: [...FALLBACK_MODELS], ok: false };
481
+ }
482
+ catch {
483
+ return { models: [...FALLBACK_MODELS], ok: false };
484
+ }
485
+ }
486
+ export async function fetchModels(endpoint, apiKey) {
487
+ const r = await fetchModelsWithStatus(endpoint, apiKey);
488
+ return r.models;
489
+ }
490
+ // Parse one SSE event stream from a chat-completions response body.
491
+ // Contract (OpenAI-compatible streaming):
492
+ // - The byte stream is split into lines across chunk boundaries (partials
493
+ // are buffered until "\n").
494
+ // - Lines starting with ":" are comments and ignored; blank lines ignored;
495
+ // only "data:" lines carry payloads.
496
+ // - "data: [DONE]" ends the stream; anything after it is ignored.
497
+ // - Other "data:" payloads are JSON; malformed JSON lines are skipped
498
+ // (never crash). Each event contributes choices[0].delta (message also
499
+ // accepted for tolerance): delta.content strings accumulate into full
500
+ // text (emitted via onToken), delta.tool_calls accumulate by `index`
501
+ // (id from the first non-empty value, name/arguments concatenated).
502
+ // - Tool deltas revealing a name fire onToolDelta + onPhase("tool", name)
503
+ // immediately, before execution.
504
+ // - Slots with an id but no name at [DONE] are dropped with an onWarning
505
+ // message and never returned (keeps assistant/tool pairing valid).
506
+ // - A stream that ends without [DONE] throws a truncation error.
507
+ // - A stream with zero "data:" lines is treated as a non-SSE JSON payload
508
+ // (tolerance for bodies that are really single-shot JSON) and parsed as
509
+ // choices[0].message like the non-streaming fallback.
510
+ export async function readSSEMessage(res, opts) {
511
+ const body = res.body;
512
+ const decoder = new TextDecoder();
513
+ let buffer = "";
514
+ let rawText = "";
515
+ let fullText = "";
516
+ let sawData = false;
517
+ let sawDone = false;
518
+ let streamingAnnounced = false;
519
+ const partials = [];
520
+ // Usage reported by the stream (typically a final chunk with empty
521
+ // choices and a top-level `usage` object). Last value seen per key wins:
522
+ // one stream carries one POST's usage. `streamReasoning` is the first
523
+ // reasoning label seen in any delta.
524
+ let streamUsage;
525
+ let streamReasoning;
526
+ // Accumulated thinking text (see onThinking): kept apart from fullText so
527
+ // reasoning never leaks into the answer, history, or tool arguments.
528
+ let fullThinking = "";
529
+ function announceStreaming() {
530
+ if (!streamingAnnounced) {
531
+ streamingAnnounced = true;
532
+ try {
533
+ opts?.onPhase?.("streaming");
534
+ }
535
+ catch {
536
+ // observer errors never break the stream
537
+ }
538
+ }
539
+ }
540
+ function processLine(rawLine) {
541
+ let line = rawLine;
542
+ if (line.endsWith("\r"))
543
+ line = line.slice(0, -1);
544
+ if (line.length === 0)
545
+ return;
546
+ if (line.startsWith(":"))
547
+ return; // SSE comment / keep-alive
548
+ if (!line.startsWith("data:"))
549
+ return; // event:/id:/retry: ignored
550
+ sawData = true;
551
+ let payload = line.slice("data:".length);
552
+ if (payload.startsWith(" "))
553
+ payload = payload.slice(1);
554
+ if (payload === "[DONE]") {
555
+ sawDone = true;
556
+ return;
557
+ }
558
+ if (payload.length === 0)
559
+ return;
560
+ let evt;
561
+ try {
562
+ evt = JSON.parse(payload);
563
+ }
564
+ catch {
565
+ return; // malformed JSON data line: skip, never crash
566
+ }
567
+ // Usage rides on its own (often final) chunk with empty choices, so it
568
+ // is read from the event top level before the delta handling below.
569
+ const usageHit = parseUsage(evt?.usage);
570
+ if (usageHit !== undefined) {
571
+ streamUsage = { ...streamUsage, ...usageHit };
572
+ }
573
+ const choice = evt
574
+ ?.choices?.[0];
575
+ const delta = (choice?.delta ?? choice?.message);
576
+ if (typeof delta !== "object" || delta === null)
577
+ return;
578
+ if (streamReasoning === undefined) {
579
+ const hit = parseReasoningLabel(delta);
580
+ if (hit !== undefined)
581
+ streamReasoning = hit;
582
+ }
583
+ const content = delta.content;
584
+ if (typeof content === "string" && content.length > 0) {
585
+ fullText += content;
586
+ announceStreaming();
587
+ try {
588
+ opts?.onPhase?.("streaming");
589
+ }
590
+ catch {
591
+ // ignore observer errors
592
+ }
593
+ try {
594
+ opts?.onToken?.(fullText);
595
+ }
596
+ catch {
597
+ // ignore observer errors
598
+ }
599
+ }
600
+ // Thinking deltas ride alongside (often before) content deltas.
601
+ // `reasoning_content` (DeepSeek-style) wins; a plain-string
602
+ // `reasoning` field is the fallback some gateways use. Object-shaped
603
+ // `reasoning` metadata is NOT text — only the label reader touches it.
604
+ const thinkingFrag = delta.reasoning_content;
605
+ if (typeof thinkingFrag === "string" && thinkingFrag.length > 0) {
606
+ fullThinking += thinkingFrag;
607
+ try {
608
+ opts?.onThinking?.(fullThinking);
609
+ }
610
+ catch {
611
+ // ignore observer errors
612
+ }
613
+ }
614
+ else {
615
+ const altFrag = delta.reasoning;
616
+ if (typeof altFrag === "string" && altFrag.length > 0) {
617
+ fullThinking += altFrag;
618
+ try {
619
+ opts?.onThinking?.(fullThinking);
620
+ }
621
+ catch {
622
+ // ignore observer errors
623
+ }
624
+ }
625
+ }
626
+ const tcs = delta.tool_calls;
627
+ if (Array.isArray(tcs)) {
628
+ announceStreaming();
629
+ for (const tc of tcs) {
630
+ const idx = typeof tc?.index === "number" && tc.index >= 0 ? tc.index : 0;
631
+ while (partials.length <= idx)
632
+ partials.push({ id: "", name: "", args: "" });
633
+ const slot = partials[idx];
634
+ if (typeof tc?.id === "string" && tc.id.length > 0 && slot.id.length === 0) {
635
+ slot.id = tc.id;
636
+ }
637
+ if (typeof tc?.type === "string" && !slot.type)
638
+ slot.type = tc.type;
639
+ const fn = tc?.function ?? {};
640
+ const nameFrag = typeof fn?.name === "string" ? fn.name : "";
641
+ if (nameFrag.length > 0)
642
+ slot.name += nameFrag;
643
+ if (typeof fn?.arguments === "string" && fn.arguments.length > 0)
644
+ slot.args += fn.arguments;
645
+ // Live hint: every delta that contributes a name fragment re-emits
646
+ // the accumulated name, so the TUI hint grows "re" -> "read" live.
647
+ if (nameFrag.length > 0 && slot.name.length > 0) {
648
+ try {
649
+ opts?.onToolDelta?.(slot.name, idx);
650
+ }
651
+ catch {
652
+ // ignore
653
+ }
654
+ try {
655
+ opts?.onPhase?.("tool", slot.name);
656
+ }
657
+ catch {
658
+ // ignore
659
+ }
660
+ }
661
+ }
662
+ }
663
+ }
664
+ function drainBuffer() {
665
+ let nl;
666
+ while ((nl = buffer.indexOf("\n")) >= 0) {
667
+ const line = buffer.slice(0, nl);
668
+ buffer = buffer.slice(nl + 1);
669
+ processLine(line);
670
+ if (sawDone)
671
+ return;
672
+ }
673
+ }
674
+ if (body == null) {
675
+ throw new Error("Empty reply from model (unexpected payload).");
676
+ }
677
+ try {
678
+ if (typeof body.getReader === "function") {
679
+ const reader = body.getReader();
680
+ try {
681
+ for (;;) {
682
+ let chunk;
683
+ try {
684
+ chunk = await reader.read();
685
+ }
686
+ catch (e) {
687
+ throw new Error(`Truncated stream from model (connection aborted: ${e instanceof Error ? e.message : String(e)}).`);
688
+ }
689
+ if (chunk.done)
690
+ break;
691
+ const v = chunk.value;
692
+ const text = typeof v === "string" ? v : decoder.decode(v, { stream: true });
693
+ rawText += text;
694
+ buffer += text;
695
+ drainBuffer();
696
+ if (sawDone) {
697
+ try {
698
+ await reader.cancel?.();
699
+ }
700
+ catch {
701
+ // ignore
702
+ }
703
+ break;
704
+ }
705
+ }
706
+ if (!sawDone && buffer.length > 0) {
707
+ processLine(buffer);
708
+ buffer = "";
709
+ }
710
+ }
711
+ finally {
712
+ try {
713
+ reader.releaseLock?.();
714
+ }
715
+ catch {
716
+ // ignore
717
+ }
718
+ }
719
+ }
720
+ else if (typeof body[Symbol.asyncIterator] === "function") {
721
+ for await (const v of body) {
722
+ const text = typeof v === "string" ? v : decoder.decode(v, { stream: true });
723
+ rawText += text;
724
+ buffer += text;
725
+ drainBuffer();
726
+ if (sawDone)
727
+ break;
728
+ }
729
+ if (!sawDone && buffer.length > 0) {
730
+ processLine(buffer);
731
+ buffer = "";
732
+ }
733
+ }
734
+ else {
735
+ // Unknown body shape: fall back to whole-text read when available.
736
+ const textFn = res.text;
737
+ if (typeof textFn === "function") {
738
+ const txt = await textFn.call(res);
739
+ rawText = String(txt ?? "");
740
+ buffer = rawText;
741
+ drainBuffer();
742
+ if (buffer.length > 0) {
743
+ processLine(buffer);
744
+ buffer = "";
745
+ }
746
+ }
747
+ else {
748
+ throw new Error("Empty reply from model (unexpected payload).");
749
+ }
750
+ }
751
+ }
752
+ catch (e) {
753
+ if (e instanceof Error && e.message.startsWith("Truncated stream"))
754
+ throw e;
755
+ if (e instanceof Error && e.message.startsWith("Empty reply"))
756
+ throw e;
757
+ throw new Error(`Truncated stream from model (connection aborted: ${e instanceof Error ? e.message : String(e)}).`);
758
+ }
759
+ // Tolerance: a body with no SSE data lines is really single-shot JSON.
760
+ if (!sawData) {
761
+ const candidate = rawText.trim();
762
+ if (candidate.length > 0) {
763
+ try {
764
+ const data = JSON.parse(candidate);
765
+ const msg = data?.choices?.[0]?.message;
766
+ if (msg !== undefined) {
767
+ const calls = Array.isArray(msg?.tool_calls) ? msg.tool_calls : [];
768
+ const content = msg?.content ?? null;
769
+ if (calls.length === 0 && (content == null || content.trim() === "")) {
770
+ throw new Error("Empty reply from model (unexpected payload).");
771
+ }
772
+ const result = {
773
+ content,
774
+ tool_calls: calls.length > 0 ? calls : undefined,
775
+ };
776
+ const usage = parseUsage(data?.usage);
777
+ if (usage !== undefined)
778
+ result.usage = usage;
779
+ const reasoning = parseReasoningLabel(msg);
780
+ if (reasoning !== undefined)
781
+ result.reasoning = reasoning;
782
+ return result;
783
+ }
784
+ }
785
+ catch (e) {
786
+ if (e instanceof Error && e.message.startsWith("Empty reply"))
787
+ throw e;
788
+ // not JSON either -> fall through to truncation error below
789
+ }
790
+ }
791
+ throw new Error("Truncated stream from model (connection aborted before [DONE]).");
792
+ }
793
+ if (!sawDone) {
794
+ throw new Error("Truncated stream from model (connection aborted before [DONE]).");
795
+ }
796
+ const calls = [];
797
+ for (let i = 0; i < partials.length; i++) {
798
+ const p = partials[i];
799
+ if (!p.name) {
800
+ if (p.id) {
801
+ try {
802
+ opts?.onWarning?.(`dropped tool call ${p.id} with no function name`);
803
+ }
804
+ catch {
805
+ // ignore
806
+ }
807
+ }
808
+ continue;
809
+ }
810
+ calls.push({
811
+ id: p.id || `stream-${i}`,
812
+ ...(p.type ? { type: p.type } : { type: "function" }),
813
+ function: { name: p.name, arguments: p.args },
814
+ });
815
+ }
816
+ if (calls.length === 0 && fullText.trim() === "") {
817
+ throw new Error("Empty reply from model (unexpected payload).");
818
+ }
819
+ const result = {
820
+ content: fullText.length > 0 ? fullText : null,
821
+ tool_calls: calls.length > 0 ? calls : undefined,
822
+ };
823
+ if (streamUsage !== undefined)
824
+ result.usage = streamUsage;
825
+ if (streamReasoning !== undefined)
826
+ result.reasoning = streamReasoning;
827
+ return result;
828
+ }
829
+ // Streaming chat POST with tools attached (tool_choice omitted, so the
830
+ // default auto applies). Sends {..., stream:true} plus `reasoning_effort`
831
+ // ONLY when opts.reasoningEffort is non-Default AND the model is in
832
+ // REASONING_EFFORT_SUPPORTED_MODELS (see reasoningEffortParam); otherwise
833
+ // the param is omitted. Parses the SSE event stream (see readSSEMessage).
834
+ // When the response has no SSE body (plain {ok, json()} mocks and other
835
+ // non-streaming payloads) it falls back to the original single-JSON parse,
836
+ // unchanged. Returns the raw assistant message: either final content or
837
+ // tool_calls the caller must execute, plus `usage`/`reasoning` only when
838
+ // the response actually carried them (usage: top-level `usage` on JSON or
839
+ // SSE final chunks; reasoning: message/delta reasoning metadata).
840
+ // Throws on HTTP error, empty reply, or a truncated stream.
841
+ // - Network throws and HTTP 429/500/502/503/504 are retried up to 2 times
842
+ // (3 attempts) with 1s->2s backoff, honoring Retry-After capped at 30s.
843
+ // Each retry emits onPhase("retry", detail). Other 4xx fail fast with
844
+ // the existing `Zen HTTP {status}` message.
845
+ // - Callers must roll back the user turn on failure (see App submit).
846
+ // `errorLabel` prefixes HTTP errors (`{label} HTTP {status}`, default "Zen");
847
+ // the dispatcher passes providerLabel(provider) for non-zen openai-chat
848
+ // providers so users see e.g. `OpenAI HTTP 401` instead of `Zen HTTP 401`.
849
+ // Legacy `function_call` shape is intentionally ignored.
850
+ export async function chatCompletion(endpoint, apiKey, model, history, opts, errorLabel = "Zen") {
851
+ const sleep = opts?.sleep ?? defaultSleep;
852
+ const signal = opts?.signal ?? null;
853
+ let lastError = null;
854
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
855
+ try {
856
+ throwIfCancelled(signal);
857
+ try {
858
+ opts?.onPhase?.("thinking");
859
+ }
860
+ catch {
861
+ // ignore observer errors
862
+ }
863
+ const effortParam = reasoningEffortParam(opts?.reasoningEffort, model);
864
+ const summaryOpts = opts;
865
+ const payload = {
866
+ model,
867
+ messages: history,
868
+ stream: true,
869
+ };
870
+ // Compaction path only: tools disabled means NO `tools` key at all
871
+ // (asserted in tests); the normal loop always sends the schema.
872
+ if (!summaryOpts?.disableTools) {
873
+ payload["tools"] = TOOL_DEFINITIONS;
874
+ }
875
+ // Compaction path only: cap output (openai-chat kind uses max_tokens).
876
+ if (typeof summaryOpts?.maxOutputTokens === "number" &&
877
+ Number.isFinite(summaryOpts.maxOutputTokens) &&
878
+ summaryOpts.maxOutputTokens > 0) {
879
+ payload["max_tokens"] = Math.floor(summaryOpts.maxOutputTokens);
880
+ }
881
+ if (effortParam !== undefined)
882
+ payload["reasoning_effort"] = effortParam;
883
+ const res = await fetch(endpoint, {
884
+ method: "POST",
885
+ headers: {
886
+ "Content-Type": "application/json",
887
+ Authorization: `Bearer ${apiKey}`,
888
+ },
889
+ body: JSON.stringify(payload),
890
+ ...(signal ? { signal } : {}),
891
+ });
892
+ if (!res.ok) {
893
+ const errText = await safeErrorText(res);
894
+ const err = new Error(`${errorLabel} HTTP ${res.status}: ${errText.slice(0, 300)}`);
895
+ if (!RETRYABLE_STATUS.has(res.status))
896
+ throw err;
897
+ if (attempt < MAX_RETRIES) {
898
+ throwIfCancelled(signal);
899
+ const delay = getRetryDelay(attempt, res);
900
+ try {
901
+ opts?.onPhase?.("retry", `attempt ${attempt + 1}/${MAX_RETRIES} after ${delay}ms (HTTP ${res.status})`);
902
+ }
903
+ catch {
904
+ // ignore
905
+ }
906
+ await sleep(delay);
907
+ throwIfCancelled(signal);
908
+ lastError = err;
909
+ continue;
910
+ }
911
+ throw err;
912
+ }
913
+ if (!hasStreamBody(res)) {
914
+ const data = (await res.json());
915
+ const msg = data?.choices?.[0]?.message;
916
+ const calls = Array.isArray(msg?.tool_calls) ? msg.tool_calls : [];
917
+ const content = msg?.content ?? null;
918
+ if (calls.length === 0 && (content == null || content.trim() === "")) {
919
+ throw new Error("Empty reply from model (unexpected payload).");
920
+ }
921
+ // Non-streaming bodies carry thinking whole, if at all — same
922
+ // channel rules as the SSE path (strings only, never the answer).
923
+ const wholeThinking = msg?.reasoning_content;
924
+ if (typeof wholeThinking === "string" && wholeThinking.length > 0) {
925
+ try {
926
+ opts?.onThinking?.(wholeThinking);
927
+ }
928
+ catch {
929
+ // ignore observer errors
930
+ }
931
+ }
932
+ else {
933
+ const wholeAlt = msg?.reasoning;
934
+ if (typeof wholeAlt === "string" && wholeAlt.length > 0) {
935
+ try {
936
+ opts?.onThinking?.(wholeAlt);
937
+ }
938
+ catch {
939
+ // ignore observer errors
940
+ }
941
+ }
942
+ }
943
+ const result = {
944
+ content,
945
+ tool_calls: calls.length > 0 ? calls : undefined,
946
+ };
947
+ const usage = parseUsage(data?.usage);
948
+ if (usage !== undefined)
949
+ result.usage = usage;
950
+ const reasoning = parseReasoningLabel(msg);
951
+ if (reasoning !== undefined)
952
+ result.reasoning = reasoning;
953
+ return result;
954
+ }
955
+ return await readSSEMessage(res, opts);
956
+ }
957
+ catch (e) {
958
+ // Cancellations (Ctrl+C / AbortSignal) are final: never retry, never
959
+ // reframe — propagate so the caller can roll back + show (cancelled).
960
+ if (isCancelError(e) || signal?.aborted)
961
+ throw new LoopCancelledError();
962
+ // HTTP failures already handled above (retry or fail-fast): rethrow
963
+ // without treating them as retryable network errors.
964
+ if (e instanceof Error && e.message.startsWith(`${errorLabel} HTTP`))
965
+ throw e;
966
+ // Parsing/validation failures (empty reply, truncation) are permanent:
967
+ // never retry, surface immediately so the caller can roll back.
968
+ if (e instanceof Error &&
969
+ (e.message.startsWith("Empty reply") || e.message.startsWith("Truncated stream"))) {
970
+ throw e;
971
+ }
972
+ // Anything else is a network-level throw: retry when attempts remain.
973
+ if (attempt < MAX_RETRIES) {
974
+ const delay = getRetryDelay(attempt, undefined);
975
+ try {
976
+ opts?.onPhase?.("retry", `attempt ${attempt + 1}/${MAX_RETRIES} after ${delay}ms (${e instanceof Error ? e.message : String(e)})`);
977
+ }
978
+ catch {
979
+ // ignore
980
+ }
981
+ try {
982
+ await sleep(delay);
983
+ }
984
+ catch {
985
+ // a failing sleep must not mask the original error
986
+ }
987
+ lastError = e;
988
+ continue;
989
+ }
990
+ throw e;
991
+ }
992
+ }
993
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
994
+ }
995
+ // Agentic loop for one user turn: thin wrapper over the shared runLoopWithChat
996
+ // core below (single loop implementation). Send → while the response carries
997
+ // tool_calls (max MAX_TOOL_STEPS tool rounds), append the assistant message,
998
+ // execute each tool locally, append {role:'tool'} results, resend.
999
+ // Streaming: each POST streams SSE tokens (onToken gets the growing text,
1000
+ // onPhase reports thinking|streaming|tool|retry|done, onToolDelta fires when
1001
+ // a tool name first appears mid-stream). A model that returns no tool_calls
1002
+ // ends the loop (graceful fallback for models without tool support). Tool
1003
+ // errors are results the model sees — NOTHING is rolled back here; only a
1004
+ // POST failure (HTTP/network/empty/truncated) throws (and the caller rolls
1005
+ // back the user turn, as before, including any streaming draft).
1006
+ export async function runAgenticLoop(endpoint, apiKey, model, history, opts) {
1007
+ return runLoopWithChat((h, o) => chatCompletion(endpoint, apiKey, model, h, {
1008
+ onToken: o?.onToken,
1009
+ onPhase: o?.onPhase,
1010
+ onToolDelta: o?.onToolDelta,
1011
+ onWarning: o?.onWarning,
1012
+ onThinking: o?.onThinking,
1013
+ sleep: o?.sleep,
1014
+ reasoningEffort: o?.reasoningEffort,
1015
+ signal: o?.signal,
1016
+ }), history, opts);
1017
+ }
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
+ // AGENTS.md loading: <cwd>/AGENTS.md (or $OPENCODE_AGENTS_PATH when set)
1107
+ // is appended to the system prompt at startup, capped at 12KB.
1108
+ export function agentsFilePath(cwd = process.cwd()) {
1109
+ return process.env.OPENCODE_AGENTS_PATH ?? path.join(cwd, "AGENTS.md");
1110
+ }
1111
+ export function loadAgentsPrompt(cwd = process.cwd()) {
1112
+ try {
1113
+ const p = agentsFilePath(cwd);
1114
+ if (!existsSync(p))
1115
+ return null;
1116
+ let text = readFileSync(p, "utf8");
1117
+ if (text.length > AGENTS_CHAR_CAP) {
1118
+ text = text.slice(0, AGENTS_CHAR_CAP) + "\n[truncated: AGENTS.md exceeded 12KB]";
1119
+ }
1120
+ return text;
1121
+ }
1122
+ catch {
1123
+ return null;
1124
+ }
1125
+ }
1126
+ export function buildSystemPrompt(cwd = process.cwd()) {
1127
+ // Two layers: src/system.ts base one-liner + repo AGENTS.md overlay.
1128
+ // Owner knobs: edit the one-liner in src/system.ts for the base identity;
1129
+ // add repo instructions to AGENTS.md for the overlay.
1130
+ const extra = loadAgentsPrompt(cwd);
1131
+ return extra ? `${SYSTEM_PROMPT}\n\n${extra}` : SYSTEM_PROMPT;
1132
+ }
1133
+ function providerHttpError(provider, status, text) {
1134
+ return new Error(`${providerLabel(provider)} HTTP ${status}: ${text.slice(0, 300)}`);
1135
+ }
1136
+ export async function chatCompletionAnthropic(apiKey, model, history, opts) {
1137
+ const sleep = opts?.sleep ?? defaultSleep;
1138
+ const signal = opts?.signal ?? null;
1139
+ let lastError = null;
1140
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
1141
+ try {
1142
+ throwIfCancelled(signal);
1143
+ try {
1144
+ opts?.onPhase?.("thinking");
1145
+ }
1146
+ catch {
1147
+ // ignore
1148
+ }
1149
+ const summaryOpts = opts;
1150
+ const base = buildAnthropicBody(history, model, {
1151
+ includeTools: !summaryOpts?.disableTools,
1152
+ });
1153
+ const body = { ...base, stream: true };
1154
+ // Compaction cap (anthropic kind uses max_tokens; default is already
1155
+ // 4096, but the summary path sets it explicitly for the assertion).
1156
+ if (typeof summaryOpts?.maxOutputTokens === "number" &&
1157
+ Number.isFinite(summaryOpts.maxOutputTokens) &&
1158
+ summaryOpts.maxOutputTokens > 0) {
1159
+ body["max_tokens"] = Math.floor(summaryOpts.maxOutputTokens);
1160
+ }
1161
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
1162
+ method: "POST",
1163
+ headers: anthropicHeaders(apiKey),
1164
+ body: JSON.stringify(body),
1165
+ ...(signal ? { signal } : {}),
1166
+ });
1167
+ if (!res.ok) {
1168
+ const errText = await safeErrorText(res);
1169
+ const err = providerHttpError("anthropic", res.status, errText);
1170
+ if (!RETRYABLE_STATUS.has(res.status))
1171
+ throw err;
1172
+ if (attempt < MAX_RETRIES) {
1173
+ throwIfCancelled(signal);
1174
+ const delay = getRetryDelay(attempt, res);
1175
+ try {
1176
+ opts?.onPhase?.("retry", `attempt ${attempt + 1}/${MAX_RETRIES} after ${delay}ms (HTTP ${res.status})`);
1177
+ }
1178
+ catch {
1179
+ // ignore
1180
+ }
1181
+ await sleep(delay);
1182
+ throwIfCancelled(signal);
1183
+ lastError = err;
1184
+ continue;
1185
+ }
1186
+ throw err;
1187
+ }
1188
+ if (!hasStreamBody(res)) {
1189
+ const data = (await res.json());
1190
+ return parseAnthropicJson(data);
1191
+ }
1192
+ return await readAnthropicSSEMessage(res, opts);
1193
+ }
1194
+ catch (e) {
1195
+ if (isCancelError(e) || signal?.aborted)
1196
+ throw new LoopCancelledError();
1197
+ if (e instanceof Error && e.message.startsWith("Anthropic HTTP"))
1198
+ throw e;
1199
+ if (e instanceof Error &&
1200
+ (e.message.startsWith("Empty reply") || e.message.startsWith("Truncated stream"))) {
1201
+ throw e;
1202
+ }
1203
+ if (attempt < MAX_RETRIES) {
1204
+ const delay = getRetryDelay(attempt, undefined);
1205
+ try {
1206
+ opts?.onPhase?.("retry", `attempt ${attempt + 1}/${MAX_RETRIES} after ${delay}ms (${e instanceof Error ? e.message : String(e)})`);
1207
+ }
1208
+ catch {
1209
+ // ignore
1210
+ }
1211
+ try {
1212
+ await sleep(delay);
1213
+ }
1214
+ catch {
1215
+ // ignore
1216
+ }
1217
+ lastError = e;
1218
+ continue;
1219
+ }
1220
+ throw e;
1221
+ }
1222
+ }
1223
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
1224
+ }
1225
+ export async function chatCompletionGemini(apiKey, model, history, opts) {
1226
+ const sleep = opts?.sleep ?? defaultSleep;
1227
+ const signal = opts?.signal ?? null;
1228
+ let lastError = null;
1229
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
1230
+ try {
1231
+ throwIfCancelled(signal);
1232
+ try {
1233
+ opts?.onPhase?.("thinking");
1234
+ }
1235
+ catch {
1236
+ // ignore
1237
+ }
1238
+ const summaryOpts = opts;
1239
+ const body = buildGeminiBody(history, model, {
1240
+ includeTools: !summaryOpts?.disableTools,
1241
+ ...(typeof summaryOpts?.maxOutputTokens === "number" &&
1242
+ Number.isFinite(summaryOpts.maxOutputTokens) &&
1243
+ summaryOpts.maxOutputTokens > 0
1244
+ ? { maxOutputTokens: Math.floor(summaryOpts.maxOutputTokens) }
1245
+ : {}),
1246
+ });
1247
+ const res = await fetch(geminiChatUrl(model), {
1248
+ method: "POST",
1249
+ headers: geminiHeaders(apiKey),
1250
+ body: JSON.stringify(body),
1251
+ ...(signal ? { signal } : {}),
1252
+ });
1253
+ if (!res.ok) {
1254
+ const errText = await safeErrorText(res);
1255
+ const err = providerHttpError("google-gemini", res.status, errText);
1256
+ if (!RETRYABLE_STATUS.has(res.status))
1257
+ throw err;
1258
+ if (attempt < MAX_RETRIES) {
1259
+ throwIfCancelled(signal);
1260
+ const delay = getRetryDelay(attempt, res);
1261
+ try {
1262
+ opts?.onPhase?.("retry", `attempt ${attempt + 1}/${MAX_RETRIES} after ${delay}ms (HTTP ${res.status})`);
1263
+ }
1264
+ catch {
1265
+ // ignore
1266
+ }
1267
+ await sleep(delay);
1268
+ throwIfCancelled(signal);
1269
+ lastError = err;
1270
+ continue;
1271
+ }
1272
+ throw err;
1273
+ }
1274
+ if (!hasStreamBody(res)) {
1275
+ // Non-streaming :generateContent fallback tolerance (same shape):
1276
+ // a plain JSON body parses like single-shot JSON.
1277
+ const data = (await res.json());
1278
+ try {
1279
+ return parseGeminiJson(data);
1280
+ }
1281
+ catch {
1282
+ // Try the non-streaming endpoint once before giving up.
1283
+ throwIfCancelled(signal);
1284
+ const res2 = await fetch(geminiGenerateUrl(model), {
1285
+ method: "POST",
1286
+ headers: geminiHeaders(apiKey),
1287
+ body: JSON.stringify(body),
1288
+ ...(signal ? { signal } : {}),
1289
+ });
1290
+ if (!res2.ok) {
1291
+ const errText2 = await safeErrorText(res2);
1292
+ throw providerHttpError("google-gemini", res2.status, errText2);
1293
+ }
1294
+ const data2 = (await res2.json());
1295
+ return parseGeminiJson(data2);
1296
+ }
1297
+ }
1298
+ return await readGeminiSSEMessage(res, opts);
1299
+ }
1300
+ catch (e) {
1301
+ if (isCancelError(e) || signal?.aborted)
1302
+ throw new LoopCancelledError();
1303
+ if (e instanceof Error && e.message.startsWith("Gemini HTTP"))
1304
+ throw e;
1305
+ // providerHttpError for gemini uses label "Google Gemini", not "Gemini":
1306
+ // rethrow those without retry-as-network (they were already handled).
1307
+ if (e instanceof Error && /HTTP \d+:/.test(e.message)) {
1308
+ const m = /HTTP (\d+):/.exec(e.message);
1309
+ if (m && !RETRYABLE_STATUS.has(Number(m[1])))
1310
+ throw e;
1311
+ // retryable HTTP already handled above; fall through only for network
1312
+ }
1313
+ if (e instanceof Error &&
1314
+ (e.message.startsWith("Empty reply") || e.message.startsWith("Truncated stream"))) {
1315
+ throw e;
1316
+ }
1317
+ if (attempt < MAX_RETRIES) {
1318
+ const delay = getRetryDelay(attempt, undefined);
1319
+ try {
1320
+ opts?.onPhase?.("retry", `attempt ${attempt + 1}/${MAX_RETRIES} after ${delay}ms (${e instanceof Error ? e.message : String(e)})`);
1321
+ }
1322
+ catch {
1323
+ // ignore
1324
+ }
1325
+ try {
1326
+ await sleep(delay);
1327
+ }
1328
+ catch {
1329
+ // ignore
1330
+ }
1331
+ lastError = e;
1332
+ continue;
1333
+ }
1334
+ throw e;
1335
+ }
1336
+ }
1337
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
1338
+ }
1339
+ // Provider dispatcher: openai-chat reuses chatCompletion with the provider's
1340
+ // error label; anthropic/gemini go through their adapters. reasoning_effort is only
1341
+ // ever attached for opencode-zen (via reasoningEffortParam); all other
1342
+ // providers never receive the param.
1343
+ export async function chatCompletionForProvider(provider, apiKey, model, history, opts) {
1344
+ const def = getProvider(provider);
1345
+ if (!def)
1346
+ throw new Error(`unknown provider: ${provider}`);
1347
+ if (def.kind === "anthropic-messages") {
1348
+ return chatCompletionAnthropic(apiKey, model, history, opts);
1349
+ }
1350
+ if (def.kind === "gemini-generate") {
1351
+ return chatCompletionGemini(apiKey, model, history, opts);
1352
+ }
1353
+ // openai-chat kind: zen keeps byte-identical behavior (errorLabel "Zen",
1354
+ // endpoint override honors OPENCODE_ZEN_ENDPOINT); others use the registry
1355
+ // endpoint with their provider label (e.g. "OpenAI", "DeepSeek").
1356
+ const endpoint = provider === "opencode-zen"
1357
+ ? (opts?.endpointOverride ?? chatEndpointFor(provider, opts?.baseURL))
1358
+ : chatEndpointFor(provider, opts?.baseURL);
1359
+ const effortOpts = provider === "opencode-zen" ? { reasoningEffort: opts?.reasoningEffort } : {};
1360
+ return chatCompletion(endpoint, apiKey, model, history, {
1361
+ onToken: opts?.onToken,
1362
+ onPhase: opts?.onPhase,
1363
+ onToolDelta: opts?.onToolDelta,
1364
+ onWarning: opts?.onWarning,
1365
+ onThinking: opts?.onThinking,
1366
+ sleep: opts?.sleep,
1367
+ signal: opts?.signal,
1368
+ ...effortOpts,
1369
+ // Compaction path only (undefined for the normal loop → tools sent).
1370
+ ...(opts?.disableTools !== undefined ? { disableTools: opts.disableTools } : {}),
1371
+ ...(opts?.maxOutputTokens !== undefined
1372
+ ? { maxOutputTokens: opts.maxOutputTokens }
1373
+ : {}),
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
+ };
1397
+ }
1398
+ // Task 7 verification gate: files were written but no test/typecheck/build
1399
+ // command ran after the last write. The turn still ends here (never block)
1400
+ // — the result is labeled unverified so a "done" claim can never pass
1401
+ // silently without evidence. Turns with no writes (questions, explanations,
1402
+ // read-only work) are unaffected.
1403
+ export function verificationGate(finalText, ctx) {
1404
+ if (!ctx.filesWritten || ctx.verifiedAfterWrite)
1405
+ return { action: "pass" };
1406
+ return {
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 };
1419
+ }
1420
+ return { kind: "end", finalText: decision.finalText };
1421
+ }
1422
+ return { kind: "end", finalText };
1423
+ }
1424
+ // Task 7 verification gate: a bash command counts as a verification run
1425
+ // when it names a common test/typecheck/build entry point. This is a word
1426
+ // heuristic, not a parser — a miss only appends a non-blocking
1427
+ // informational flag (never stops the turn), and the list is pinned by
1428
+ // tests/loop-verification-gate.test.ts.
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.
1437
+ //
1438
+ // Parallel-safe = this explicit allowlist only (new tools default to
1439
+ // serial). Excluded on purpose:
1440
+ // - write/edit/bash need approval and mutate the world (bash can touch
1441
+ // anything, so no footprint check could clear it);
1442
+ // - ask_question blocks on a UI modal (parallel prompts make no sense);
1443
+ // - todowrite/todo_update share module-global todo state (read-modify-write
1444
+ // races); todo_get is pure but sub-millisecond, so batching it buys
1445
+ // nothing and it stays serial too (empty footprint, see below).
1446
+ export const PARALLEL_SAFE_TOOLS = new Set([
1447
+ "read",
1448
+ "grep",
1449
+ "glob",
1450
+ "webfetch",
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.
1481
+ export function planToolBatches(calls) {
1482
+ const batches = [];
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
+ }
1730
+ }
1731
+ export async function runAgenticLoopForProvider(provider, apiKey, model, history, opts) {
1732
+ return runLoopWithChat((h, o) => chatCompletionForProvider(provider, apiKey, model, h, {
1733
+ onToken: o?.onToken,
1734
+ onPhase: o?.onPhase,
1735
+ onToolDelta: o?.onToolDelta,
1736
+ onWarning: o?.onWarning,
1737
+ onThinking: o?.onThinking,
1738
+ sleep: o?.sleep,
1739
+ signal: o?.signal,
1740
+ reasoningEffort: o?.reasoningEffort,
1741
+ baseURL: opts?.baseURL,
1742
+ endpointOverride: opts?.endpointOverride,
1743
+ }), history, opts);
1744
+ }
1745
+ // Per-provider model list: live list per kind with curated fallback on ANY
1746
+ // failure. Zen keeps today's compatibility rule (see fetchModels); other
1747
+ // providers accept every listed id.
1748
+ // WithStatus variant reports ok:true only when the live list was used, so
1749
+ // callers cache successes and keep failures uncached. fetchModelsForProvider
1750
+ // stays byte-identical (returns models only).
1751
+ function hasOpenAILiveIds(data) {
1752
+ try {
1753
+ const entries = Array.isArray(data)
1754
+ ? data
1755
+ : data?.data;
1756
+ if (!Array.isArray(entries) || entries.length === 0)
1757
+ return false;
1758
+ for (const entry of entries) {
1759
+ if (entryId(entry))
1760
+ return true;
1761
+ }
1762
+ return false;
1763
+ }
1764
+ catch {
1765
+ return false;
1766
+ }
1767
+ }
1768
+ function hasGeminiLiveIds(data) {
1769
+ try {
1770
+ const o = data;
1771
+ const entries = Array.isArray(o?.models) ? o.models : null;
1772
+ if (!Array.isArray(entries) || entries.length === 0)
1773
+ return false;
1774
+ for (const entry of entries) {
1775
+ let id = entryId(entry);
1776
+ if (id && id.startsWith("models/"))
1777
+ id = id.slice("models/".length);
1778
+ if (id)
1779
+ return true;
1780
+ }
1781
+ return false;
1782
+ }
1783
+ catch {
1784
+ return false;
1785
+ }
1786
+ }
1787
+ export async function fetchModelsForProviderWithStatus(provider, apiKey, baseURL, zenEndpointOverride) {
1788
+ const def = getProvider(provider);
1789
+ if (!def)
1790
+ return { models: [], ok: false };
1791
+ const fallback = [...def.fallbackModels];
1792
+ try {
1793
+ if (provider === "opencode-zen") {
1794
+ // Byte-identical rule: reuse fetchModels (compatibility-filtered).
1795
+ const endpoint = zenEndpointOverride ?? chatEndpointFor(provider, baseURL);
1796
+ return await fetchModelsWithStatus(endpoint, apiKey);
1797
+ }
1798
+ if (def.kind === "anthropic-messages") {
1799
+ const res = await fetch(modelsUrlForProvider(provider), {
1800
+ headers: {
1801
+ "x-api-key": apiKey,
1802
+ "anthropic-version": ANTHROPIC_VERSION,
1803
+ },
1804
+ });
1805
+ if (!res.ok)
1806
+ return { models: fallback, ok: false };
1807
+ let data;
1808
+ try {
1809
+ data = await res.json();
1810
+ }
1811
+ catch {
1812
+ return { models: fallback, ok: false };
1813
+ }
1814
+ const models = parseAnthropicModelsList(data, fallback);
1815
+ if (!hasOpenAILiveIds(data))
1816
+ return { models: fallback, ok: false };
1817
+ return { models, ok: true };
1818
+ }
1819
+ if (def.kind === "gemini-generate") {
1820
+ const res = await fetch(modelsUrlForProvider(provider), {
1821
+ headers: geminiHeaders(apiKey),
1822
+ });
1823
+ if (!res.ok)
1824
+ return { models: fallback, ok: false };
1825
+ let data;
1826
+ try {
1827
+ data = await res.json();
1828
+ }
1829
+ catch {
1830
+ return { models: fallback, ok: false };
1831
+ }
1832
+ const models = parseGeminiModelsList(data, fallback);
1833
+ if (!hasGeminiLiveIds(data))
1834
+ return { models: fallback, ok: false };
1835
+ return { models, ok: true };
1836
+ }
1837
+ // openai-chat (non-zen): accept all listed ids.
1838
+ const res = await fetch(modelsUrlForProvider(provider, baseURL), {
1839
+ headers: { Authorization: `Bearer ${apiKey}` },
1840
+ });
1841
+ if (!res.ok)
1842
+ return { models: fallback, ok: false };
1843
+ let data;
1844
+ try {
1845
+ data = await res.json();
1846
+ }
1847
+ catch {
1848
+ return { models: fallback, ok: false };
1849
+ }
1850
+ const models = parseOpenAIModelsList(data, fallback);
1851
+ if (!hasOpenAILiveIds(data))
1852
+ return { models: fallback, ok: false };
1853
+ return { models, ok: true };
1854
+ }
1855
+ catch {
1856
+ return { models: fallback, ok: false };
1857
+ }
1858
+ }
1859
+ export async function fetchModelsForProvider(provider, apiKey, baseURL, zenEndpointOverride) {
1860
+ const r = await fetchModelsForProviderWithStatus(provider, apiKey, baseURL, zenEndpointOverride);
1861
+ return r.models;
1862
+ }