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/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/dist/App.js +2428 -0
- package/dist/adapters.js +926 -0
- package/dist/auth.js +122 -0
- package/dist/cli.js +28 -0
- package/dist/compact.js +277 -0
- package/dist/context-windows.js +112 -0
- package/dist/env-block.js +166 -0
- package/dist/permissions.js +129 -0
- package/dist/providers.js +224 -0
- package/dist/session.js +218 -0
- package/dist/skills.js +283 -0
- package/dist/snapshots.js +243 -0
- package/dist/system.js +22 -0
- package/dist/tools.js +1867 -0
- package/dist/zen.js +1862 -0
- package/package.json +54 -0
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
|
+
}
|