atom-agent 1.4.0 → 1.5.1
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 +70 -0
- package/README.md +221 -224
- package/dist/App.js +922 -341
- package/dist/adapters.js +502 -21
- package/dist/agent/goal-evaluator.js +3 -0
- package/dist/agent/loop.js +250 -434
- package/dist/agent/tool-pipeline.js +398 -0
- package/dist/agent/turn-events.js +12 -0
- package/dist/cli.js +57 -8
- package/dist/compact.js +72 -8
- package/dist/config.js +19 -0
- package/dist/context-manager.js +6 -2
- package/dist/extensions.js +6 -0
- package/dist/file-diffs.js +108 -0
- package/dist/kilo.js +1 -1
- package/dist/local-discovery.js +2 -2
- package/dist/media.js +276 -0
- package/dist/overflow.js +140 -0
- package/dist/policy.js +8 -0
- package/dist/providers.js +11 -3
- package/dist/scheduler.js +38 -9
- package/dist/session-revert.js +125 -0
- package/dist/sessions.js +101 -0
- package/dist/snapshots.js +69 -0
- package/dist/system.js +2 -89
- package/dist/telemetry.js +79 -5
- package/dist/todos.js +241 -0
- package/dist/tools/filesystem.js +102 -22
- package/dist/tools/registry.js +184 -45
- package/dist/tools/ripgrep.js +7 -6
- package/dist/tools/search.js +172 -17
- package/dist/tools/shared.js +6 -0
- package/dist/tools.js +7 -39
- package/dist/ui/diff-panel.js +1 -1
- package/dist/ui/diff-view.js +13 -5
- package/dist/ui/diff.js +67 -0
- package/dist/ui/errors.js +20 -6
- package/dist/ui/input.js +24 -20
- package/dist/ui/live-tail.js +36 -1
- package/dist/ui/markdown.js +9 -4
- package/dist/ui/modals.js +7 -5
- package/dist/ui/paint-scheduler.js +120 -0
- package/dist/ui/palette.js +4 -2
- package/dist/ui/pickers.js +4 -1
- package/dist/ui/side-by-side.js +81 -22
- package/dist/ui/status-bar.js +63 -8
- package/dist/ui/stream-store.js +7 -0
- package/dist/ui/theme.js +23 -1
- package/dist/ui/todo-panel.js +5 -2
- package/dist/ui/tool-inspector.js +33 -4
- package/dist/ui/transcript.js +8 -5
- package/dist/web/events.js +93 -0
- package/dist/web/runtime.js +790 -0
- package/dist/web/server.js +570 -0
- package/dist/web/ui/app.js +1925 -0
- package/dist/web/ui/index.html +135 -0
- package/dist/web/ui/styles.css +515 -0
- package/dist/zen.js +532 -34
- package/documentation/cli.md +5 -5
- package/documentation/configuration.md +11 -6
- package/documentation/development.md +4 -3
- package/documentation/goals.md +1 -1
- package/documentation/index.md +4 -4
- package/documentation/providers.md +2 -3
- package/documentation/skills.md +3 -3
- package/documentation/tools.md +8 -3
- package/documentation/troubleshooting.md +1 -1
- package/package.json +3 -2
package/dist/zen.js
CHANGED
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
// different Zen request shapes and are out of scope.
|
|
6
6
|
import { existsSync, readFileSync } from "node:fs";
|
|
7
7
|
import * as path from "node:path";
|
|
8
|
-
import { MAX_TOOL_STEPS, allToolDefinitions, getExtensionPromptHints, } from "./tools.js";
|
|
8
|
+
import { MAX_TOOL_STEPS, allToolDefinitions, chatToolDefinitions, getExtensionPromptHints, } from "./tools.js";
|
|
9
9
|
import { chatEndpointFor, getProvider, isLocalProviderId, modelsUrlForProvider, providerLabel, } from "./providers.js";
|
|
10
10
|
import { discoverLocalProvider } from "./local-discovery.js";
|
|
11
|
-
import { ANTHROPIC_MAX_TOKENS, ANTHROPIC_VERSION, anthropicHeaders, anthropicThinkingFor, buildAnthropicBody, buildGeminiBody, geminiChatUrl, geminiGenerateUrl, geminiHeaders, geminiThinkingLevelFor, isEffortRejection, parseAnthropicJson, parseAnthropicModelsList, parseGeminiJson, parseGeminiModelsList, parseOpenAIModelsList, readAnthropicSSEMessage, readGeminiSSEMessage, isStallError, readWithStall, sseStallTimeoutMs, } from "./adapters.js";
|
|
12
|
-
export { isStallError, readWithStall, sseStallTimeoutMs } from "./adapters.js";
|
|
11
|
+
import { ANTHROPIC_MAX_TOKENS, ANTHROPIC_VERSION, anthropicHeaders, anthropicThinkingFor, buildAnthropicBody, buildGeminiBody, geminiChatUrl, geminiGenerateUrl, geminiHeaders, geminiThinkingLevelFor, isEffortRejection, parseAnthropicJson, parseAnthropicModelsList, parseGeminiJson, parseGeminiModelsList, parseOpenAIModelsList, readAnthropicSSEMessage, readGeminiSSEMessage, buildResponsesBody, isResponsesEffortRejection, isStallError, parseResponsesObject, readResponsesSSEMessage, readWithStall, sseHeaderTimeoutMs, sseStallTimeoutMs, zenHeaders, zenRequestId, } from "./adapters.js";
|
|
12
|
+
export { isStallError, readWithStall, sseHeaderTimeoutMs, sseStallTimeoutMs, ZEN_CLIENT_UA, zenHeaders, zenRequestId, zenSessionId } from "./adapters.js";
|
|
13
13
|
import { KILO_FALLBACK_MODELS, fetchKiloModelsWithStatus, normalizeKiloChatError, } from "./kilo.js";
|
|
14
14
|
import { splitSystemHead } from "./prompt-cache.js";
|
|
15
15
|
import { SYSTEM_PROMPT } from "./system.js";
|
|
@@ -86,6 +86,19 @@ export function reasoningEffortParam(effort, _model) {
|
|
|
86
86
|
return undefined;
|
|
87
87
|
return normalized;
|
|
88
88
|
}
|
|
89
|
+
// Wire value for the Responses `reasoning.effort` knob, or undefined when
|
|
90
|
+
// the param must be omitted (Auto, or an unknown effort string). Max maps
|
|
91
|
+
// to high — the deepest widely-supported level (same precedent as the
|
|
92
|
+
// Gemini thinkingLevel mapping in adapters.ts).
|
|
93
|
+
export function responsesEffortParam(effort) {
|
|
94
|
+
const normalized = normalizeEffort(effort);
|
|
95
|
+
if (normalized === "auto")
|
|
96
|
+
return undefined;
|
|
97
|
+
if (normalized === "max")
|
|
98
|
+
return "high";
|
|
99
|
+
return normalized;
|
|
100
|
+
}
|
|
101
|
+
import { historyHasMedia, isImageRejection, lowerOpenAIContent, } from "./media.js";
|
|
89
102
|
export { CHARS_PER_TOKEN, createContextManager, estimateTokensForChars, historyChars, messageChars, } from "./context-manager.js";
|
|
90
103
|
export { openTodoNeedles } from "./agent/gates.js";
|
|
91
104
|
function finiteCount(value) {
|
|
@@ -170,9 +183,25 @@ export { isCancelError, LoopCancelledError } from "./agent/loop.js";
|
|
|
170
183
|
// Retry-After) and weak-network throws both ride this policy. Cancellation
|
|
171
184
|
// never retries. Delays grow exponentially under a 30s cap, so a fully dead
|
|
172
185
|
// endpoint costs ~3.5min worst case before the turn fails loudly.
|
|
186
|
+
// 524/529 added for opencode parity (Cloudflare / overloaded gateways).
|
|
173
187
|
export const MAX_RETRIES = 10;
|
|
174
|
-
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
|
|
188
|
+
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504, 524, 529]);
|
|
175
189
|
const RETRY_AFTER_CAP_MS = 30_000;
|
|
190
|
+
// Per-model-call deadline (opencode options.timeout parity): bounds a single
|
|
191
|
+
// chat POST including streaming. Env ATOM_MODEL_TIMEOUT_MS, default 300s
|
|
192
|
+
// (matches opencode header/chunk defaults), max-clamped to 10min. Caller
|
|
193
|
+
// combines with the user-cancel signal per attempt via AbortSignal.any.
|
|
194
|
+
export const DEFAULT_MODEL_TIMEOUT_MS = 300_000;
|
|
195
|
+
export const MAX_MODEL_TIMEOUT_MS = 600_000;
|
|
196
|
+
export function modelTimeoutMs() {
|
|
197
|
+
const raw = process.env.ATOM_MODEL_TIMEOUT_MS;
|
|
198
|
+
if (raw !== undefined) {
|
|
199
|
+
const n = Number(raw.trim());
|
|
200
|
+
if (Number.isFinite(n) && n > 0)
|
|
201
|
+
return Math.min(Math.floor(n), MAX_MODEL_TIMEOUT_MS);
|
|
202
|
+
}
|
|
203
|
+
return DEFAULT_MODEL_TIMEOUT_MS;
|
|
204
|
+
}
|
|
176
205
|
export function defaultSleep(ms) {
|
|
177
206
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
178
207
|
}
|
|
@@ -220,15 +249,23 @@ function hasStreamBody(res) {
|
|
|
220
249
|
return false;
|
|
221
250
|
}
|
|
222
251
|
}
|
|
223
|
-
// Curated
|
|
224
|
-
//
|
|
225
|
-
//
|
|
252
|
+
// Curated Zen models, verified from https://opencode.ai/docs/zen + the live
|
|
253
|
+
// /models list. Used when the live list cannot be fetched or cannot confirm
|
|
254
|
+
// compatibility (network/auth/429/shape issues). Covers both wire families:
|
|
255
|
+
// chat/completions (default transport) and responses-family (muse-spark-*,
|
|
256
|
+
// served from /responses — see isZenResponsesModel); the live filter below
|
|
257
|
+
// accepts ids from either set, the dispatcher routes by family.
|
|
226
258
|
export const FALLBACK_MODELS = [
|
|
227
259
|
"big-pickle",
|
|
228
260
|
"mimo-v2.5-free",
|
|
229
261
|
"ling-3.0-flash-fin-free",
|
|
230
262
|
"nemotron-3-ultra-free",
|
|
231
263
|
"nemotron-3.5-lightning-free",
|
|
264
|
+
"deepseek-v4-flash-free",
|
|
265
|
+
"muse-spark-1.3-contributor-free",
|
|
266
|
+
"muse-spark-1.2-contributor-free",
|
|
267
|
+
"muse-spark-1.3",
|
|
268
|
+
"muse-spark-1.2",
|
|
232
269
|
"deepseek-v4-pro",
|
|
233
270
|
"deepseek-v4-flash",
|
|
234
271
|
"deepseek-v4-flash-vision-exp",
|
|
@@ -244,6 +281,26 @@ export const FALLBACK_MODELS = [
|
|
|
244
281
|
"minimax-m2.7",
|
|
245
282
|
"minimax-m3",
|
|
246
283
|
];
|
|
284
|
+
// Responses-family model prefixes: these ids are served from Zen's
|
|
285
|
+
// /responses endpoint (OpenAI Responses API shape), not /chat/completions.
|
|
286
|
+
// Verified from the endpoint column at https://opencode.ai/docs/zen.
|
|
287
|
+
const ZEN_RESPONSES_MODEL_PREFIXES = ["muse-spark-"];
|
|
288
|
+
/** True when a Zen model id must ride the Responses transport. */
|
|
289
|
+
export function isZenResponsesModel(model) {
|
|
290
|
+
return ZEN_RESPONSES_MODEL_PREFIXES.some((p) => model.startsWith(p));
|
|
291
|
+
}
|
|
292
|
+
export const RESPONSES_ENDPOINT_DEFAULT = "https://opencode.ai/zen/v1/responses";
|
|
293
|
+
// Derive the /responses endpoint from a chat/completions endpoint (mirrors
|
|
294
|
+
// modelsUrl above); falls back to the default when the shape is unknown.
|
|
295
|
+
export function responsesEndpointFor(chatEndpoint) {
|
|
296
|
+
const suffix = "/chat/completions";
|
|
297
|
+
if (chatEndpoint.endsWith(suffix)) {
|
|
298
|
+
return chatEndpoint.slice(0, -suffix.length) + "/responses";
|
|
299
|
+
}
|
|
300
|
+
if (chatEndpoint.endsWith("/responses"))
|
|
301
|
+
return chatEndpoint;
|
|
302
|
+
return RESPONSES_ENDPOINT_DEFAULT;
|
|
303
|
+
}
|
|
247
304
|
export function endpointConfig() {
|
|
248
305
|
return {
|
|
249
306
|
endpoint: process.env.OPENCODE_ZEN_ENDPOINT ?? DEFAULT_ENDPOINT,
|
|
@@ -292,7 +349,11 @@ function entryId(entry) {
|
|
|
292
349
|
export async function fetchModelsWithStatus(endpoint, apiKey) {
|
|
293
350
|
try {
|
|
294
351
|
const res = await fetch(modelsUrl(endpoint), {
|
|
295
|
-
|
|
352
|
+
// Zen client identity: free `*-free` models are gated upstream on
|
|
353
|
+
// official-client headers (UA + `x-opencode-session`, else 429/400).
|
|
354
|
+
// Anonymous-safe: zenHeaders omits Authorization when no key instead
|
|
355
|
+
// of sending `Bearer `.
|
|
356
|
+
headers: zenHeaders(apiKey),
|
|
296
357
|
});
|
|
297
358
|
if (!res.ok)
|
|
298
359
|
return { models: [...FALLBACK_MODELS], ok: false };
|
|
@@ -394,12 +455,14 @@ export async function readSSEMessage(res, opts) {
|
|
|
394
455
|
// while a free-tier request sits queued).
|
|
395
456
|
let lastDataAt = Date.now();
|
|
396
457
|
// Fail fast when the stream flows (or idles) with no model output: same
|
|
397
|
-
//
|
|
398
|
-
//
|
|
458
|
+
// Truncated contract as a dead connection (stalls are retryable upstream —
|
|
459
|
+
// one retry after data, backoff for header stalls), same env knobs as the
|
|
460
|
+
// per-read byte race. Header phase (no data yet) uses the generous header
|
|
461
|
+
// budget; established streams use the tighter chunk budget. Checked
|
|
399
462
|
// after each drained chunk — legitimately slow generations keep emitting
|
|
400
463
|
// `data:` lines, so only true silence trips it.
|
|
401
464
|
function throwIfDataStalled() {
|
|
402
|
-
const budget = sseStallTimeoutMs();
|
|
465
|
+
const budget = sawData ? sseStallTimeoutMs() : sseHeaderTimeoutMs();
|
|
403
466
|
if (Date.now() - lastDataAt > budget) {
|
|
404
467
|
throw new Error(`Truncated stream from model (stall: no output for ${budget}ms — queued or stalled upstream; resend to retry).`);
|
|
405
468
|
}
|
|
@@ -782,7 +845,17 @@ opts, errorLabel = "Zen") {
|
|
|
782
845
|
// Server-authoritative unsupported: when a 400 names the effort knob, the
|
|
783
846
|
// flag below drops it and the loop retries without it (once per call).
|
|
784
847
|
let effortDropped = false;
|
|
848
|
+
// Same contract for vision input (see src/media.ts): a 400 naming image
|
|
849
|
+
// input retries once with descriptors stripped to prose markers.
|
|
850
|
+
let mediaStripped = false;
|
|
785
851
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
852
|
+
// Per-attempt model deadline: bounds the whole POST+stream (opencode
|
|
853
|
+
// options.timeout parity). User cancel still wins; deadline aborts map
|
|
854
|
+
// to retryable stall errors below, never to LoopCancelledError.
|
|
855
|
+
const deadlineMs = modelTimeoutMs();
|
|
856
|
+
let timedOut = false;
|
|
857
|
+
let timeoutId = null;
|
|
858
|
+
const attemptController = new AbortController();
|
|
786
859
|
try {
|
|
787
860
|
throwIfCancelled(signal);
|
|
788
861
|
try {
|
|
@@ -795,13 +868,27 @@ opts, errorLabel = "Zen") {
|
|
|
795
868
|
? undefined
|
|
796
869
|
: reasoningEffortParam(opts?.reasoningEffort, model);
|
|
797
870
|
const summaryOpts = opts;
|
|
871
|
+
const mediaMode = opts?.stripMedia === true || mediaStripped
|
|
872
|
+
? "strip"
|
|
873
|
+
: "send";
|
|
798
874
|
// Stable-prefix split (prompt-cache architecture): history[0]'s env
|
|
799
875
|
// tail becomes its own system message so the stable head + tools stay
|
|
800
876
|
// byte-identical across POSTs for implicit prefix caching. Consecutive
|
|
801
877
|
// system messages concatenate on every OpenAI-protocol server, so this
|
|
802
878
|
// is content-neutral. No env tail (tests, old saves) → history passes
|
|
803
879
|
// through untouched, byte-identical to before.
|
|
804
|
-
|
|
880
|
+
// Media lowering runs after the split: histories without descriptors
|
|
881
|
+
// lower byte-identically (lowerOpenAIContent returns the string as-is).
|
|
882
|
+
const messages = splitSystemHead(outgoingHistory).map((m) => {
|
|
883
|
+
const c = m.content;
|
|
884
|
+
if (typeof c !== "string")
|
|
885
|
+
return m;
|
|
886
|
+
const lowered = lowerOpenAIContent(m.role, c, mediaMode);
|
|
887
|
+
// Identity means untouched (no descriptors): keep the original ref
|
|
888
|
+
// so media-free payloads stay byte-identical. Anything else
|
|
889
|
+
// (stripped string or parts array) replaces the content.
|
|
890
|
+
return lowered === c ? m : { ...m, content: lowered };
|
|
891
|
+
});
|
|
805
892
|
const payload = {
|
|
806
893
|
model,
|
|
807
894
|
messages,
|
|
@@ -810,9 +897,15 @@ opts, errorLabel = "Zen") {
|
|
|
810
897
|
// Compaction path only: tools disabled means NO `tools` key at all
|
|
811
898
|
// (asserted in tests); the normal loop always sends the schema —
|
|
812
899
|
// builtins plus extension-registered custom tools, so the model can
|
|
813
|
-
// discover and call them exactly like builtins.
|
|
900
|
+
// discover and call them exactly like builtins. update_goal rides
|
|
901
|
+
// along only for live goal turns (includeUpdateGoal, set per POST by
|
|
902
|
+
// the runAgenticLoop* entry points) — otherwise the model cannot
|
|
903
|
+
// misuse what it cannot see.
|
|
814
904
|
if (!summaryOpts?.disableTools) {
|
|
815
|
-
payload["tools"] =
|
|
905
|
+
payload["tools"] =
|
|
906
|
+
opts?.includeUpdateGoal === false
|
|
907
|
+
? chatToolDefinitions(false)
|
|
908
|
+
: allToolDefinitions();
|
|
816
909
|
}
|
|
817
910
|
// Compaction path only: cap output (openai-chat kind uses max_tokens).
|
|
818
911
|
if (typeof summaryOpts?.maxOutputTokens === "number" &&
|
|
@@ -822,6 +915,20 @@ opts, errorLabel = "Zen") {
|
|
|
822
915
|
}
|
|
823
916
|
if (effortParam !== undefined)
|
|
824
917
|
payload["reasoning_effort"] = effortParam;
|
|
918
|
+
// Base headers per provider: Zen sends the official-client identity
|
|
919
|
+
// (`User-Agent: opencode/*` + `x-opencode-session`/`x-opencode-request`)
|
|
920
|
+
// so free `*-free` models get quota instead of 429 FreeUsageLimitError
|
|
921
|
+
// or 400 MissingSessionID. Session is stable per process, request is
|
|
922
|
+
// fresh per POST attempt. Other openai-chat providers keep the legacy
|
|
923
|
+
// shape byte-identical. Anonymous-safe everywhere: no key omits
|
|
924
|
+
// Authorization (never `Bearer `). Pre-request hooks below can still
|
|
925
|
+
// override/delete any key (string sets, null/undefined deletes).
|
|
926
|
+
const baseHeaders = hookProvider === "opencode-zen"
|
|
927
|
+
? zenHeaders(apiKey, { requestId: zenRequestId() })
|
|
928
|
+
: {
|
|
929
|
+
"Content-Type": "application/json",
|
|
930
|
+
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
931
|
+
};
|
|
825
932
|
// Pre-request hooks (ticket 08) run per POST attempt: payload
|
|
826
933
|
// replacement must be a record (else ignored — downstream JSON/fetch
|
|
827
934
|
// handling is never bypassed); header merge honors deletions.
|
|
@@ -833,18 +940,41 @@ opts, errorLabel = "Zen") {
|
|
|
833
940
|
model,
|
|
834
941
|
url: endpoint,
|
|
835
942
|
payload,
|
|
836
|
-
headers: {
|
|
837
|
-
"Content-Type": "application/json",
|
|
838
|
-
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
839
|
-
},
|
|
943
|
+
headers: { ...baseHeaders },
|
|
840
944
|
})
|
|
841
945
|
: {
|
|
842
946
|
payload,
|
|
843
|
-
headers: {
|
|
844
|
-
"Content-Type": "application/json",
|
|
845
|
-
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
846
|
-
},
|
|
947
|
+
headers: { ...baseHeaders },
|
|
847
948
|
};
|
|
949
|
+
// Forward user cancel into the per-attempt controller, then arm the
|
|
950
|
+
// model deadline on the same controller.
|
|
951
|
+
if (signal) {
|
|
952
|
+
if (signal.aborted)
|
|
953
|
+
throw new LoopCancelledError();
|
|
954
|
+
signal.addEventListener("abort", () => {
|
|
955
|
+
try {
|
|
956
|
+
attemptController.abort();
|
|
957
|
+
}
|
|
958
|
+
catch {
|
|
959
|
+
// ignore
|
|
960
|
+
}
|
|
961
|
+
}, { once: true });
|
|
962
|
+
}
|
|
963
|
+
timeoutId = setTimeout(() => {
|
|
964
|
+
timedOut = true;
|
|
965
|
+
try {
|
|
966
|
+
attemptController.abort();
|
|
967
|
+
}
|
|
968
|
+
catch {
|
|
969
|
+
// ignore
|
|
970
|
+
}
|
|
971
|
+
}, deadlineMs);
|
|
972
|
+
try {
|
|
973
|
+
timeoutId.unref?.();
|
|
974
|
+
}
|
|
975
|
+
catch {
|
|
976
|
+
// ignore — environments without unref proceed regardless
|
|
977
|
+
}
|
|
848
978
|
const res = await fetch(endpoint, {
|
|
849
979
|
method: "POST",
|
|
850
980
|
// Anonymous-capable providers (Kilo free models) omit Authorization
|
|
@@ -853,7 +983,7 @@ opts, errorLabel = "Zen") {
|
|
|
853
983
|
// behavior is unchanged.
|
|
854
984
|
headers: outgoing.headers,
|
|
855
985
|
body: JSON.stringify(outgoing.payload),
|
|
856
|
-
|
|
986
|
+
signal: attemptController.signal,
|
|
857
987
|
});
|
|
858
988
|
// Post-response observers (ticket 08): every resolved POST (ok and
|
|
859
989
|
// HTTP-error alike), fail-open — never break the turn. Zero-cost when
|
|
@@ -871,6 +1001,27 @@ opts, errorLabel = "Zen") {
|
|
|
871
1001
|
}
|
|
872
1002
|
if (!res.ok) {
|
|
873
1003
|
const errText = await safeErrorText(res);
|
|
1004
|
+
// The server is the authority on vision support: a 400 naming
|
|
1005
|
+
// image input means this model/deployment takes no images — warn,
|
|
1006
|
+
// strip descriptors to markers, and retry without them (once per
|
|
1007
|
+
// call). Checked before effort so a joint rejection still strips.
|
|
1008
|
+
if (res.status === 400 &&
|
|
1009
|
+
!mediaStripped &&
|
|
1010
|
+
opts?.stripMedia !== true &&
|
|
1011
|
+
historyHasMedia(outgoingHistory) &&
|
|
1012
|
+
isImageRejection(errText)) {
|
|
1013
|
+
mediaStripped = true;
|
|
1014
|
+
try {
|
|
1015
|
+
opts?.onWarning?.(`image input is not supported by ${model} — continuing without images`);
|
|
1016
|
+
}
|
|
1017
|
+
catch {
|
|
1018
|
+
// ignore observer errors
|
|
1019
|
+
}
|
|
1020
|
+
throwIfCancelled(signal);
|
|
1021
|
+
if (timeoutId)
|
|
1022
|
+
clearTimeout(timeoutId);
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
874
1025
|
// The server is the authority on effort support: a 400 naming the
|
|
875
1026
|
// knob means this model/deployment has no such control — warn,
|
|
876
1027
|
// drop the knob, and retry without it (setting kept). Any other
|
|
@@ -887,11 +1038,16 @@ opts, errorLabel = "Zen") {
|
|
|
887
1038
|
// ignore observer errors
|
|
888
1039
|
}
|
|
889
1040
|
throwIfCancelled(signal);
|
|
1041
|
+
if (timeoutId)
|
|
1042
|
+
clearTimeout(timeoutId);
|
|
890
1043
|
continue;
|
|
891
1044
|
}
|
|
892
1045
|
const err = new Error(`${errorLabel} HTTP ${res.status}: ${errText.slice(0, 300)}`);
|
|
893
|
-
if (!RETRYABLE_STATUS.has(res.status))
|
|
1046
|
+
if (!RETRYABLE_STATUS.has(res.status)) {
|
|
1047
|
+
if (timeoutId)
|
|
1048
|
+
clearTimeout(timeoutId);
|
|
894
1049
|
throw err;
|
|
1050
|
+
}
|
|
895
1051
|
if (attempt < MAX_RETRIES) {
|
|
896
1052
|
throwIfCancelled(signal);
|
|
897
1053
|
const delay = getRetryDelay(attempt, res);
|
|
@@ -901,11 +1057,15 @@ opts, errorLabel = "Zen") {
|
|
|
901
1057
|
catch {
|
|
902
1058
|
// ignore
|
|
903
1059
|
}
|
|
1060
|
+
if (timeoutId)
|
|
1061
|
+
clearTimeout(timeoutId);
|
|
904
1062
|
await sleep(delay);
|
|
905
1063
|
throwIfCancelled(signal);
|
|
906
1064
|
lastError = err;
|
|
907
1065
|
continue;
|
|
908
1066
|
}
|
|
1067
|
+
if (timeoutId)
|
|
1068
|
+
clearTimeout(timeoutId);
|
|
909
1069
|
throw err;
|
|
910
1070
|
}
|
|
911
1071
|
if (!hasStreamBody(res)) {
|
|
@@ -950,26 +1110,287 @@ opts, errorLabel = "Zen") {
|
|
|
950
1110
|
const reasoning = parseReasoningLabel(msg);
|
|
951
1111
|
if (reasoning !== undefined)
|
|
952
1112
|
result.reasoning = reasoning;
|
|
1113
|
+
if (timeoutId)
|
|
1114
|
+
clearTimeout(timeoutId);
|
|
953
1115
|
return result;
|
|
954
1116
|
}
|
|
955
|
-
|
|
1117
|
+
try {
|
|
1118
|
+
const streamed = await readSSEMessage(res, { ...opts, signal: attemptController.signal });
|
|
1119
|
+
if (timeoutId)
|
|
1120
|
+
clearTimeout(timeoutId);
|
|
1121
|
+
return streamed;
|
|
1122
|
+
}
|
|
1123
|
+
catch (streamErr) {
|
|
1124
|
+
if (timeoutId)
|
|
1125
|
+
clearTimeout(timeoutId);
|
|
1126
|
+
throw streamErr;
|
|
1127
|
+
}
|
|
956
1128
|
}
|
|
957
1129
|
catch (e) {
|
|
1130
|
+
if (timeoutId)
|
|
1131
|
+
clearTimeout(timeoutId);
|
|
1132
|
+
// User cancel wins over deadline: only the user's own signal maps to
|
|
1133
|
+
// LoopCancelledError. A deadline abort surfaces as a retryable stall.
|
|
1134
|
+
if (signal?.aborted)
|
|
1135
|
+
throw new LoopCancelledError();
|
|
1136
|
+
if (timedOut && !(e instanceof Error && e.message.startsWith(`${errorLabel} HTTP`))) {
|
|
1137
|
+
const deadlineErr = new Error(`Truncated stream from model (stall: no output for ${deadlineMs}ms — model deadline; resend to retry).`);
|
|
1138
|
+
if (attempt < MAX_RETRIES) {
|
|
1139
|
+
const delay = getRetryDelay(attempt, undefined);
|
|
1140
|
+
try {
|
|
1141
|
+
opts?.onPhase?.("retry", `attempt ${attempt + 1}/${MAX_RETRIES} after ${delay}ms (model deadline ${deadlineMs}ms)`);
|
|
1142
|
+
}
|
|
1143
|
+
catch {
|
|
1144
|
+
// ignore
|
|
1145
|
+
}
|
|
1146
|
+
try {
|
|
1147
|
+
await sleep(delay);
|
|
1148
|
+
}
|
|
1149
|
+
catch {
|
|
1150
|
+
// a failing sleep must not mask the original error
|
|
1151
|
+
}
|
|
1152
|
+
lastError = deadlineErr;
|
|
1153
|
+
throwIfCancelled(signal);
|
|
1154
|
+
continue;
|
|
1155
|
+
}
|
|
1156
|
+
throw deadlineErr;
|
|
1157
|
+
}
|
|
958
1158
|
// Cancellations (Ctrl+C / AbortSignal) are final: never retry, never
|
|
959
1159
|
// reframe — propagate so the caller can roll back + show (cancelled).
|
|
960
|
-
if (isCancelError(e)
|
|
1160
|
+
if (isCancelError(e))
|
|
961
1161
|
throw new LoopCancelledError();
|
|
962
1162
|
// HTTP failures already handled above (retry or fail-fast): rethrow
|
|
963
1163
|
// without treating them as retryable network errors.
|
|
964
1164
|
if (e instanceof Error && e.message.startsWith(`${errorLabel} HTTP`))
|
|
965
1165
|
throw e;
|
|
966
|
-
//
|
|
967
|
-
|
|
1166
|
+
// Empty replies are permanent: never retry, surface immediately.
|
|
1167
|
+
if (e instanceof Error && e.message.startsWith("Empty reply")) {
|
|
1168
|
+
throw e;
|
|
1169
|
+
}
|
|
1170
|
+
// Truncated streams: stall timeouts ride the normal network backoff
|
|
1171
|
+
// (opencode parity — SSE read timed out is retryable); an abort with
|
|
1172
|
+
// zero data gets exactly one immediate retry, then fails loudly.
|
|
1173
|
+
if (e instanceof Error && e.message.startsWith("Truncated stream")) {
|
|
1174
|
+
if (!isStallError(e) && attempt >= 1)
|
|
1175
|
+
throw e;
|
|
1176
|
+
if (attempt < MAX_RETRIES) {
|
|
1177
|
+
const delay = getRetryDelay(attempt, undefined);
|
|
1178
|
+
try {
|
|
1179
|
+
opts?.onPhase?.("retry", `attempt ${attempt + 1}/${MAX_RETRIES} after ${delay}ms (${e.message.slice(0, 120)})`);
|
|
1180
|
+
}
|
|
1181
|
+
catch {
|
|
1182
|
+
// ignore
|
|
1183
|
+
}
|
|
1184
|
+
try {
|
|
1185
|
+
await sleep(delay);
|
|
1186
|
+
}
|
|
1187
|
+
catch {
|
|
1188
|
+
// a failing sleep must not mask the original error
|
|
1189
|
+
}
|
|
1190
|
+
lastError = e;
|
|
1191
|
+
throwIfCancelled(signal);
|
|
1192
|
+
continue;
|
|
1193
|
+
}
|
|
1194
|
+
throw e;
|
|
1195
|
+
}
|
|
1196
|
+
// Anything else is a network-level throw: retry when attempts remain.
|
|
1197
|
+
if (attempt < MAX_RETRIES) {
|
|
1198
|
+
const delay = getRetryDelay(attempt, undefined);
|
|
1199
|
+
try {
|
|
1200
|
+
opts?.onPhase?.("retry", `attempt ${attempt + 1}/${MAX_RETRIES} after ${delay}ms (${e instanceof Error ? e.message : String(e)})`);
|
|
1201
|
+
}
|
|
1202
|
+
catch {
|
|
1203
|
+
// ignore
|
|
1204
|
+
}
|
|
1205
|
+
try {
|
|
1206
|
+
await sleep(delay);
|
|
1207
|
+
}
|
|
1208
|
+
catch {
|
|
1209
|
+
// a failing sleep must not mask the original error
|
|
1210
|
+
}
|
|
1211
|
+
lastError = e;
|
|
1212
|
+
continue;
|
|
1213
|
+
}
|
|
1214
|
+
throw e;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
1218
|
+
}
|
|
1219
|
+
// Responses-family chat POST with tools attached (tool_choice omitted, so
|
|
1220
|
+
// the default auto applies). Sends {model, instructions?, input, stream:true}
|
|
1221
|
+
// plus `reasoning: {effort}` whenever opts.reasoningEffort is non-Auto (see
|
|
1222
|
+
// responsesEffortParam) — for every responses model on opencode-zen.
|
|
1223
|
+
// A 400 naming the knob means the model truly lacks it: warn once via
|
|
1224
|
+
// onWarning and retry without it. Parses the Responses SSE event stream
|
|
1225
|
+
// (see readResponsesSSEMessage in adapters.ts).
|
|
1226
|
+
// Retry/rollback/hook/error-label contract mirrors chatCompletion exactly:
|
|
1227
|
+
// network throws and HTTP 429/500/502/503/504 retry up to MAX_RETRIES with
|
|
1228
|
+
// the same backoff; other 4xx fail fast as `{errorLabel} HTTP {status}`;
|
|
1229
|
+
// empty replies and truncated streams (no response.completed) throw
|
|
1230
|
+
// permanently; status "incomplete" returns with `truncated: true`.
|
|
1231
|
+
// Callers must roll back the user turn on failure (see App submit).
|
|
1232
|
+
export async function chatCompletionResponses(endpoint, apiKey, model, history,
|
|
1233
|
+
// providerId (ticket 08): hook attribution for the responses transport —
|
|
1234
|
+
// the dispatcher passes its provider id, direct callers omit it and
|
|
1235
|
+
// default to "opencode-zen". Optional, wire-compatible.
|
|
1236
|
+
opts, errorLabel = "Zen") {
|
|
1237
|
+
const sleep = opts?.sleep ?? defaultSleep;
|
|
1238
|
+
const signal = opts?.signal ?? null;
|
|
1239
|
+
const hookProvider = opts?.providerId ?? "opencode-zen";
|
|
1240
|
+
const contextHooks = contextTransformers();
|
|
1241
|
+
const outgoingHistory = contextHooks.length > 0 ? await applyContextTransform(contextHooks, history) : history;
|
|
1242
|
+
let lastError = null;
|
|
1243
|
+
// Server-authoritative unsupported: when a 400 names the reasoning knob,
|
|
1244
|
+
// the flag below drops it and the loop retries without it (once per call).
|
|
1245
|
+
let effortDropped = false;
|
|
1246
|
+
// Same contract for vision input: a 400 naming image input retries once
|
|
1247
|
+
// with descriptors stripped to prose markers.
|
|
1248
|
+
let mediaStripped = false;
|
|
1249
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
1250
|
+
try {
|
|
1251
|
+
throwIfCancelled(signal);
|
|
1252
|
+
try {
|
|
1253
|
+
opts?.onPhase?.("thinking");
|
|
1254
|
+
}
|
|
1255
|
+
catch {
|
|
1256
|
+
// ignore observer errors
|
|
1257
|
+
}
|
|
1258
|
+
const effortParam = effortDropped
|
|
1259
|
+
? undefined
|
|
1260
|
+
: responsesEffortParam(opts?.reasoningEffort);
|
|
1261
|
+
const summaryOpts = opts;
|
|
1262
|
+
const mediaMode = opts?.stripMedia === true || mediaStripped
|
|
1263
|
+
? "strip"
|
|
1264
|
+
: "send";
|
|
1265
|
+
const converted = buildResponsesBody(outgoingHistory, model, {
|
|
1266
|
+
// Compaction path only: tools disabled means NO `tools` key at all;
|
|
1267
|
+
// the normal loop always sends the schema so the model can discover
|
|
1268
|
+
// and call tools exactly like builtins. update_goal rides along only
|
|
1269
|
+
// for live goal turns (includeUpdateGoal); otherwise hidden.
|
|
1270
|
+
includeTools: summaryOpts?.disableTools === true ? false : undefined,
|
|
1271
|
+
includeUpdateGoal: opts?.includeUpdateGoal,
|
|
1272
|
+
stripMedia: mediaMode === "strip" ? true : undefined,
|
|
1273
|
+
});
|
|
1274
|
+
// mediaMode is recomputed per attempt, so the strip retry below
|
|
1275
|
+
// rebuilds the body in strip mode on its next pass through the loop.
|
|
1276
|
+
const payload = {
|
|
1277
|
+
model: converted.model,
|
|
1278
|
+
...(converted.instructions !== undefined
|
|
1279
|
+
? { instructions: converted.instructions }
|
|
1280
|
+
: {}),
|
|
1281
|
+
input: converted.input,
|
|
1282
|
+
stream: true,
|
|
1283
|
+
};
|
|
1284
|
+
if (converted.tools !== undefined)
|
|
1285
|
+
payload["tools"] = converted.tools;
|
|
1286
|
+
// Compaction path only: cap output (responses kind uses
|
|
1287
|
+
// max_output_tokens, same name as the cap option).
|
|
1288
|
+
if (typeof summaryOpts?.maxOutputTokens === "number" &&
|
|
1289
|
+
Number.isFinite(summaryOpts.maxOutputTokens) &&
|
|
1290
|
+
summaryOpts.maxOutputTokens > 0) {
|
|
1291
|
+
payload["max_output_tokens"] = Math.floor(summaryOpts.maxOutputTokens);
|
|
1292
|
+
}
|
|
1293
|
+
if (effortParam !== undefined)
|
|
1294
|
+
payload["reasoning"] = { effort: effortParam };
|
|
1295
|
+
const preHooks = beforeRequestInterceptors();
|
|
1296
|
+
const outgoing = preHooks.length > 0
|
|
1297
|
+
? await applyBeforeRequest(preHooks, {
|
|
1298
|
+
provider: hookProvider,
|
|
1299
|
+
model,
|
|
1300
|
+
url: endpoint,
|
|
1301
|
+
payload,
|
|
1302
|
+
headers: { ...zenHeaders(apiKey, { requestId: zenRequestId() }) },
|
|
1303
|
+
})
|
|
1304
|
+
: {
|
|
1305
|
+
payload,
|
|
1306
|
+
headers: { ...zenHeaders(apiKey, { requestId: zenRequestId() }) },
|
|
1307
|
+
};
|
|
1308
|
+
const res = await fetch(endpoint, {
|
|
1309
|
+
method: "POST",
|
|
1310
|
+
// Anonymous-capable: zenHeaders omits Authorization when no key —
|
|
1311
|
+
// never an empty `Bearer `.
|
|
1312
|
+
headers: outgoing.headers,
|
|
1313
|
+
body: JSON.stringify(outgoing.payload),
|
|
1314
|
+
...(signal ? { signal } : {}),
|
|
1315
|
+
});
|
|
1316
|
+
const postHooks = afterResponseObservers();
|
|
1317
|
+
if (postHooks.length > 0) {
|
|
1318
|
+
await notifyAfterResponse(postHooks, {
|
|
1319
|
+
provider: hookProvider,
|
|
1320
|
+
model,
|
|
1321
|
+
url: endpoint,
|
|
1322
|
+
status: res.status,
|
|
1323
|
+
ok: res.ok,
|
|
1324
|
+
headers: snapshotResponseHeaders(res),
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
if (!res.ok) {
|
|
1328
|
+
const errText = await safeErrorText(res);
|
|
1329
|
+
if (res.status === 400 &&
|
|
1330
|
+
!mediaStripped &&
|
|
1331
|
+
opts?.stripMedia !== true &&
|
|
1332
|
+
historyHasMedia(outgoingHistory) &&
|
|
1333
|
+
isImageRejection(errText)) {
|
|
1334
|
+
mediaStripped = true;
|
|
1335
|
+
try {
|
|
1336
|
+
opts?.onWarning?.(`image input is not supported by ${model} — continuing without images`);
|
|
1337
|
+
}
|
|
1338
|
+
catch {
|
|
1339
|
+
// ignore observer errors
|
|
1340
|
+
}
|
|
1341
|
+
throwIfCancelled(signal);
|
|
1342
|
+
continue;
|
|
1343
|
+
}
|
|
1344
|
+
if (res.status === 400 &&
|
|
1345
|
+
effortParam !== undefined &&
|
|
1346
|
+
!effortDropped &&
|
|
1347
|
+
isResponsesEffortRejection(errText)) {
|
|
1348
|
+
effortDropped = true;
|
|
1349
|
+
try {
|
|
1350
|
+
opts?.onWarning?.(`reasoning effort "${effortParam}" is not supported by ${model} — continuing without it`);
|
|
1351
|
+
}
|
|
1352
|
+
catch {
|
|
1353
|
+
// ignore observer errors
|
|
1354
|
+
}
|
|
1355
|
+
throwIfCancelled(signal);
|
|
1356
|
+
continue;
|
|
1357
|
+
}
|
|
1358
|
+
const err = new Error(`${errorLabel} HTTP ${res.status}: ${errText.slice(0, 300)}`);
|
|
1359
|
+
if (!RETRYABLE_STATUS.has(res.status))
|
|
1360
|
+
throw err;
|
|
1361
|
+
if (attempt < MAX_RETRIES) {
|
|
1362
|
+
throwIfCancelled(signal);
|
|
1363
|
+
const delay = getRetryDelay(attempt, res);
|
|
1364
|
+
try {
|
|
1365
|
+
opts?.onPhase?.("retry", `attempt ${attempt + 1}/${MAX_RETRIES} after ${delay}ms (HTTP ${res.status})`);
|
|
1366
|
+
}
|
|
1367
|
+
catch {
|
|
1368
|
+
// ignore
|
|
1369
|
+
}
|
|
1370
|
+
await sleep(delay);
|
|
1371
|
+
throwIfCancelled(signal);
|
|
1372
|
+
lastError = err;
|
|
1373
|
+
continue;
|
|
1374
|
+
}
|
|
1375
|
+
throw err;
|
|
1376
|
+
}
|
|
1377
|
+
if (!hasStreamBody(res)) {
|
|
1378
|
+
// Non-streaming responses object (no chat `choices` shape here —
|
|
1379
|
+
// parseResponsesObject reads the Responses `output` array).
|
|
1380
|
+
const data = await res.json();
|
|
1381
|
+
return parseResponsesObject(data);
|
|
1382
|
+
}
|
|
1383
|
+
return await readResponsesSSEMessage(res, opts);
|
|
1384
|
+
}
|
|
1385
|
+
catch (e) {
|
|
1386
|
+
if (isCancelError(e) || signal?.aborted)
|
|
1387
|
+
throw new LoopCancelledError();
|
|
1388
|
+
if (e instanceof Error && e.message.startsWith(`${errorLabel} HTTP`))
|
|
1389
|
+
throw e;
|
|
968
1390
|
if (e instanceof Error &&
|
|
969
1391
|
(e.message.startsWith("Empty reply") || e.message.startsWith("Truncated stream"))) {
|
|
970
1392
|
throw e;
|
|
971
1393
|
}
|
|
972
|
-
// Anything else is a network-level throw: retry when attempts remain.
|
|
973
1394
|
if (attempt < MAX_RETRIES) {
|
|
974
1395
|
const delay = getRetryDelay(attempt, undefined);
|
|
975
1396
|
try {
|
|
@@ -1007,6 +1428,20 @@ opts, errorLabel = "Zen") {
|
|
|
1007
1428
|
// on display). A length-truncated response (`finish_reason: "length"`) does
|
|
1008
1429
|
// not throw: the loop fails each carried tool call inline with a repair
|
|
1009
1430
|
// error and continues to the next model round.
|
|
1431
|
+
// Per-POST goal-tool visibility: update_goal rides the schema only while a
|
|
1432
|
+
// live goal turn is engaged (guarded — a throwing accessor reads as no
|
|
1433
|
+
// goal, exactly like the loop's readLiveGoal). Evaluated per POST so a goal
|
|
1434
|
+
// set, paused, or cleared mid-turn reshapes the very next schema; callers
|
|
1435
|
+
// without a goal hook (compaction, web, tests) read as no-goal and send the
|
|
1436
|
+
// legacy full surface only when they leave includeUpdateGoal undefined.
|
|
1437
|
+
function isGoalTurnLive(opts) {
|
|
1438
|
+
try {
|
|
1439
|
+
return opts?.goal?.getGoal?.()?.active === true;
|
|
1440
|
+
}
|
|
1441
|
+
catch {
|
|
1442
|
+
return false;
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1010
1445
|
export async function runAgenticLoop(endpoint, apiKey, model, history, opts) {
|
|
1011
1446
|
return runLoopWithChat((h, o) => chatCompletion(endpoint, apiKey, model, h, {
|
|
1012
1447
|
onToken: o?.onToken,
|
|
@@ -1017,6 +1452,7 @@ export async function runAgenticLoop(endpoint, apiKey, model, history, opts) {
|
|
|
1017
1452
|
sleep: o?.sleep,
|
|
1018
1453
|
reasoningEffort: o?.reasoningEffort,
|
|
1019
1454
|
signal: o?.signal,
|
|
1455
|
+
includeUpdateGoal: isGoalTurnLive(opts),
|
|
1020
1456
|
}), history, opts);
|
|
1021
1457
|
}
|
|
1022
1458
|
// AGENTS.md loading: <cwd>/AGENTS.md (or $OPENCODE_AGENTS_PATH when set)
|
|
@@ -1068,6 +1504,9 @@ export async function chatCompletionAnthropic(apiKey, model, history, opts) {
|
|
|
1068
1504
|
// Same server-authoritative unsupported contract as the openai-chat path:
|
|
1069
1505
|
// a 400 naming the thinking knob drops it for the rest of the call.
|
|
1070
1506
|
let anthropicEffortDropped = false;
|
|
1507
|
+
// Vision fallback (see src/media.ts): a 400 naming image input retries
|
|
1508
|
+
// once with descriptors stripped to markers.
|
|
1509
|
+
let anthropicMediaStripped = false;
|
|
1071
1510
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
1072
1511
|
try {
|
|
1073
1512
|
throwIfCancelled(signal);
|
|
@@ -1080,6 +1519,8 @@ export async function chatCompletionAnthropic(apiKey, model, history, opts) {
|
|
|
1080
1519
|
const summaryOpts = opts;
|
|
1081
1520
|
const base = buildAnthropicBody(outgoingHistory, model, {
|
|
1082
1521
|
includeTools: !summaryOpts?.disableTools,
|
|
1522
|
+
includeUpdateGoal: opts?.includeUpdateGoal !== false,
|
|
1523
|
+
stripMedia: opts?.stripMedia === true || anthropicMediaStripped,
|
|
1083
1524
|
});
|
|
1084
1525
|
const body = { ...base, stream: true };
|
|
1085
1526
|
// Compaction cap (anthropic kind uses max_tokens; default is already
|
|
@@ -1131,6 +1572,23 @@ export async function chatCompletionAnthropic(apiKey, model, history, opts) {
|
|
|
1131
1572
|
}
|
|
1132
1573
|
if (!res.ok) {
|
|
1133
1574
|
const errText = await safeErrorText(res);
|
|
1575
|
+
// Vision fallback (see src/media.ts): a 400 naming image input
|
|
1576
|
+
// retries once with descriptors stripped to markers.
|
|
1577
|
+
if (res.status === 400 &&
|
|
1578
|
+
!anthropicMediaStripped &&
|
|
1579
|
+
opts?.stripMedia !== true &&
|
|
1580
|
+
historyHasMedia(outgoingHistory) &&
|
|
1581
|
+
isImageRejection(errText)) {
|
|
1582
|
+
anthropicMediaStripped = true;
|
|
1583
|
+
try {
|
|
1584
|
+
opts?.onWarning?.(`image input is not supported by ${model} — continuing without images`);
|
|
1585
|
+
}
|
|
1586
|
+
catch {
|
|
1587
|
+
// ignore observer errors
|
|
1588
|
+
}
|
|
1589
|
+
throwIfCancelled(signal);
|
|
1590
|
+
continue;
|
|
1591
|
+
}
|
|
1134
1592
|
if (res.status === 400 &&
|
|
1135
1593
|
anthropicBudget !== undefined &&
|
|
1136
1594
|
!anthropicEffortDropped &&
|
|
@@ -1215,6 +1673,9 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
|
|
|
1215
1673
|
// Same server-authoritative unsupported contract as the other paths: a
|
|
1216
1674
|
// 400 naming the thinking knob drops it for the rest of the call.
|
|
1217
1675
|
let geminiEffortDropped = false;
|
|
1676
|
+
// Vision fallback (see src/media.ts): a 400 naming image input retries
|
|
1677
|
+
// once with descriptors stripped to markers.
|
|
1678
|
+
let geminiMediaStripped = false;
|
|
1218
1679
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
1219
1680
|
try {
|
|
1220
1681
|
throwIfCancelled(signal);
|
|
@@ -1227,11 +1688,13 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
|
|
|
1227
1688
|
const summaryOpts = opts;
|
|
1228
1689
|
const body = buildGeminiBody(outgoingHistory, model, {
|
|
1229
1690
|
includeTools: !summaryOpts?.disableTools,
|
|
1691
|
+
includeUpdateGoal: opts?.includeUpdateGoal !== false,
|
|
1230
1692
|
...(typeof summaryOpts?.maxOutputTokens === "number" &&
|
|
1231
1693
|
Number.isFinite(summaryOpts.maxOutputTokens) &&
|
|
1232
1694
|
summaryOpts.maxOutputTokens > 0
|
|
1233
1695
|
? { maxOutputTokens: Math.floor(summaryOpts.maxOutputTokens) }
|
|
1234
1696
|
: {}),
|
|
1697
|
+
stripMedia: opts?.stripMedia === true || geminiMediaStripped,
|
|
1235
1698
|
});
|
|
1236
1699
|
// /effort maps to the native thinkingLevel (Auto omits it; Max rides
|
|
1237
1700
|
// high, the deepest level the API offers). Merged into
|
|
@@ -1278,6 +1741,23 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
|
|
|
1278
1741
|
}
|
|
1279
1742
|
if (!res.ok) {
|
|
1280
1743
|
const errText = await safeErrorText(res);
|
|
1744
|
+
// Vision fallback (see src/media.ts): a 400 naming image input
|
|
1745
|
+
// retries once with descriptors stripped to markers.
|
|
1746
|
+
if (res.status === 400 &&
|
|
1747
|
+
!geminiMediaStripped &&
|
|
1748
|
+
opts?.stripMedia !== true &&
|
|
1749
|
+
historyHasMedia(outgoingHistory) &&
|
|
1750
|
+
isImageRejection(errText)) {
|
|
1751
|
+
geminiMediaStripped = true;
|
|
1752
|
+
try {
|
|
1753
|
+
opts?.onWarning?.(`image input is not supported by ${model} — continuing without images`);
|
|
1754
|
+
}
|
|
1755
|
+
catch {
|
|
1756
|
+
// ignore observer errors
|
|
1757
|
+
}
|
|
1758
|
+
throwIfCancelled(signal);
|
|
1759
|
+
continue;
|
|
1760
|
+
}
|
|
1281
1761
|
if (res.status === 400 &&
|
|
1282
1762
|
geminiLevel !== undefined &&
|
|
1283
1763
|
!geminiEffortDropped &&
|
|
@@ -1387,11 +1867,13 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
|
|
|
1387
1867
|
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
1388
1868
|
}
|
|
1389
1869
|
// Provider dispatcher: openai-chat reuses chatCompletion with the provider's
|
|
1390
|
-
// error label; anthropic/gemini go through their adapters
|
|
1391
|
-
//
|
|
1392
|
-
//
|
|
1393
|
-
//
|
|
1394
|
-
//
|
|
1870
|
+
// error label; anthropic/gemini go through their adapters; Zen
|
|
1871
|
+
// responses-family models (muse-spark-*) ride chatCompletionResponses.
|
|
1872
|
+
// Effort rides every kind: reasoning_effort on openai-chat (all providers,
|
|
1873
|
+
// all models), reasoning.effort on responses, thinking budgets on
|
|
1874
|
+
// anthropic-messages, thinkingLevel on gemini-generate. Auto omits the knob
|
|
1875
|
+
// everywhere; a model that truly lacks it 400s and the transports above
|
|
1876
|
+
// retry once without it.
|
|
1395
1877
|
export async function chatCompletionForProvider(provider, apiKey, model, history, opts) {
|
|
1396
1878
|
const def = getProvider(provider);
|
|
1397
1879
|
if (!def)
|
|
@@ -1430,9 +1912,17 @@ export async function chatCompletionForProvider(provider, apiKey, model, history
|
|
|
1430
1912
|
...effortOpts,
|
|
1431
1913
|
// Compaction path only (undefined for the normal loop → tools sent).
|
|
1432
1914
|
...(opts?.disableTools !== undefined ? { disableTools: opts.disableTools } : {}),
|
|
1915
|
+
// Goal-tool visibility (undefined for compaction/summary callers →
|
|
1916
|
+
// legacy full surface; the loop entry points always set it per POST).
|
|
1917
|
+
...(opts?.includeUpdateGoal !== undefined
|
|
1918
|
+
? { includeUpdateGoal: opts.includeUpdateGoal }
|
|
1919
|
+
: {}),
|
|
1433
1920
|
...(opts?.maxOutputTokens !== undefined
|
|
1434
1921
|
? { maxOutputTokens: opts.maxOutputTokens }
|
|
1435
1922
|
: {}),
|
|
1923
|
+
// Media strip (compaction/summarization callers set it; the normal
|
|
1924
|
+
// loop leaves it undefined → images expand natively).
|
|
1925
|
+
...(opts?.stripMedia !== undefined ? { stripMedia: opts.stripMedia } : {}),
|
|
1436
1926
|
};
|
|
1437
1927
|
// Kilo rides the shared OpenAI-chat path (streaming, tool reconstruction,
|
|
1438
1928
|
// retry, effort with server-rejection fallback) with its registry
|
|
@@ -1446,6 +1936,13 @@ export async function chatCompletionForProvider(provider, apiKey, model, history
|
|
|
1446
1936
|
throw normalizeKiloChatError(e, apiKey);
|
|
1447
1937
|
}
|
|
1448
1938
|
}
|
|
1939
|
+
// Zen responses-family (muse-spark-*, free contributor tiers included):
|
|
1940
|
+
// same chatOpts surface (effort/tools/compaction/media/hooks), Responses
|
|
1941
|
+
// wire shape + /responses endpoint. Only opencode-zen serves this family;
|
|
1942
|
+
// every other provider keeps the chat path below byte-identical.
|
|
1943
|
+
if (provider === "opencode-zen" && isZenResponsesModel(model)) {
|
|
1944
|
+
return chatCompletionResponses(responsesEndpointFor(endpoint), apiKey, model, history, chatOpts, providerLabel(provider));
|
|
1945
|
+
}
|
|
1449
1946
|
return chatCompletion(endpoint, apiKey, model, history, chatOpts, providerLabel(provider));
|
|
1450
1947
|
}
|
|
1451
1948
|
export { evaluateTurnEnd, isCodePath, MAX_TODO_ROUNDS, MAX_VERIFY_ROUNDS, todoCompletionGate, TURN_END_GATES, verificationGate, } from "./agent/gates.js";
|
|
@@ -1486,6 +1983,7 @@ export async function runAgenticLoopForProvider(provider, apiKey, model, history
|
|
|
1486
1983
|
reasoningEffort: o?.reasoningEffort,
|
|
1487
1984
|
baseURL: opts?.baseURL,
|
|
1488
1985
|
endpointOverride: opts?.endpointOverride,
|
|
1986
|
+
includeUpdateGoal: isGoalTurnLive(opts),
|
|
1489
1987
|
}), history, opts);
|
|
1490
1988
|
}
|
|
1491
1989
|
// Per-provider model list: live list per kind with curated fallback on ANY
|