atom-agent 0.3.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +83 -32
  3. package/dist/App.js +2178 -318
  4. package/dist/adapters.js +146 -15
  5. package/dist/agent/gates.js +153 -0
  6. package/dist/agent/loop-guard.js +184 -0
  7. package/dist/agent/loop.js +908 -0
  8. package/dist/agent/normalize.js +144 -0
  9. package/dist/agent/types.js +1 -0
  10. package/dist/auth.js +2 -1
  11. package/dist/cli.js +68 -6
  12. package/dist/compact.js +6 -48
  13. package/dist/config.js +171 -0
  14. package/dist/context-manager.js +564 -0
  15. package/dist/kilo.js +343 -0
  16. package/dist/local-discovery.js +308 -0
  17. package/dist/policy.js +286 -0
  18. package/dist/prompt-cache.js +99 -0
  19. package/dist/providers.js +183 -2
  20. package/dist/rollback.js +21 -0
  21. package/dist/scheduler.js +247 -0
  22. package/dist/session.js +35 -3
  23. package/dist/skills.js +214 -43
  24. package/dist/snapshots.js +57 -2
  25. package/dist/system.js +8 -1
  26. package/dist/telemetry-dashboard.js +589 -0
  27. package/dist/telemetry-server.js +301 -0
  28. package/dist/telemetry.js +1056 -0
  29. package/dist/tools/dir-cache.js +207 -0
  30. package/dist/tools/filesystem.js +149 -0
  31. package/dist/tools/fingerprints.js +33 -0
  32. package/dist/tools/overflow.js +76 -0
  33. package/dist/tools/read-cache.js +160 -0
  34. package/dist/tools/registry.js +802 -0
  35. package/dist/tools/search.js +242 -0
  36. package/dist/tools/shared.js +31 -0
  37. package/dist/tools/shell.js +273 -0
  38. package/dist/tools/todo.js +191 -0
  39. package/dist/tools/web.js +454 -0
  40. package/dist/tools.js +17 -1863
  41. package/dist/ui/activity.js +51 -0
  42. package/dist/ui/diff-panel.js +55 -0
  43. package/dist/ui/diff-view.js +112 -0
  44. package/dist/ui/diff.js +422 -0
  45. package/dist/ui/errors.js +129 -0
  46. package/dist/ui/highlight.js +120 -0
  47. package/dist/ui/input-model.js +115 -0
  48. package/dist/ui/input.js +40 -0
  49. package/dist/ui/live-tail.js +15 -0
  50. package/dist/ui/markdown.js +525 -0
  51. package/dist/ui/modals.js +47 -0
  52. package/dist/ui/palette.js +70 -0
  53. package/dist/ui/pickers.js +32 -0
  54. package/dist/ui/side-by-side.js +144 -0
  55. package/dist/ui/status-bar.js +75 -0
  56. package/dist/ui/theme.js +128 -0
  57. package/dist/ui/todo-panel.js +30 -0
  58. package/dist/ui/tool-inspector.js +59 -0
  59. package/dist/ui/transcript.js +128 -0
  60. package/dist/zen.js +145 -666
  61. package/package.json +1 -1
package/dist/zen.js CHANGED
@@ -5,10 +5,13 @@
5
5
  // different Zen request shapes and are out of scope.
6
6
  import { existsSync, readFileSync } from "node:fs";
7
7
  import * as path from "node:path";
