atom-agent 1.1.0 → 1.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 +106 -0
- package/README.md +18 -8
- package/atom.example.json +11 -0
- package/dist/App.js +1637 -255
- package/dist/adapters.js +112 -21
- package/dist/agent/gates.js +14 -1
- package/dist/agent/goal-evaluator.js +69 -0
- package/dist/agent/loop-guard.js +11 -13
- package/dist/agent/loop.js +716 -132
- package/dist/agent/normalize.js +9 -2
- package/dist/cli.js +25 -3
- package/dist/compact.js +169 -17
- package/dist/config.js +43 -7
- package/dist/context-manager.js +16 -198
- package/dist/context-windows.js +4 -2
- package/dist/env-block.js +46 -8
- package/dist/extension-commands.js +196 -0
- package/dist/extension-ui.js +153 -0
- package/dist/extensions.js +1571 -0
- package/dist/goal.js +583 -0
- package/dist/project-trust.js +96 -0
- package/dist/providers.js +6 -6
- package/dist/scheduler.js +159 -41
- package/dist/session.js +23 -5
- package/dist/sessions.js +543 -0
- package/dist/system.js +89 -13
- package/dist/telemetry-dashboard.js +28 -0
- package/dist/telemetry.js +39 -0
- package/dist/tools/compaction-hooks.js +165 -0
- package/dist/tools/custom.js +189 -0
- package/dist/tools/dir-cache.js +7 -0
- package/dist/tools/filesystem.js +3 -2
- package/dist/tools/intercept.js +145 -0
- package/dist/tools/overrides.js +105 -0
- package/dist/tools/provider-hooks.js +224 -0
- package/dist/tools/registry.js +247 -17
- package/dist/tools/ripgrep.js +256 -0
- package/dist/tools/search.js +119 -58
- package/dist/tools/shared.js +39 -0
- package/dist/tools/shell.js +7 -5
- package/dist/tools/web.js +6 -6
- package/dist/tools.js +45 -0
- package/dist/ui/diff-view.js +7 -2
- package/dist/ui/live-host.js +18 -0
- package/dist/ui/live-tail.js +9 -3
- package/dist/ui/markdown.js +26 -2
- package/dist/ui/palette.js +3 -1
- package/dist/ui/side-by-side.js +2 -2
- package/dist/ui/status-bar.js +80 -5
- package/dist/ui/status-host.js +22 -0
- package/dist/ui/stream-store.js +48 -0
- package/dist/ui/tool-inspector.js +7 -1
- package/dist/ui/transcript.js +92 -38
- package/dist/zen.js +370 -87
- package/documentation/architecture.md +114 -0
- package/documentation/cli.md +82 -0
- package/documentation/compaction.md +50 -0
- package/documentation/configuration.md +111 -0
- package/documentation/development.md +62 -0
- package/documentation/extensions.md +160 -0
- package/documentation/getting-started.md +63 -0
- package/documentation/goals.md +41 -0
- package/documentation/index.md +41 -0
- package/documentation/observability.md +70 -0
- package/documentation/permissions.md +66 -0
- package/documentation/providers.md +78 -0
- package/documentation/sessions.md +92 -0
- package/documentation/skills.md +57 -0
- package/documentation/tools.md +94 -0
- package/documentation/troubleshooting.md +54 -0
- package/examples/extensions/01-audit-gate.js +24 -0
- package/examples/extensions/02-notes-tool.js +32 -0
- package/examples/extensions/03-custom-command.js +32 -0
- package/package.json +6 -2
package/dist/zen.js
CHANGED
|
@@ -5,14 +5,20 @@
|
|
|
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,
|
|
8
|
+
import { MAX_TOOL_STEPS, allToolDefinitions, 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_VERSION, anthropicHeaders, buildAnthropicBody, buildGeminiBody, geminiChatUrl, geminiGenerateUrl, geminiHeaders, parseAnthropicJson, parseAnthropicModelsList, parseGeminiJson, parseGeminiModelsList, parseOpenAIModelsList, readAnthropicSSEMessage, readGeminiSSEMessage, isStallError, readWithStall, } 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, isStallError, readWithStall, sseStallTimeoutMs, } from "./adapters.js";
|
|
12
12
|
export { isStallError, readWithStall, sseStallTimeoutMs } 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";
|
|
16
|
+
// Provider hooks (ticket 08): extension context/pre-request/post-response
|
|
17
|
+
// hooks fire per POST in the three transports below (openai-chat,
|
|
18
|
+
// anthropic-messages, gemini-generate), so every provider kind is covered
|
|
19
|
+
// through the chatCompletionForProvider dispatcher. All apply/notify helpers
|
|
20
|
+
// never throw (fail-open), so hooks can never break the turn.
|
|
21
|
+
import { afterResponseObservers, applyBeforeRequest, applyContextTransform, beforeRequestInterceptors, contextTransformers, notifyAfterResponse, snapshotResponseHeaders, } from "./tools/provider-hooks.js";
|
|
16
22
|
export { MAX_TOOL_STEPS };
|
|
17
23
|
// Re-exported so existing `SYSTEM_PROMPT` imports keep working; the
|
|
18
24
|
// owner-editable source of truth lives in src/system.ts.
|
|
@@ -22,61 +28,66 @@ export const MODELS_URL_DEFAULT = "https://opencode.ai/zen/v1/models";
|
|
|
22
28
|
// Task 5 default: strongest tool-reliable chat/completions default available,
|
|
23
29
|
// verified against the live /models list + https://opencode.ai/docs/zen on
|
|
24
30
|
// 2026-09-08 (endpoint chat/completions, Tool Calls support, not deprecated,
|
|
25
|
-
//
|
|
26
|
-
//
|
|
31
|
+
// verified 1M context window). Free models (big-pickle etc.) stay in
|
|
32
|
+
// FALLBACK_MODELS, selectable via /model.
|
|
27
33
|
export const DEFAULT_MODEL = "deepseek-v4-pro";
|
|
28
34
|
export const AGENTS_CHAR_CAP = 12 * 1024;
|
|
29
|
-
// ---- Conversation
|
|
30
|
-
// Long sessions
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
// (when valid) → project atom.json → global atom.json → compiled default:
|
|
35
|
-
// - ATOM_MAX_HISTORY_MESSAGES, clamped to 10–1000 (default 100)
|
|
36
|
-
// - ATOM_MAX_HISTORY_CHARS, clamped to 10_000–2_000_000 (default 200_000)
|
|
35
|
+
// ---- Conversation history: uncapped ----
|
|
36
|
+
// Long sessions ride on compaction, not truncation: the shared loop core
|
|
37
|
+
// sends the full history on every POST (uniform across providers) and
|
|
38
|
+
// auto-compact at ~83% of the verified window is the only pressure valve.
|
|
39
|
+
// There are no message/char caps and no trim step.
|
|
37
40
|
export { toolStepBudget } from "./agent/loop.js";
|
|
38
|
-
// Reasoning effort (session state in the App, default "
|
|
39
|
-
// Wire values are
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// the
|
|
41
|
+
// Reasoning effort (session state in the App, default "auto").
|
|
42
|
+
// Wire values are low/medium/high/max. "auto" never sends a param: it lets
|
|
43
|
+
// the model decide. The top setting is `Max`, sent on the wire as `max`.
|
|
44
|
+
// Support is assumed for every model on every provider kind — the server is
|
|
45
|
+
// authoritative: a model that truly lacks the knob fails the POST with a
|
|
46
|
+
// 400 naming the effort param, and the transports below retry once without
|
|
47
|
+
// it (see isEffortRejection in adapters.ts). Nothing is preemptively gated
|
|
48
|
+
// by model name, so "(unsupported)" only ever reflects an actual rejection.
|
|
44
49
|
export const EFFORT_OPTIONS = [
|
|
45
|
-
"
|
|
50
|
+
"auto",
|
|
46
51
|
"low",
|
|
47
52
|
"medium",
|
|
48
53
|
"high",
|
|
49
54
|
"max",
|
|
50
55
|
];
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
export
|
|
55
|
-
"
|
|
56
|
-
|
|
57
|
-
"
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
56
|
+
// Canonicalize a stored/picked effort value. "default" is the pre-auto name
|
|
57
|
+
// for the same level (old saves, old atom.json) and maps to "auto"; unknown
|
|
58
|
+
// values fall back to "auto" instead of stranding the session.
|
|
59
|
+
export function normalizeEffort(value) {
|
|
60
|
+
if (value === "default" || value === "auto")
|
|
61
|
+
return "auto";
|
|
62
|
+
if (value === "low" || value === "medium" || value === "high" || value === "max") {
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
return "auto";
|
|
66
|
+
}
|
|
67
|
+
// Effort support is provider-wide, never per-model: every known provider
|
|
68
|
+
// kind has a wire mapping (reasoning_effort on openai-chat, thinking on
|
|
69
|
+
// anthropic-messages, thinkingLevel on gemini-generate). Returns false only
|
|
70
|
+
// for an empty model or an unknown provider id — the actual per-model truth
|
|
71
|
+
// comes from the server at POST time (see above).
|
|
72
|
+
export function isEffortSupported(model, provider) {
|
|
73
|
+
if (!model)
|
|
74
|
+
return false;
|
|
75
|
+
if (provider === undefined)
|
|
76
|
+
return true;
|
|
77
|
+
return getProvider(provider) !== undefined;
|
|
64
78
|
}
|
|
65
79
|
// Wire value for the POST body, or undefined when the param must be
|
|
66
|
-
// omitted (
|
|
67
|
-
|
|
68
|
-
|
|
80
|
+
// omitted (Auto, or an unknown effort string). The `model` argument is
|
|
81
|
+
// accepted for backward compatibility and intentionally ignored: support is
|
|
82
|
+
// assumed for every model, with server rejection as the only veto.
|
|
83
|
+
export function reasoningEffortParam(effort, _model) {
|
|
84
|
+
const normalized = normalizeEffort(effort);
|
|
85
|
+
if (normalized === "auto")
|
|
69
86
|
return undefined;
|
|
70
|
-
|
|
71
|
-
return undefined;
|
|
72
|
-
if (effort === "low" || effort === "medium" || effort === "high" || effort === "max") {
|
|
73
|
-
return effort;
|
|
74
|
-
}
|
|
75
|
-
return undefined;
|
|
87
|
+
return normalized;
|
|
76
88
|
}
|
|
77
|
-
export { CHARS_PER_TOKEN,
|
|
89
|
+
export { CHARS_PER_TOKEN, createContextManager, estimateTokensForChars, historyChars, messageChars, } from "./context-manager.js";
|
|
78
90
|
export { openTodoNeedles } from "./agent/gates.js";
|
|
79
|
-
export { truncateHistory } from "./agent/loop.js";
|
|
80
91
|
function finiteCount(value) {
|
|
81
92
|
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
82
93
|
? Math.floor(value)
|
|
@@ -337,11 +348,20 @@ export async function fetchModels(endpoint, apiKey) {
|
|
|
337
348
|
// - Slots with an id but no name at [DONE] are dropped with an onWarning
|
|
338
349
|
// message and never returned (keeps assistant/tool pairing valid).
|
|
339
350
|
// - A stream that ends without [DONE] throws a truncation error.
|
|
351
|
+
// - A response flagged `finish_reason: "length"` (output limit cut the
|
|
352
|
+
// response off, so tool arguments are incomplete) does NOT throw: it
|
|
353
|
+
// returns normally with `truncated: true` so the loop can fail each carried
|
|
354
|
+
// tool call inline and continue the turn. Transport failures (aborted
|
|
355
|
+
// connections, stalls, empty replies) keep throwing.
|
|
340
356
|
// - A stream silent longer than the stall budget (env ATOM_STALL_TIMEOUT_MS,
|
|
341
357
|
// default 60s; the clock resets on every received chunk) throws a
|
|
342
358
|
// Truncated-stream stall error — permanent, never retried, same contract
|
|
343
359
|
// as a dead connection (verified live: free-tier routers can stall a
|
|
344
360
|
// 200-OK stream mid-generation for minutes).
|
|
361
|
+
// - Queue comments (`: ...`) and keep-alives carry bytes but no model output:
|
|
362
|
+
// only `data:` payload lines refresh the data-silence clock, so minutes of
|
|
363
|
+
// `: KILO PROCESSING` while queued fail fast instead of hanging the turn
|
|
364
|
+
// (same budget, same permanent contract).
|
|
345
365
|
// - A stream with zero "data:" lines is treated as a non-SSE JSON payload
|
|
346
366
|
// (tolerance for bodies that are really single-shot JSON) and parsed as
|
|
347
367
|
// choices[0].message like the non-streaming fallback.
|
|
@@ -361,9 +381,29 @@ export async function readSSEMessage(res, opts) {
|
|
|
361
381
|
// reasoning label seen in any delta.
|
|
362
382
|
let streamUsage;
|
|
363
383
|
let streamReasoning;
|
|
384
|
+
// Output-limit flag (see contract above): set when any streamed choice
|
|
385
|
+
// reports `finish_reason: "length"`. Returned on the result — never thrown.
|
|
386
|
+
let lengthTruncated = false;
|
|
364
387
|
// Accumulated thinking text (see onThinking): kept apart from fullText so
|
|
365
388
|
// reasoning never leaks into the answer, history, or tool arguments.
|
|
366
389
|
let fullThinking = "";
|
|
390
|
+
// Data-silence tracking (see throwIfDataStalled): timestamp of the last
|
|
391
|
+
// `data:` payload line. Queue comments (`: KILO PROCESSING`) and keep-alive
|
|
392
|
+
// comments carry bytes but no model output — they advance the raw stream
|
|
393
|
+
// but must NOT extend the stall budget (live-proven: minutes of comments
|
|
394
|
+
// while a free-tier request sits queued).
|
|
395
|
+
let lastDataAt = Date.now();
|
|
396
|
+
// Fail fast when the stream flows (or idles) with no model output: same
|
|
397
|
+
// permanent Truncated contract as a dead connection (passes through every
|
|
398
|
+
// catch below untouched), same env knob as the per-read byte race. Checked
|
|
399
|
+
// after each drained chunk — legitimately slow generations keep emitting
|
|
400
|
+
// `data:` lines, so only true silence trips it.
|
|
401
|
+
function throwIfDataStalled() {
|
|
402
|
+
const budget = sseStallTimeoutMs();
|
|
403
|
+
if (Date.now() - lastDataAt > budget) {
|
|
404
|
+
throw new Error(`Truncated stream from model (stall: no output for ${budget}ms — queued or stalled upstream; resend to retry).`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
367
407
|
function announceStreaming() {
|
|
368
408
|
if (!streamingAnnounced) {
|
|
369
409
|
streamingAnnounced = true;
|
|
@@ -386,6 +426,7 @@ export async function readSSEMessage(res, opts) {
|
|
|
386
426
|
if (!line.startsWith("data:"))
|
|
387
427
|
return; // event:/id:/retry: ignored
|
|
388
428
|
sawData = true;
|
|
429
|
+
lastDataAt = Date.now();
|
|
389
430
|
let payload = line.slice("data:".length);
|
|
390
431
|
if (payload.startsWith(" "))
|
|
391
432
|
payload = payload.slice(1);
|
|
@@ -410,6 +451,10 @@ export async function readSSEMessage(res, opts) {
|
|
|
410
451
|
}
|
|
411
452
|
const choice = evt
|
|
412
453
|
?.choices?.[0];
|
|
454
|
+
// Output-limit marker rides on the choice, beside the delta — any chunk
|
|
455
|
+
// reporting it means the tool arguments below are incomplete.
|
|
456
|
+
if (choice?.finish_reason === "length")
|
|
457
|
+
lengthTruncated = true;
|
|
413
458
|
const delta = (choice?.delta ?? choice?.message);
|
|
414
459
|
if (typeof delta !== "object" || delta === null)
|
|
415
460
|
return;
|
|
@@ -533,6 +578,7 @@ export async function readSSEMessage(res, opts) {
|
|
|
533
578
|
rawText += text;
|
|
534
579
|
buffer += text;
|
|
535
580
|
drainBuffer();
|
|
581
|
+
throwIfDataStalled();
|
|
536
582
|
if (sawDone) {
|
|
537
583
|
try {
|
|
538
584
|
await reader.cancel?.();
|
|
@@ -569,6 +615,7 @@ export async function readSSEMessage(res, opts) {
|
|
|
569
615
|
rawText += text;
|
|
570
616
|
buffer += text;
|
|
571
617
|
drainBuffer();
|
|
618
|
+
throwIfDataStalled();
|
|
572
619
|
if (sawDone)
|
|
573
620
|
break;
|
|
574
621
|
}
|
|
@@ -612,6 +659,10 @@ export async function readSSEMessage(res, opts) {
|
|
|
612
659
|
throw new Error(`Truncated stream from model (connection aborted: ${e instanceof Error ? e.message : String(e)}).`);
|
|
613
660
|
}
|
|
614
661
|
// Tolerance: a body with no SSE data lines is really single-shot JSON.
|
|
662
|
+
// (The data-silence bound applies only once real SSE traffic exists, so
|
|
663
|
+
// whole-body JSON payloads are never false-tripped by it.)
|
|
664
|
+
if (sawData)
|
|
665
|
+
throwIfDataStalled();
|
|
615
666
|
if (!sawData) {
|
|
616
667
|
const candidate = rawText.trim();
|
|
617
668
|
if (candidate.length > 0) {
|
|
@@ -628,6 +679,8 @@ export async function readSSEMessage(res, opts) {
|
|
|
628
679
|
content,
|
|
629
680
|
tool_calls: calls.length > 0 ? calls : undefined,
|
|
630
681
|
};
|
|
682
|
+
if (data?.choices?.[0]?.finish_reason === "length")
|
|
683
|
+
result.truncated = true;
|
|
631
684
|
const usage = parseUsage(data?.usage);
|
|
632
685
|
if (usage !== undefined)
|
|
633
686
|
result.usage = usage;
|
|
@@ -675,6 +728,8 @@ export async function readSSEMessage(res, opts) {
|
|
|
675
728
|
content: fullText.length > 0 ? fullText : null,
|
|
676
729
|
tool_calls: calls.length > 0 ? calls : undefined,
|
|
677
730
|
};
|
|
731
|
+
if (lengthTruncated)
|
|
732
|
+
result.truncated = true;
|
|
678
733
|
if (streamUsage !== undefined)
|
|
679
734
|
result.usage = streamUsage;
|
|
680
735
|
if (streamReasoning !== undefined)
|
|
@@ -683,16 +738,21 @@ export async function readSSEMessage(res, opts) {
|
|
|
683
738
|
}
|
|
684
739
|
// Streaming chat POST with tools attached (tool_choice omitted, so the
|
|
685
740
|
// default auto applies). Sends {..., stream:true} plus `reasoning_effort`
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
//
|
|
741
|
+
// whenever opts.reasoningEffort is non-Auto (see reasoningEffortParam) —
|
|
742
|
+
// for every model, on every provider routed through this transport.
|
|
743
|
+
// A 400 naming the knob means the model truly lacks it: warn once via
|
|
744
|
+
// onWarning and retry without it. Parses the SSE event stream (see
|
|
745
|
+
// readSSEMessage).
|
|
689
746
|
// When the response has no SSE body (plain {ok, json()} mocks and other
|
|
690
747
|
// non-streaming payloads) it falls back to the original single-JSON parse,
|
|
691
748
|
// unchanged. Returns the raw assistant message: either final content or
|
|
692
749
|
// tool_calls the caller must execute, plus `usage`/`reasoning` only when
|
|
693
750
|
// the response actually carried them (usage: top-level `usage` on JSON or
|
|
694
751
|
// SSE final chunks; reasoning: message/delta reasoning metadata).
|
|
695
|
-
// Throws on HTTP error, empty reply, or a truncated stream
|
|
752
|
+
// Throws on HTTP error, empty reply, or a truncated stream (aborted
|
|
753
|
+
// connection / stall / missing [DONE]). A response flagged
|
|
754
|
+
// `finish_reason: "length"` instead returns normally with `truncated: true`
|
|
755
|
+
// (the loop fails its tool calls inline and continues).
|
|
696
756
|
// - Network throws and HTTP 429/500/502/503/504 are retried up to
|
|
697
757
|
// MAX_RETRIES (10) with 1s→2s→4s… backoff, honoring Retry-After capped
|
|
698
758
|
// at 30s.
|
|
@@ -703,10 +763,25 @@ export async function readSSEMessage(res, opts) {
|
|
|
703
763
|
// the dispatcher passes providerLabel(provider) for non-zen openai-chat
|
|
704
764
|
// providers so users see e.g. `OpenAI HTTP 401` instead of `Zen HTTP 401`.
|
|
705
765
|
// Legacy `function_call` shape is intentionally ignored.
|
|
706
|
-
export async function chatCompletion(endpoint, apiKey, model, history,
|
|
766
|
+
export async function chatCompletion(endpoint, apiKey, model, history,
|
|
767
|
+
// providerId (ticket 08): hook attribution for the shared openai-chat
|
|
768
|
+
// transport — the dispatcher passes its provider id, direct zen callers
|
|
769
|
+
// omit it and default to "opencode-zen". Optional, wire-compatible.
|
|
770
|
+
opts, errorLabel = "Zen") {
|
|
707
771
|
const sleep = opts?.sleep ?? defaultSleep;
|
|
708
772
|
const signal = opts?.signal ?? null;
|
|
773
|
+
const hookProvider = opts?.providerId ?? "opencode-zen";
|
|
774
|
+
// Context hooks (ticket 08) run once per call — not per retry — over a
|
|
775
|
+
// per-POST copy; the loop transcript array is never mutated. Fail-open:
|
|
776
|
+
// a throwing handler degrades to the untransformed messages. Zero-cost
|
|
777
|
+
// when no hooks are registered: the apply path is skipped entirely (no
|
|
778
|
+
// extra awaits per POST), so hook-free turns keep byte-identical timing.
|
|
779
|
+
const contextHooks = contextTransformers();
|
|
780
|
+
const outgoingHistory = contextHooks.length > 0 ? await applyContextTransform(contextHooks, history) : history;
|
|
709
781
|
let lastError = null;
|
|
782
|
+
// Server-authoritative unsupported: when a 400 names the effort knob, the
|
|
783
|
+
// flag below drops it and the loop retries without it (once per call).
|
|
784
|
+
let effortDropped = false;
|
|
710
785
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
711
786
|
try {
|
|
712
787
|
throwIfCancelled(signal);
|
|
@@ -716,7 +791,9 @@ export async function chatCompletion(endpoint, apiKey, model, history, opts, err
|
|
|
716
791
|
catch {
|
|
717
792
|
// ignore observer errors
|
|
718
793
|
}
|
|
719
|
-
const effortParam =
|
|
794
|
+
const effortParam = effortDropped
|
|
795
|
+
? undefined
|
|
796
|
+
: reasoningEffortParam(opts?.reasoningEffort, model);
|
|
720
797
|
const summaryOpts = opts;
|
|
721
798
|
// Stable-prefix split (prompt-cache architecture): history[0]'s env
|
|
722
799
|
// tail becomes its own system message so the stable head + tools stay
|
|
@@ -724,16 +801,18 @@ export async function chatCompletion(endpoint, apiKey, model, history, opts, err
|
|
|
724
801
|
// system messages concatenate on every OpenAI-protocol server, so this
|
|
725
802
|
// is content-neutral. No env tail (tests, old saves) → history passes
|
|
726
803
|
// through untouched, byte-identical to before.
|
|
727
|
-
const messages = splitSystemHead(
|
|
804
|
+
const messages = splitSystemHead(outgoingHistory);
|
|
728
805
|
const payload = {
|
|
729
806
|
model,
|
|
730
807
|
messages,
|
|
731
808
|
stream: true,
|
|
732
809
|
};
|
|
733
810
|
// Compaction path only: tools disabled means NO `tools` key at all
|
|
734
|
-
// (asserted in tests); the normal loop always sends the schema
|
|
811
|
+
// (asserted in tests); the normal loop always sends the schema —
|
|
812
|
+
// builtins plus extension-registered custom tools, so the model can
|
|
813
|
+
// discover and call them exactly like builtins.
|
|
735
814
|
if (!summaryOpts?.disableTools) {
|
|
736
|
-
payload["tools"] =
|
|
815
|
+
payload["tools"] = allToolDefinitions();
|
|
737
816
|
}
|
|
738
817
|
// Compaction path only: cap output (openai-chat kind uses max_tokens).
|
|
739
818
|
if (typeof summaryOpts?.maxOutputTokens === "number" &&
|
|
@@ -743,21 +822,73 @@ export async function chatCompletion(endpoint, apiKey, model, history, opts, err
|
|
|
743
822
|
}
|
|
744
823
|
if (effortParam !== undefined)
|
|
745
824
|
payload["reasoning_effort"] = effortParam;
|
|
825
|
+
// Pre-request hooks (ticket 08) run per POST attempt: payload
|
|
826
|
+
// replacement must be a record (else ignored — downstream JSON/fetch
|
|
827
|
+
// handling is never bypassed); header merge honors deletions.
|
|
828
|
+
// Zero-cost when unregistered (see context hooks above).
|
|
829
|
+
const preHooks = beforeRequestInterceptors();
|
|
830
|
+
const outgoing = preHooks.length > 0
|
|
831
|
+
? await applyBeforeRequest(preHooks, {
|
|
832
|
+
provider: hookProvider,
|
|
833
|
+
model,
|
|
834
|
+
url: endpoint,
|
|
835
|
+
payload,
|
|
836
|
+
headers: {
|
|
837
|
+
"Content-Type": "application/json",
|
|
838
|
+
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
839
|
+
},
|
|
840
|
+
})
|
|
841
|
+
: {
|
|
842
|
+
payload,
|
|
843
|
+
headers: {
|
|
844
|
+
"Content-Type": "application/json",
|
|
845
|
+
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
846
|
+
},
|
|
847
|
+
};
|
|
746
848
|
const res = await fetch(endpoint, {
|
|
747
849
|
method: "POST",
|
|
748
850
|
// Anonymous-capable providers (Kilo free models) omit Authorization
|
|
749
851
|
// when no key is configured — never an empty `Bearer `. Keyed
|
|
750
852
|
// providers always pass a key (gated by providerNeedsKey), so their
|
|
751
853
|
// behavior is unchanged.
|
|
752
|
-
headers:
|
|
753
|
-
|
|
754
|
-
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
755
|
-
},
|
|
756
|
-
body: JSON.stringify(payload),
|
|
854
|
+
headers: outgoing.headers,
|
|
855
|
+
body: JSON.stringify(outgoing.payload),
|
|
757
856
|
...(signal ? { signal } : {}),
|
|
758
857
|
});
|
|
858
|
+
// Post-response observers (ticket 08): every resolved POST (ok and
|
|
859
|
+
// HTTP-error alike), fail-open — never break the turn. Zero-cost when
|
|
860
|
+
// unregistered (the header snapshot is only built for live observers).
|
|
861
|
+
const postHooks = afterResponseObservers();
|
|
862
|
+
if (postHooks.length > 0) {
|
|
863
|
+
await notifyAfterResponse(postHooks, {
|
|
864
|
+
provider: hookProvider,
|
|
865
|
+
model,
|
|
866
|
+
url: endpoint,
|
|
867
|
+
status: res.status,
|
|
868
|
+
ok: res.ok,
|
|
869
|
+
headers: snapshotResponseHeaders(res),
|
|
870
|
+
});
|
|
871
|
+
}
|
|
759
872
|
if (!res.ok) {
|
|
760
873
|
const errText = await safeErrorText(res);
|
|
874
|
+
// The server is the authority on effort support: a 400 naming the
|
|
875
|
+
// knob means this model/deployment has no such control — warn,
|
|
876
|
+
// drop the knob, and retry without it (setting kept). Any other
|
|
877
|
+
// 400 keeps failing loudly below.
|
|
878
|
+
if (res.status === 400 &&
|
|
879
|
+
effortParam !== undefined &&
|
|
880
|
+
!effortDropped &&
|
|
881
|
+
isEffortRejection(errText)) {
|
|
882
|
+
effortDropped = true;
|
|
883
|
+
try {
|
|
884
|
+
opts?.onWarning?.(`reasoning effort "${effortParam}" is not supported by ${model} — continuing without it`);
|
|
885
|
+
}
|
|
886
|
+
catch {
|
|
887
|
+
// ignore observer errors
|
|
888
|
+
}
|
|
889
|
+
throwIfCancelled(signal);
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
761
892
|
const err = new Error(`${errorLabel} HTTP ${res.status}: ${errText.slice(0, 300)}`);
|
|
762
893
|
if (!RETRYABLE_STATUS.has(res.status))
|
|
763
894
|
throw err;
|
|
@@ -811,6 +942,8 @@ export async function chatCompletion(endpoint, apiKey, model, history, opts, err
|
|
|
811
942
|
content,
|
|
812
943
|
tool_calls: calls.length > 0 ? calls : undefined,
|
|
813
944
|
};
|
|
945
|
+
if (data?.choices?.[0]?.finish_reason === "length")
|
|
946
|
+
result.truncated = true;
|
|
814
947
|
const usage = parseUsage(data?.usage);
|
|
815
948
|
if (usage !== undefined)
|
|
816
949
|
result.usage = usage;
|
|
@@ -861,16 +994,19 @@ export async function chatCompletion(endpoint, apiKey, model, history, opts, err
|
|
|
861
994
|
}
|
|
862
995
|
// Agentic loop for one user turn: thin wrapper over the shared runLoopWithChat
|
|
863
996
|
// core below (single loop implementation). Send → while the response carries
|
|
864
|
-
// tool_calls (
|
|
865
|
-
// execute each tool locally, append {role:'tool'}
|
|
997
|
+
// tool_calls (uncapped by default; explicit opts.maxSteps still caps), append
|
|
998
|
+
// the assistant message, execute each tool locally, append {role:'tool'}
|
|
999
|
+
// results, resend.
|
|
866
1000
|
// Streaming: each POST streams SSE tokens (onToken gets the growing text,
|
|
867
1001
|
// onPhase reports thinking|streaming|tool|retry|done, onToolDelta fires when
|
|
868
1002
|
// a tool name first appears mid-stream). A model that returns no tool_calls
|
|
869
1003
|
// ends the loop (graceful fallback for models without tool support). Tool
|
|
870
1004
|
// errors are results the model sees — NOTHING is rolled back here; only a
|
|
871
|
-
// POST failure (HTTP/network/empty/
|
|
1005
|
+
// POST failure (HTTP/network/empty/stalled-stream) throws (and the caller rolls
|
|
872
1006
|
// back the user turn, as before; the caller preserves any streamed partial
|
|
873
|
-
// on display).
|
|
1007
|
+
// on display). A length-truncated response (`finish_reason: "length"`) does
|
|
1008
|
+
// not throw: the loop fails each carried tool call inline with a repair
|
|
1009
|
+
// error and continues to the next model round.
|
|
874
1010
|
export async function runAgenticLoop(endpoint, apiKey, model, history, opts) {
|
|
875
1011
|
return runLoopWithChat((h, o) => chatCompletion(endpoint, apiKey, model, h, {
|
|
876
1012
|
onToken: o?.onToken,
|
|
@@ -904,11 +1040,16 @@ export function loadAgentsPrompt(cwd = process.cwd()) {
|
|
|
904
1040
|
}
|
|
905
1041
|
}
|
|
906
1042
|
export function buildSystemPrompt(cwd = process.cwd()) {
|
|
907
|
-
//
|
|
908
|
-
//
|
|
909
|
-
//
|
|
1043
|
+
// Three layers: src/system.ts base one-liner + repo AGENTS.md overlay +
|
|
1044
|
+
// extension prompt hints (ticket 06). The hints ride the existing assembly
|
|
1045
|
+
// — no parallel prompt pipeline: when none are registered the result is
|
|
1046
|
+
// byte-identical to the two-layer form.
|
|
910
1047
|
const extra = loadAgentsPrompt(cwd);
|
|
911
|
-
|
|
1048
|
+
const base = extra ? `${SYSTEM_PROMPT}\n\n${extra}` : SYSTEM_PROMPT;
|
|
1049
|
+
const hints = getExtensionPromptHints();
|
|
1050
|
+
if (hints.length === 0)
|
|
1051
|
+
return base;
|
|
1052
|
+
return `${base}\n\n## Extension hints\n${hints.map((h) => `- ${h}`).join("\n")}`;
|
|
912
1053
|
}
|
|
913
1054
|
function providerHttpError(provider, status, text) {
|
|
914
1055
|
return new Error(`${providerLabel(provider)} HTTP ${status}: ${text.slice(0, 300)}`);
|
|
@@ -916,7 +1057,17 @@ function providerHttpError(provider, status, text) {
|
|
|
916
1057
|
export async function chatCompletionAnthropic(apiKey, model, history, opts) {
|
|
917
1058
|
const sleep = opts?.sleep ?? defaultSleep;
|
|
918
1059
|
const signal = opts?.signal ?? null;
|
|
1060
|
+
// Ticket 08: same per-POST hook contract as the openai-chat path above
|
|
1061
|
+
// (context once per call, pre/post per attempt, all fail-open, zero-cost
|
|
1062
|
+
// when unregistered).
|
|
1063
|
+
const anthropicContextHooks = contextTransformers();
|
|
1064
|
+
const outgoingHistory = anthropicContextHooks.length > 0
|
|
1065
|
+
? await applyContextTransform(anthropicContextHooks, history)
|
|
1066
|
+
: history;
|
|
919
1067
|
let lastError = null;
|
|
1068
|
+
// Same server-authoritative unsupported contract as the openai-chat path:
|
|
1069
|
+
// a 400 naming the thinking knob drops it for the rest of the call.
|
|
1070
|
+
let anthropicEffortDropped = false;
|
|
920
1071
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
921
1072
|
try {
|
|
922
1073
|
throwIfCancelled(signal);
|
|
@@ -927,7 +1078,7 @@ export async function chatCompletionAnthropic(apiKey, model, history, opts) {
|
|
|
927
1078
|
// ignore
|
|
928
1079
|
}
|
|
929
1080
|
const summaryOpts = opts;
|
|
930
|
-
const base = buildAnthropicBody(
|
|
1081
|
+
const base = buildAnthropicBody(outgoingHistory, model, {
|
|
931
1082
|
includeTools: !summaryOpts?.disableTools,
|
|
932
1083
|
});
|
|
933
1084
|
const body = { ...base, stream: true };
|
|
@@ -938,14 +1089,62 @@ export async function chatCompletionAnthropic(apiKey, model, history, opts) {
|
|
|
938
1089
|
summaryOpts.maxOutputTokens > 0) {
|
|
939
1090
|
body["max_tokens"] = Math.floor(summaryOpts.maxOutputTokens);
|
|
940
1091
|
}
|
|
1092
|
+
// /effort maps to the native thinking budget (Auto omits it; a cap too
|
|
1093
|
+
// small for the 1024 minimum omits it too — see anthropicThinkingFor).
|
|
1094
|
+
const anthropicEffort = anthropicEffortDropped
|
|
1095
|
+
? undefined
|
|
1096
|
+
: reasoningEffortParam(opts?.reasoningEffort, model);
|
|
1097
|
+
const anthropicBudget = anthropicEffort !== undefined
|
|
1098
|
+
? anthropicThinkingFor(anthropicEffort, typeof body["max_tokens"] === "number"
|
|
1099
|
+
? body["max_tokens"]
|
|
1100
|
+
: ANTHROPIC_MAX_TOKENS)
|
|
1101
|
+
: undefined;
|
|
1102
|
+
if (anthropicBudget !== undefined) {
|
|
1103
|
+
body["thinking"] = { type: "enabled", budget_tokens: anthropicBudget };
|
|
1104
|
+
}
|
|
1105
|
+
const anthropicPreHooks = beforeRequestInterceptors();
|
|
1106
|
+
const outgoing = anthropicPreHooks.length > 0
|
|
1107
|
+
? await applyBeforeRequest(anthropicPreHooks, {
|
|
1108
|
+
provider: "anthropic",
|
|
1109
|
+
model,
|
|
1110
|
+
url: "https://api.anthropic.com/v1/messages",
|
|
1111
|
+
payload: body,
|
|
1112
|
+
headers: anthropicHeaders(apiKey),
|
|
1113
|
+
})
|
|
1114
|
+
: { payload: body, headers: anthropicHeaders(apiKey) };
|
|
941
1115
|
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
942
1116
|
method: "POST",
|
|
943
|
-
headers:
|
|
944
|
-
body: JSON.stringify(
|
|
1117
|
+
headers: outgoing.headers,
|
|
1118
|
+
body: JSON.stringify(outgoing.payload),
|
|
945
1119
|
...(signal ? { signal } : {}),
|
|
946
1120
|
});
|
|
1121
|
+
const anthropicPostHooks = afterResponseObservers();
|
|
1122
|
+
if (anthropicPostHooks.length > 0) {
|
|
1123
|
+
await notifyAfterResponse(anthropicPostHooks, {
|
|
1124
|
+
provider: "anthropic",
|
|
1125
|
+
model,
|
|
1126
|
+
url: "https://api.anthropic.com/v1/messages",
|
|
1127
|
+
status: res.status,
|
|
1128
|
+
ok: res.ok,
|
|
1129
|
+
headers: snapshotResponseHeaders(res),
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
947
1132
|
if (!res.ok) {
|
|
948
1133
|
const errText = await safeErrorText(res);
|
|
1134
|
+
if (res.status === 400 &&
|
|
1135
|
+
anthropicBudget !== undefined &&
|
|
1136
|
+
!anthropicEffortDropped &&
|
|
1137
|
+
isEffortRejection(errText)) {
|
|
1138
|
+
anthropicEffortDropped = true;
|
|
1139
|
+
try {
|
|
1140
|
+
opts?.onWarning?.(`reasoning effort "${anthropicEffort}" is not supported by ${model} — continuing without it`);
|
|
1141
|
+
}
|
|
1142
|
+
catch {
|
|
1143
|
+
// ignore observer errors
|
|
1144
|
+
}
|
|
1145
|
+
throwIfCancelled(signal);
|
|
1146
|
+
continue;
|
|
1147
|
+
}
|
|
949
1148
|
const err = providerHttpError("anthropic", res.status, errText);
|
|
950
1149
|
if (!RETRYABLE_STATUS.has(res.status))
|
|
951
1150
|
throw err;
|
|
@@ -1005,7 +1204,17 @@ export async function chatCompletionAnthropic(apiKey, model, history, opts) {
|
|
|
1005
1204
|
export async function chatCompletionGemini(apiKey, model, history, opts) {
|
|
1006
1205
|
const sleep = opts?.sleep ?? defaultSleep;
|
|
1007
1206
|
const signal = opts?.signal ?? null;
|
|
1207
|
+
// Ticket 08: same per-POST hook contract as the openai-chat path above
|
|
1208
|
+
// (context once per call, pre/post per attempt, all fail-open, zero-cost
|
|
1209
|
+
// when unregistered).
|
|
1210
|
+
const geminiContextHooks = contextTransformers();
|
|
1211
|
+
const outgoingHistory = geminiContextHooks.length > 0
|
|
1212
|
+
? await applyContextTransform(geminiContextHooks, history)
|
|
1213
|
+
: history;
|
|
1008
1214
|
let lastError = null;
|
|
1215
|
+
// Same server-authoritative unsupported contract as the other paths: a
|
|
1216
|
+
// 400 naming the thinking knob drops it for the rest of the call.
|
|
1217
|
+
let geminiEffortDropped = false;
|
|
1009
1218
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
1010
1219
|
try {
|
|
1011
1220
|
throwIfCancelled(signal);
|
|
@@ -1016,7 +1225,7 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
|
|
|
1016
1225
|
// ignore
|
|
1017
1226
|
}
|
|
1018
1227
|
const summaryOpts = opts;
|
|
1019
|
-
const body = buildGeminiBody(
|
|
1228
|
+
const body = buildGeminiBody(outgoingHistory, model, {
|
|
1020
1229
|
includeTools: !summaryOpts?.disableTools,
|
|
1021
1230
|
...(typeof summaryOpts?.maxOutputTokens === "number" &&
|
|
1022
1231
|
Number.isFinite(summaryOpts.maxOutputTokens) &&
|
|
@@ -1024,14 +1233,65 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
|
|
|
1024
1233
|
? { maxOutputTokens: Math.floor(summaryOpts.maxOutputTokens) }
|
|
1025
1234
|
: {}),
|
|
1026
1235
|
});
|
|
1236
|
+
// /effort maps to the native thinkingLevel (Auto omits it; Max rides
|
|
1237
|
+
// high, the deepest level the API offers). Merged into
|
|
1238
|
+
// generationConfig so a compaction maxOutputTokens cap survives.
|
|
1239
|
+
const geminiEffort = geminiEffortDropped
|
|
1240
|
+
? undefined
|
|
1241
|
+
: reasoningEffortParam(opts?.reasoningEffort, model);
|
|
1242
|
+
const geminiLevel = geminiEffort !== undefined ? geminiThinkingLevelFor(geminiEffort) : undefined;
|
|
1243
|
+
if (geminiLevel !== undefined) {
|
|
1244
|
+
const gc = typeof body.generationConfig === "object" && body.generationConfig !== null
|
|
1245
|
+
? { ...body.generationConfig }
|
|
1246
|
+
: {};
|
|
1247
|
+
body.generationConfig = {
|
|
1248
|
+
...gc,
|
|
1249
|
+
thinkingConfig: { thinkingLevel: geminiLevel },
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
const geminiPreHooks = beforeRequestInterceptors();
|
|
1253
|
+
const outgoing = geminiPreHooks.length > 0
|
|
1254
|
+
? await applyBeforeRequest(geminiPreHooks, {
|
|
1255
|
+
provider: "google-gemini",
|
|
1256
|
+
model,
|
|
1257
|
+
url: geminiChatUrl(model),
|
|
1258
|
+
payload: body,
|
|
1259
|
+
headers: geminiHeaders(apiKey),
|
|
1260
|
+
})
|
|
1261
|
+
: { payload: body, headers: geminiHeaders(apiKey) };
|
|
1027
1262
|
const res = await fetch(geminiChatUrl(model), {
|
|
1028
1263
|
method: "POST",
|
|
1029
|
-
headers:
|
|
1030
|
-
body: JSON.stringify(
|
|
1264
|
+
headers: outgoing.headers,
|
|
1265
|
+
body: JSON.stringify(outgoing.payload),
|
|
1031
1266
|
...(signal ? { signal } : {}),
|
|
1032
1267
|
});
|
|
1268
|
+
const geminiPostHooks = afterResponseObservers();
|
|
1269
|
+
if (geminiPostHooks.length > 0) {
|
|
1270
|
+
await notifyAfterResponse(geminiPostHooks, {
|
|
1271
|
+
provider: "google-gemini",
|
|
1272
|
+
model,
|
|
1273
|
+
url: geminiChatUrl(model),
|
|
1274
|
+
status: res.status,
|
|
1275
|
+
ok: res.ok,
|
|
1276
|
+
headers: snapshotResponseHeaders(res),
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1033
1279
|
if (!res.ok) {
|
|
1034
1280
|
const errText = await safeErrorText(res);
|
|
1281
|
+
if (res.status === 400 &&
|
|
1282
|
+
geminiLevel !== undefined &&
|
|
1283
|
+
!geminiEffortDropped &&
|
|
1284
|
+
isEffortRejection(errText)) {
|
|
1285
|
+
geminiEffortDropped = true;
|
|
1286
|
+
try {
|
|
1287
|
+
opts?.onWarning?.(`reasoning effort "${geminiEffort}" is not supported by ${model} — continuing without it`);
|
|
1288
|
+
}
|
|
1289
|
+
catch {
|
|
1290
|
+
// ignore observer errors
|
|
1291
|
+
}
|
|
1292
|
+
throwIfCancelled(signal);
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1035
1295
|
const err = providerHttpError("google-gemini", res.status, errText);
|
|
1036
1296
|
if (!RETRYABLE_STATUS.has(res.status))
|
|
1037
1297
|
throw err;
|
|
@@ -1063,10 +1323,20 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
|
|
|
1063
1323
|
throwIfCancelled(signal);
|
|
1064
1324
|
const res2 = await fetch(geminiGenerateUrl(model), {
|
|
1065
1325
|
method: "POST",
|
|
1066
|
-
headers:
|
|
1067
|
-
body: JSON.stringify(
|
|
1326
|
+
headers: outgoing.headers,
|
|
1327
|
+
body: JSON.stringify(outgoing.payload),
|
|
1068
1328
|
...(signal ? { signal } : {}),
|
|
1069
1329
|
});
|
|
1330
|
+
if (geminiPostHooks.length > 0) {
|
|
1331
|
+
await notifyAfterResponse(geminiPostHooks, {
|
|
1332
|
+
provider: "google-gemini",
|
|
1333
|
+
model,
|
|
1334
|
+
url: geminiGenerateUrl(model),
|
|
1335
|
+
status: res2.status,
|
|
1336
|
+
ok: res2.ok,
|
|
1337
|
+
headers: snapshotResponseHeaders(res2),
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1070
1340
|
if (!res2.ok) {
|
|
1071
1341
|
const errText2 = await safeErrorText(res2);
|
|
1072
1342
|
throw providerHttpError("google-gemini", res2.status, errText2);
|
|
@@ -1117,9 +1387,11 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
|
|
|
1117
1387
|
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
1118
1388
|
}
|
|
1119
1389
|
// Provider dispatcher: openai-chat reuses chatCompletion with the provider's
|
|
1120
|
-
// error label; anthropic/gemini go through their adapters.
|
|
1121
|
-
//
|
|
1122
|
-
//
|
|
1390
|
+
// error label; anthropic/gemini go through their adapters. Effort rides
|
|
1391
|
+
// every kind: reasoning_effort on openai-chat (all providers, all models),
|
|
1392
|
+
// thinking budgets on anthropic-messages, thinkingLevel on gemini-generate.
|
|
1393
|
+
// Auto omits the knob; a model that truly lacks it 400s and the transports
|
|
1394
|
+
// above retry once without it.
|
|
1123
1395
|
export async function chatCompletionForProvider(provider, apiKey, model, history, opts) {
|
|
1124
1396
|
const def = getProvider(provider);
|
|
1125
1397
|
if (!def)
|
|
@@ -1136,7 +1408,14 @@ export async function chatCompletionForProvider(provider, apiKey, model, history
|
|
|
1136
1408
|
const endpoint = provider === "opencode-zen"
|
|
1137
1409
|
? (opts?.endpointOverride ?? chatEndpointFor(provider, opts?.baseURL))
|
|
1138
1410
|
: chatEndpointFor(provider, opts?.baseURL);
|
|
1139
|
-
|
|
1411
|
+
// Effort passes through for every openai-chat provider (zen, OpenAI,
|
|
1412
|
+
// DeepSeek, Mistral, Kilo, openai-compatible, local runtimes): the shared
|
|
1413
|
+
// transport sends reasoning_effort when non-Auto and falls back without it
|
|
1414
|
+
// on a server rejection. Anthropic/Gemini kinds receive opts directly
|
|
1415
|
+
// above and map effort to their native thinking knobs.
|
|
1416
|
+
const effortOpts = opts?.reasoningEffort !== undefined
|
|
1417
|
+
? { reasoningEffort: opts.reasoningEffort }
|
|
1418
|
+
: {};
|
|
1140
1419
|
const chatOpts = {
|
|
1141
1420
|
onToken: opts?.onToken,
|
|
1142
1421
|
onPhase: opts?.onPhase,
|
|
@@ -1145,6 +1424,9 @@ export async function chatCompletionForProvider(provider, apiKey, model, history
|
|
|
1145
1424
|
onThinking: opts?.onThinking,
|
|
1146
1425
|
sleep: opts?.sleep,
|
|
1147
1426
|
signal: opts?.signal,
|
|
1427
|
+
// Ticket 08: provider-hook attribution for the shared openai-chat
|
|
1428
|
+
// transport (otherwise every kind would report "opencode-zen").
|
|
1429
|
+
providerId: provider,
|
|
1148
1430
|
...effortOpts,
|
|
1149
1431
|
// Compaction path only (undefined for the normal loop → tools sent).
|
|
1150
1432
|
...(opts?.disableTools !== undefined ? { disableTools: opts.disableTools } : {}),
|
|
@@ -1153,9 +1435,9 @@ export async function chatCompletionForProvider(provider, apiKey, model, history
|
|
|
1153
1435
|
: {}),
|
|
1154
1436
|
};
|
|
1155
1437
|
// Kilo rides the shared OpenAI-chat path (streaming, tool reconstruction,
|
|
1156
|
-
// retry) with its registry
|
|
1157
|
-
//
|
|
1158
|
-
//
|
|
1438
|
+
// retry, effort with server-rejection fallback) with its registry
|
|
1439
|
+
// endpoint + label; HTTP failures are reframed into concise actionable
|
|
1440
|
+
// Kilo errors (see src/kilo.ts).
|
|
1159
1441
|
if (provider === "kilo") {
|
|
1160
1442
|
try {
|
|
1161
1443
|
return await chatCompletion(endpoint, apiKey, model, history, chatOpts, providerLabel(provider));
|
|
@@ -1166,16 +1448,17 @@ export async function chatCompletionForProvider(provider, apiKey, model, history
|
|
|
1166
1448
|
}
|
|
1167
1449
|
return chatCompletion(endpoint, apiKey, model, history, chatOpts, providerLabel(provider));
|
|
1168
1450
|
}
|
|
1169
|
-
export { evaluateTurnEnd, isCodePath, MAX_VERIFY_ROUNDS, todoCompletionGate, TURN_END_GATES, verificationGate, } from "./agent/gates.js";
|
|
1451
|
+
export { evaluateTurnEnd, isCodePath, MAX_TODO_ROUNDS, MAX_VERIFY_ROUNDS, todoCompletionGate, TURN_END_GATES, verificationGate, } from "./agent/gates.js";
|
|
1170
1452
|
// Parallel independent tool calls: batch PLANNING lives in src/scheduler.ts
|
|
1171
1453
|
// (effect metadata + conflict rules, no per-tool branches); this module only
|
|
1172
1454
|
// plans via planToolBatches below and executes (serial singletons in program
|
|
1173
|
-
// order,
|
|
1455
|
+
// order, disjoint batches concurrently, results committed in call order).
|
|
1174
1456
|
//
|
|
1175
|
-
// Parallel-safe = batchable reads
|
|
1176
|
-
//
|
|
1177
|
-
//
|
|
1178
|
-
//
|
|
1457
|
+
// Parallel-safe = batchable reads plus disjoint-file writes (see TOOL_EFFECTS
|
|
1458
|
+
// and canonicalFileKey in scheduler.ts). Approvals for batched writes resolve
|
|
1459
|
+
// serially in call order before any member executes. Excluded on purpose:
|
|
1460
|
+
// - bash mutates/spawns with an unbounded footprint (it can touch anything,
|
|
1461
|
+
// so no footprint check could clear it) — always a singleton;
|
|
1179
1462
|
// - ask_question blocks on a UI modal (parallel prompts make no sense);
|
|
1180
1463
|
// - todowrite/todo_update share module-global todo state (read-modify-write
|
|
1181
1464
|
// races); todo_get is pure but sub-millisecond, so batching it buys
|
|
@@ -1190,7 +1473,7 @@ export function planToolBatches(calls) {
|
|
|
1190
1473
|
}
|
|
1191
1474
|
import { runLoopWithChat } from "./agent/loop.js";
|
|
1192
1475
|
export { runLoopWithChat } from "./agent/loop.js";
|
|
1193
|
-
export { DEFAULT_MAX_TOTAL_TOOL_CALLS, DEFAULT_TOOL_TIMEOUT_MS, executeWithTimeout, resolveMaxTotalToolCalls, resolveToolTimeoutMs, } from "./agent/loop.js";
|
|
1476
|
+
export { DEFAULT_MAX_TOTAL_TOOL_CALLS, DEFAULT_TOOL_TIMEOUT_MS, executeWithTimeout, emptyResponseFollowUp, isEmptyReplyError, MAX_EMPTY_ROUNDS, resolveMaxTotalToolCalls, resolveToolTimeoutMs, } from "./agent/loop.js";
|
|
1194
1477
|
export async function runAgenticLoopForProvider(provider, apiKey, model, history, opts) {
|
|
1195
1478
|
return runLoopWithChat((h, o) => chatCompletionForProvider(provider, apiKey, model, h, {
|
|
1196
1479
|
onToken: o?.onToken,
|