8
- import { MAX_TOOL_STEPS, TOOL_DEFINITIONS, 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";
8
+ import { MAX_TOOL_STEPS, TOOL_DEFINITIONS, } from "./tools.js";
9
+ import { chatEndpointFor, getProvider, isLocalProviderId, modelsUrlForProvider, providerLabel, } from "./providers.js";
10
+ import { discoverLocalProvider } from "./local-discovery.js";
11
+ import { ANTHROPIC_VERSION, anthropicHeaders, buildAnthropicBody, buildGeminiBody, geminiChatUrl, geminiGenerateUrl, geminiHeaders, parseAnthropicJson, parseAnthropicModelsList, parseGeminiJson, parseGeminiModelsList, parseOpenAIModelsList, readAnthropicSSEMessage, readGeminiSSEMessage, isStallError, readWithStall, } from "./adapters.js";
12
+ export { isStallError, readWithStall, sseStallTimeoutMs } from "./adapters.js";
13
+ import { KILO_FALLBACK_MODELS, fetchKiloModelsWithStatus, normalizeKiloChatError, } from "./kilo.js";
14
+ import { splitSystemHead } from "./prompt-cache.js";
12
15
  import { SYSTEM_PROMPT } from "./system.js";
13
16
  export { MAX_TOOL_STEPS };
14
17
  // Re-exported so existing `SYSTEM_PROMPT` imports keep working; the
@@ -26,36 +29,18 @@ export const AGENTS_CHAR_CAP = 12 * 1024;
26
29
  // ---- Conversation-history budget (deterministic, no extra model calls) ----
27
30
  // Long sessions can't bloat context, cost, and latency: the shared loop core
28
31
  // trims history to BOTH caps before every POST (uniform across providers).
29
- // Optional env overrides (invalid/unset defaults):
32
+ // The caps themselves live in the ContextManager module (re-exported here so
33
+ // existing importers keep working); precedence per knob stays env override
34
+ // (when valid) → project atom.json → global atom.json → compiled default:
30
35
  // - ATOM_MAX_HISTORY_MESSAGES, clamped to 10–1000 (default 100)
31
36
  // - ATOM_MAX_HISTORY_CHARS, clamped to 10_000–2_000_000 (default 200_000)
32
- export 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
- }
37
+ export { toolStepBudget } from "./agent/loop.js";
38
+ // Reasoning effort (session state in the App, default "default").
39
+ // Wire values are exactly default/low/medium/high/max. "default" never
40
+ // sends a param. NOTE: the user asked for `xhigh`, but the only VERIFIED
41
+ // valid values (OpenCode Zen docs/changelog: Thinking Effort
42
+ // Default/Max/High/Medium/Low, sent as `reasoning_effort`) use `Max`, so
43
+ // the top setting is `Max`, sent on the wire as `max`.
59
44
  export const EFFORT_OPTIONS = [
60
45
  "default",
61
46
  "low",
@@ -89,158 +74,9 @@ export function reasoningEffortParam(effort, model) {
89
74
  }
90
75
  return undefined;
91
76
  }
92
- // 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
- }
77
+ export { CHARS_PER_TOKEN, MAX_HISTORY_CHARS, MAX_HISTORY_MESSAGES, createContextManager, estimateTokensForChars, historyCharBudget, historyChars, historyMessageBudget, messageChars, truncateHistoryWithCaps, } from "./context-manager.js";
78
+ export { openTodoNeedles } from "./agent/gates.js";
79
+ export { truncateHistory } from "./agent/loop.js";
244
80
  function finiteCount(value) {
245
81
  return typeof value === "number" && Number.isFinite(value) && value >= 0
246
82
  ? Math.floor(value)
@@ -262,6 +98,22 @@ export function parseUsage(value) {
262
98
  const total = finiteCount(o["total_tokens"]);
263
99
  if (total !== undefined)
264
100
  out.total_tokens = total;
101
+ // Provider-reported prefix-cache counters (present-only, like everything
102
+ // else here): OpenAI prompt_tokens_details.cached_tokens (+cache_write
103
+ // when sent) and DeepSeek prompt_cache_hit_tokens (official docs shapes).
104
+ const details = o["prompt_tokens_details"];
105
+ if (typeof details === "object" && details !== null) {
106
+ const d = details;
107
+ const cached = finiteCount(d["cached_tokens"]);
108
+ if (cached !== undefined)
109
+ out.cacheReadTokens = cached;
110
+ const written = finiteCount(d["cache_write_tokens"]);
111
+ if (written !== undefined)
112
+ out.cacheWriteTokens = written;
113
+ }
114
+ const hit = finiteCount(o["prompt_cache_hit_tokens"]);
115
+ if (hit !== undefined)
116
+ out.cacheReadTokens = hit;
265
117
  return out.prompt_tokens !== undefined ||
266
118
  out.completion_tokens !== undefined ||
267
119
  out.total_tokens !== undefined
@@ -298,43 +150,24 @@ export function parseReasoningLabel(value) {
298
150
  return "present";
299
151
  return undefined;
300
152
  }
301
- // 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;
153
+ // Live execution phases surfaced to the TUI via onPhase. `detail` carries
154
+ // the tool name for "tool" and a retry summary (attempt/delay/status) for
155
+ // "retry"; it is empty for the other phases.
156
+ import { isCancelError, LoopCancelledError, throwIfCancelled } from "./agent/loop.js";
157
+ export { isCancelError, LoopCancelledError } from "./agent/loop.js";
158
+ // Ten retries (eleven total attempts): provider rate limits (429 with
159
+ // Retry-After) and weak-network throws both ride this policy. Cancellation
160
+ // never retries. Delays grow exponentially under a 30s cap, so a fully dead
161
+ // endpoint costs ~3.5min worst case before the turn fails loudly.
162
+ export const MAX_RETRIES = 10;
330
163
  const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
331
164
  const RETRY_AFTER_CAP_MS = 30_000;
332
165
  export function defaultSleep(ms) {
333
166
  return new Promise((resolve) => setTimeout(resolve, ms));
334
167
  }
335
- // Exponential backoff 1s -> 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).
168
+ // Exponential backoff 1s 2s → 4s …, honoring Retry-After (seconds or
169
+ // HTTP date) capped at 30s. `attempt` is the 0-based index of the failure
170
+ // just seen (0 => first failure => 1s).
338
171
  export function getRetryDelay(attempt, res) {
339
172
  try {
340
173
  const raw = res
@@ -357,7 +190,7 @@ export function getRetryDelay(attempt, res) {
357
190
  catch {
358
191
  // fall through to backoff
359
192
  }
360
- return attempt === 0 ? 1000 : 2000;
193
+ return Math.min(1000 * 2 ** attempt, RETRY_AFTER_CAP_MS);
361
194
  }
362
195
  async function safeErrorText(res) {
363
196
  try {
@@ -504,6 +337,11 @@ export async function fetchModels(endpoint, apiKey) {
504
337
  // - Slots with an id but no name at [DONE] are dropped with an onWarning
505
338
  // message and never returned (keeps assistant/tool pairing valid).
506
339
  // - A stream that ends without [DONE] throws a truncation error.
340
+ // - A stream silent longer than the stall budget (env ATOM_STALL_TIMEOUT_MS,
341
+ // default 60s; the clock resets on every received chunk) throws a
342
+ // Truncated-stream stall error — permanent, never retried, same contract
343
+ // as a dead connection (verified live: free-tier routers can stall a
344
+ // 200-OK stream mid-generation for minutes).
507
345
  // - A stream with zero "data:" lines is treated as a non-SSE JSON payload
508
346
  // (tolerance for bodies that are really single-shot JSON) and parsed as
509
347
  // choices[0].message like the non-streaming fallback.
@@ -681,9 +519,11 @@ export async function readSSEMessage(res, opts) {
681
519
  for (;;) {
682
520
  let chunk;
683
521
  try {
684
- chunk = await reader.read();
522
+ chunk = await readWithStall(() => reader.read());
685
523
  }
686
524
  catch (e) {
525
+ if (isStallError(e))
526
+ throw e;
687
527
  throw new Error(`Truncated stream from model (connection aborted: ${e instanceof Error ? e.message : String(e)}).`);
688
528
  }
689
529
  if (chunk.done)
@@ -718,13 +558,28 @@ export async function readSSEMessage(res, opts) {
718
558
  }
719
559
  }
720
560
  else if (typeof body[Symbol.asyncIterator] === "function") {
721
- 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;
561
+ const it = body[Symbol.asyncIterator]();
562
+ try {
563
+ for (;;) {
564
+ const step = await readWithStall(() => it.next());
565
+ if (step.done)
566
+ break;
567
+ const v = step.value;
568
+ const text = typeof v === "string" ? v : decoder.decode(v, { stream: true });
569
+ rawText += text;
570
+ buffer += text;
571
+ drainBuffer();
572
+ if (sawDone)
573
+ break;
574
+ }
575
+ }
576
+ finally {
577
+ try {
578
+ await it.return?.();
579
+ }
580
+ catch {
581
+ // ignore — the stream is over either way
582
+ }
728
583
  }
729
584
  if (!sawDone && buffer.length > 0) {
730
585
  processLine(buffer);
@@ -838,8 +693,9 @@ export async function readSSEMessage(res, opts) {
838
693
  // the response actually carried them (usage: top-level `usage` on JSON or
839
694
  // SSE final chunks; reasoning: message/delta reasoning metadata).
840
695
  // Throws on HTTP error, empty reply, or a truncated stream.
841
- // - Network throws and HTTP 429/500/502/503/504 are retried up to 2 times
842
- // (3 attempts) with 1s->2s backoff, honoring Retry-After capped at 30s.
696
+ // - Network throws and HTTP 429/500/502/503/504 are retried up to
697
+ // MAX_RETRIES (10) with 1s2s→4s… backoff, honoring Retry-After capped
698
+ // at 30s.
843
699
  // Each retry emits onPhase("retry", detail). Other 4xx fail fast with
844
700
  // the existing `Zen HTTP {status}` message.
845
701
  // - Callers must roll back the user turn on failure (see App submit).
@@ -862,9 +718,16 @@ export async function chatCompletion(endpoint, apiKey, model, history, opts, err
862
718
  }
863
719
  const effortParam = reasoningEffortParam(opts?.reasoningEffort, model);
864
720
  const summaryOpts = opts;
721
+ // Stable-prefix split (prompt-cache architecture): history[0]'s env
722
+ // tail becomes its own system message so the stable head + tools stay
723
+ // byte-identical across POSTs for implicit prefix caching. Consecutive
724
+ // system messages concatenate on every OpenAI-protocol server, so this
725
+ // is content-neutral. No env tail (tests, old saves) → history passes
726
+ // through untouched, byte-identical to before.
727
+ const messages = splitSystemHead(history);
865
728
  const payload = {
866
729
  model,
867
- messages: history,
730
+ messages,
868
731
  stream: true,
869
732
  };
870
733
  // Compaction path only: tools disabled means NO `tools` key at all
@@ -882,9 +745,13 @@ export async function chatCompletion(endpoint, apiKey, model, history, opts, err
882
745
  payload["reasoning_effort"] = effortParam;
883
746
  const res = await fetch(endpoint, {
884
747
  method: "POST",
748
+ // Anonymous-capable providers (Kilo free models) omit Authorization
749
+ // when no key is configured — never an empty `Bearer `. Keyed
750
+ // providers always pass a key (gated by providerNeedsKey), so their
751
+ // behavior is unchanged.
885
752
  headers: {
886
753
  "Content-Type": "application/json",
887
- Authorization: `Bearer ${apiKey}`,
754
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
888
755
  },
889
756
  body: JSON.stringify(payload),
890
757
  ...(signal ? { signal } : {}),
@@ -1002,7 +869,8 @@ export async function chatCompletion(endpoint, apiKey, model, history, opts, err
1002
869
  // ends the loop (graceful fallback for models without tool support). Tool
1003
870
  // errors are results the model sees — NOTHING is rolled back here; only a
1004
871
  // POST failure (HTTP/network/empty/truncated) throws (and the caller rolls
1005
- // back the user turn, as before, including any streaming draft).
872
+ // back the user turn, as before; the caller preserves any streamed partial
873
+ // on display).
1006
874
  export async function runAgenticLoop(endpoint, apiKey, model, history, opts) {
1007
875
  return runLoopWithChat((h, o) => chatCompletion(endpoint, apiKey, model, h, {
1008
876
  onToken: o?.onToken,
@@ -1015,94 +883,6 @@ export async function runAgenticLoop(endpoint, apiKey, model, history, opts) {
1015
883
  signal: o?.signal,
1016
884
  }), history, opts);
1017
885
  }
1018
- // Execute one parsed tool call through validation + permission +
1019
- // ask_question gates. Model mistakes (unknown name, invalid args) return
1020
- // repairs-oriented results WITHOUT executing; cancellations propagate as
1021
- // LoopCancelledError (never a result, never retried). Everything else
1022
- // returns a result string fed back to the model:
1023
- // - ask_question never needs approval; without an askUser hook it resolves
1024
- // to "Error: ask_question has no UI hook".
1025
- // - write/edit/bash consult the approve hook when one is provided; a "no"
1026
- // resolves to "Error: denied by user: <tool>" (final, no retry/rollback).
1027
- // Without a hook every tool executes immediately.
1028
- async function runOneTool(call, parsed, opts, execute) {
1029
- const name = call?.function?.name ?? "(unknown)";
1030
- // Unknown tool: model mistake — list actual names, never execute.
1031
- if (!toolNames().includes(name)) {
1032
- return `Error: unknown tool "${name}". Available: ${toolNames().join(", ")}`;
1033
- }
1034
- // Argument validation BEFORE approval/execution: model mistake, never runs.
1035
- const detail = validateToolArgs(name, parsed);
1036
- if (detail) {
1037
- return invalidCall(detail);
1038
- }
1039
- if (name === "ask_question") {
1040
- throwIfCancelled(opts?.signal);
1041
- // If the signal aborts during the modal, runAskQuestion rejects with
1042
- // LoopCancelledError (no result). If it resolves just as the signal
1043
- // aborts, return the result — the loop records it, then stops before
1044
- // the next POST (no new POSTs, pairing stays valid until rollback).
1045
- return runAskQuestion(parsed, opts?.askUser, opts?.signal);
1046
- }
1047
- if (opts?.approve && needsApproval(name)) {
1048
- let decision;
1049
- try {
1050
- decision = await opts.approve(name, parsed);
1051
- }
1052
- catch (e) {
1053
- // Whole-turn cancellation must propagate (Ctrl+C cancels the turn,
1054
- // not just deny one call). Anything else is a denial.
1055
- if (isCancelError(e) || opts?.signal?.aborted)
1056
- throw new LoopCancelledError();
1057
- decision = "no";
1058
- }
1059
- // Abort that lands as a resolved denial still cancels the whole turn.
1060
- throwIfCancelled(opts?.signal);
1061
- if (decision === "no") {
1062
- return `Error: denied by user: ${name}`;
1063
- }
1064
- // "once" runs this call; "always" runs it too (the caller caches the
1065
- // always-allowed set session-wide so later calls skip the prompt).
1066
- }
1067
- // No new executions after a cancel: stop after the current tool finishes.
1068
- // The current tool (if already running) is awaited to completion and its
1069
- // result IS recorded — the loop then stops before the next tool/POST, so
1070
- // assistant/tool pairing stays valid until the caller rolls back.
1071
- throwIfCancelled(opts?.signal);
1072
- try {
1073
- return await execute(name, parsed);
1074
- }
1075
- catch (e) {
1076
- if (isCancelError(e) || opts?.signal?.aborted)
1077
- throw new LoopCancelledError();
1078
- throw e;
1079
- }
1080
- }
1081
- async function runAskQuestion(parsed, askUser, signal) {
1082
- const invalid = validateAskQuestionArgs(parsed);
1083
- if (invalid)
1084
- return invalid;
1085
- if (!askUser)
1086
- return "Error: ask_question has no UI hook";
1087
- const q = parsed;
1088
- const allowCustom = q.allowCustom === true;
1089
- try {
1090
- const answer = await askUser(q.question, q.options, allowCustom);
1091
- if (typeof answer === "string" && answer.startsWith("Error:"))
1092
- return answer;
1093
- return JSON.stringify({ answer });
1094
- }
1095
- catch (e) {
1096
- // Whole-turn cancellation (Ctrl+C) propagates — it is NOT the Esc
1097
- // question-cancel result below.
1098
- if (isCancelError(e) || signal?.aborted)
1099
- throw new LoopCancelledError();
1100
- const msg = e instanceof Error ? e.message : String(e);
1101
- if (/cancel/i.test(msg))
1102
- return "Error: question cancelled by user";
1103
- return `Error: ${msg}`;
1104
- }
1105
- }
1106
886
  // AGENTS.md loading: <cwd>/AGENTS.md (or $OPENCODE_AGENTS_PATH when set)
1107
887
  // is appended to the system prompt at startup, capped at 12KB.
1108
888
  export function agentsFilePath(cwd = process.cwd()) {
@@ -1357,7 +1137,7 @@ export async function chatCompletionForProvider(provider, apiKey, model, history
1357
1137
  ? (opts?.endpointOverride ?? chatEndpointFor(provider, opts?.baseURL))
1358
1138
  : chatEndpointFor(provider, opts?.baseURL);
1359
1139
  const effortOpts = provider === "opencode-zen" ? { reasoningEffort: opts?.reasoningEffort } : {};
1360
- return chatCompletion(endpoint, apiKey, model, history, {
1140
+ const chatOpts = {
1361
1141
  onToken: opts?.onToken,
1362
1142
  onPhase: opts?.onPhase,
1363
1143
  onToolDelta: opts?.onToolDelta,
@@ -1371,363 +1151,46 @@ export async function chatCompletionForProvider(provider, apiKey, model, history
1371
1151
  ...(opts?.maxOutputTokens !== undefined
1372
1152
  ? { maxOutputTokens: opts.maxOutputTokens }
1373
1153
  : {}),
1374
- }, providerLabel(provider));
1375
- }
1376
- // Todo-completion guard: the turn may not end with final text while todos
1377
- // are open. With budget left, record the attempt and feed back a guard
1378
- // message as a user follow-up so the model must continue with tool calls or
1379
- // explicitly resolve the todos. With the step budget spent, end with an
1380
- // explicit blocked statement naming the unfinished items instead.
1381
- export function todoCompletionGate(finalText, ctx) {
1382
- const open = getTodos().filter((t) => t.status !== "completed");
1383
- if (open.length === 0)
1384
- return { action: "pass" };
1385
- const items = open.map((t, i) => `${i + 1}. [${t.status}] ${t.content}`).join("\n");
1386
- if (ctx.step >= ctx.maxSteps) {
1387
- return {
1388
- action: "end",
1389
- finalText: `${finalText}${finalText ? "\n" : ""}(blocked: ${open.length} open todo(s) — resolve with todo_update/todowrite before ending the turn:\n${items})`,
1390
- };
1391
- }
1392
- return {
1393
- action: "continue",
1394
- assistantText: finalText,
1395
- followUp: `(todo guard: ${open.length} open todo(s) — do not end the turn with final text. Continue with tool calls, or resolve them with todo_update/todowrite:\n${items})`,
1396
1154
  };
1397
- }
1398
- // 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 };
1155
+ // Kilo rides the shared OpenAI-chat path (streaming, tool reconstruction,
1156
+ // retry) with its registry endpoint + label; HTTP failures are reframed
1157
+ // into concise actionable Kilo errors (see src/kilo.ts). reasoning_effort
1158
+ // is never attached (zen-only gating above).
1159
+ if (provider === "kilo") {
1160
+ try {
1161
+ return await chatCompletion(endpoint, apiKey, model, history, chatOpts, providerLabel(provider));
1162
+ }
1163
+ catch (e) {
1164
+ throw normalizeKiloChatError(e, apiKey);
1419
1165
  }
1420
- return { kind: "end", finalText: decision.finalText };
1421
1166
  }
1422
- return { kind: "end", finalText };
1167
+ return chatCompletion(endpoint, apiKey, model, history, chatOpts, providerLabel(provider));
1423
1168
  }
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.
1169
+ export { evaluateTurnEnd, isCodePath, MAX_VERIFY_ROUNDS, todoCompletionGate, TURN_END_GATES, verificationGate, } from "./agent/gates.js";
1170
+ // Parallel independent tool calls: batch PLANNING lives in src/scheduler.ts
1171
+ // (effect metadata + conflict rules, no per-tool branches); this module only
1172
+ // plans via planToolBatches below and executes (serial singletons in program
1173
+ // order, read batches concurrently, results committed in call order).
1437
1174
  //
1438
- // Parallel-safe = 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);
1175
+ // Parallel-safe = batchable reads only (see TOOL_EFFECTS in scheduler.ts).
1176
+ // Excluded on purpose:
1177
+ // - write/edit/bash mutate or spawn with an unbounded footprint (bash can
1178
+ // touch anything, so no footprint check could clear it) — always singletons;
1442
1179
  // - ask_question blocks on a UI modal (parallel prompts make no sense);
1443
1180
  // - todowrite/todo_update share module-global todo state (read-modify-write
1444
1181
  // races); todo_get is pure but sub-millisecond, so batching it buys
1445
- // nothing and it stays serial too (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.
1182
+ // nothing and it stays serial too.
1183
+ import { planBatches } from "./scheduler.js";
1184
+ // Partition one assistant message's tool_calls into commit batches —
1185
+ // effect-aware (see planBatches), preserving program order and the commit
1186
+ // contract the turn-continuation seam defines (one transcript entry per
1187
+ // call, in order). Kept under this name/signature for callers and tests.
1481
1188
  export function planToolBatches(calls) {
1482
- 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
- }
1189
+ return planBatches(calls);
1730
1190
  }
1191
+ import { runLoopWithChat } from "./agent/loop.js";
1192
+ export { runLoopWithChat } from "./agent/loop.js";
1193
+ export { DEFAULT_MAX_TOTAL_TOOL_CALLS, DEFAULT_TOOL_TIMEOUT_MS, executeWithTimeout, resolveMaxTotalToolCalls, resolveToolTimeoutMs, } from "./agent/loop.js";
1731
1194
  export async function runAgenticLoopForProvider(provider, apiKey, model, history, opts) {
1732
1195
  return runLoopWithChat((h, o) => chatCompletionForProvider(provider, apiKey, model, h, {
1733
1196
  onToken: o?.onToken,
@@ -1790,6 +1253,22 @@ export async function fetchModelsForProviderWithStatus(provider, apiKey, baseURL
1790
1253
  return { models: [], ok: false };
1791
1254
  const fallback = [...def.fallbackModels];
1792
1255
  try {
1256
+ // Local runtimes: probe the loopback server (short timeout, never
1257
+ // throws) instead of a keyed /models fetch. Chat itself still flows
1258
+ // through the normal openai-chat path below.
1259
+ if (isLocalProviderId(provider)) {
1260
+ const res = await discoverLocalProvider(provider, { baseURL });
1261
+ return { models: res.models.map((m) => m.id), ok: res.ok };
1262
+ }
1263
+ if (provider === "kilo") {
1264
+ // Dynamic catalog via the Kilo gateway (anonymous when apiKey is "",
1265
+ // authenticated otherwise). TTL-cached inside src/kilo.ts; failures
1266
+ // return the offline placeholder uncached, exactly like other kinds.
1267
+ const res = await fetchKiloModelsWithStatus(apiKey);
1268
+ if (!res.ok)
1269
+ return { models: [...KILO_FALLBACK_MODELS], ok: false };
1270
+ return { models: res.models, ok: true };
1271
+ }
1793
1272
  if (provider === "opencode-zen") {
1794
1273
  // Byte-identical rule: reuse fetchModels (compatibility-filtered).
1795
1274
  const endpoint = zenEndpointOverride ?? chatEndpointFor(provider, baseURL);