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
@@ -0,0 +1,908 @@
1
+ // Shared agentic-loop core: the SINGLE loop implementation backing both
2
+ // runAgenticLoop and runAgenticLoopForProvider (same tool/rollback contract).
3
+ // Moved verbatim from src/zen.ts; zen.ts re-exports runLoopWithChat so
4
+ // existing importers keep working untouched.
5
+ //
6
+ // Sequencing: each assistant message's tool_calls block is partitioned by
7
+ // planBatches — a batch of parallel-safe calls runs concurrently and its
8
+ // results commit in call order (re-paired by index, one transcript entry per
9
+ // call); everything else executes strictly serially in program order. A
10
+ // failure in one call NEVER skips the remaining commits of its block when
11
+ // the results are values; malformed calls yield their error result inline.
12
+ // A thrown execution error (or cancel) aborts the turn exactly as the old
13
+ // serial loop did — the caller rolls the partial turn back, so
14
+ // assistant/tool pairing stays valid.
15
+ //
16
+ // Dependency direction: agent/loop -> {tools, tools/read-cache,
17
+ // scheduler, config, context-manager, agent/gates, agent/loop-guard,
18
+ // agent/normalize, agent/types} and NOT zen (transports stay in
19
+ // zen.ts; runAgenticLoopForProvider wraps this loop from there).
20
+ import { loadAtomConfig } from "../config.js";
21
+ import { createContextManager, historyCharBudget, historyChars, historyMessageBudget, truncateHistoryWithCaps, } from "../context-manager.js";
22
+ import { planBatches } from "../scheduler.js";
23
+ import { describeToolCall, executeTool, invalidCall, MAX_TOOL_STEPS, needsApproval, toolNames, validateAskQuestionArgs, validateToolArgs, } from "../tools.js";
24
+ import { getReadCacheStats } from "../tools/read-cache.js";
25
+ import { bashExitCode, evaluateTurnEnd, isCodePath, isVerificationCommand, openTodoNeedles, } from "./gates.js";
26
+ import { errorStreakFollowUp, ErrorStreakTracker, repetitionFollowUp, RepetitionGuard, repetitionStopNotice, } from "./loop-guard.js";
27
+ import { normalizeChatResult, normalizeToolResult, toolSignature } from "./normalize.js";
28
+ // Whole-turn cancellation: thrown when the user cancels (Ctrl+C) mid-loop.
29
+ // The App catches it, rolls the partial turn back (same splice contract as
30
+ // POST failure), renders one dim `(cancelled)` line, and returns to a clean
31
+ // input state. Never retried, never a tool result.
32
+ export class LoopCancelledError extends Error {
33
+ constructor() {
34
+ super("(cancelled)");
35
+ this.name = "LoopCancelledError";
36
+ }
37
+ }
38
+ export function isCancelError(e) {
39
+ if (e instanceof LoopCancelledError)
40
+ return true;
41
+ if (e instanceof Error && e.name === "LoopCancelledError")
42
+ return true;
43
+ // fetch abort surfaces as DOMException AbortError (or Error with that name
44
+ // in mocks). Treat any AbortError as a cancellation, never a retry.
45
+ if (e instanceof Error && e.name === "AbortError")
46
+ return true;
47
+ if (typeof DOMException !== "undefined" && e instanceof DOMException && e.name === "AbortError") {
48
+ return true;
49
+ }
50
+ return false;
51
+ }
52
+ export function throwIfCancelled(signal) {
53
+ if (signal?.aborted)
54
+ throw new LoopCancelledError();
55
+ }
56
+ // Tool-round budget for one agentic turn (env → atom.json → 30).
57
+ // A real explore → implement → verify task needs 15–30 tool rounds, so the
58
+ // default is 30; an explicit `opts.maxSteps` still wins (tests inject it).
59
+ export function toolStepBudget() {
60
+ const raw = process.env.ATOM_MAX_TOOL_STEPS;
61
+ if (raw !== undefined) {
62
+ const text = raw.trim();
63
+ if (/^\d+$/.test(text)) {
64
+ const n = Number(text);
65
+ if (Number.isFinite(n))
66
+ return Math.min(Math.max(Math.floor(n), 5), 100);
67
+ }
68
+ }
69
+ return loadAtomConfig().config.maxToolSteps ?? MAX_TOOL_STEPS;
70
+ }
71
+ // Legacy trim entry: byte-identical contract (legacy env/config/default caps
72
+ // + live todo pinning, same notice, same in-place splice). New code should
73
+ // use a ContextManager (derived, window-aware caps); the loop core does when
74
+ // it knows the model (see AgenticOpts.context).
75
+ export function truncateHistory(history, notify, reserve) {
76
+ return truncateHistoryWithCaps(history, { maxMessages: historyMessageBudget(), maxChars: historyCharBudget() }, { notify, reserve, todoNeedles: openTodoNeedles() });
77
+ }
78
+ // Per-tool outer timeout (ms): undefined → default 60s (enabled); explicit
79
+ // <=0/NaN → disabled (direct await, zero overhead). Clamped 1s–120s when
80
+ // enabled so a stuck executor can never hang the turn past the bash ceiling.
81
+ export const DEFAULT_TOOL_TIMEOUT_MS = 60_000;
82
+ export function resolveToolTimeoutMs(raw) {
83
+ if (raw === undefined)
84
+ return DEFAULT_TOOL_TIMEOUT_MS;
85
+ if (typeof raw !== "number" || !Number.isFinite(raw))
86
+ return DEFAULT_TOOL_TIMEOUT_MS;
87
+ if (raw <= 0)
88
+ return null;
89
+ return Math.min(Math.max(Math.floor(raw), 1000), 120_000);
90
+ }
91
+ // Total tool-call budget per turn (default 200, min 1). Existing suites peak
92
+ // near 30 calls/turn, so the default only caps parallel-batch explosions.
93
+ export const DEFAULT_MAX_TOTAL_TOOL_CALLS = 200;
94
+ export function resolveMaxTotalToolCalls(raw) {
95
+ if (typeof raw !== "number" || !Number.isFinite(raw))
96
+ return DEFAULT_MAX_TOTAL_TOOL_CALLS;
97
+ return Math.max(1, Math.floor(raw));
98
+ }
99
+ // Race one execution against the outer timeout. Timeout resolves to an
100
+ // `Error:` result (the model adapts); the underlying promise is left to
101
+ // settle — executors own their own cleanup.
102
+ //
103
+ // Cancellation is DELIBERATELY not raced here: the pinned contract is that
104
+ // an in-flight tool runs to completion and its result IS recorded, with the
105
+ // cancel stopping the turn before the next batch/POST (see the
106
+ // throwIfCancelled checks between batches and before each POST). Racing
107
+ // abort against the execution would drop the in-flight result and break
108
+ // assistant/tool pairing guarantees the tests pin. A hung tool + cancel
109
+ // therefore waits for the timeout (≤60s), commits the timeout error, then
110
+ // the next boundary check throws LoopCancelledError.
111
+ export async function executeWithTimeout(execute, name, parsed, timeoutMs, signal) {
112
+ // No new executions after a cancel: refuse to start when already aborted.
113
+ if (signal?.aborted)
114
+ throw new LoopCancelledError();
115
+ if (timeoutMs === null) {
116
+ return execute(name, parsed);
117
+ }
118
+ let timer = null;
119
+ try {
120
+ const execP = execute(name, parsed);
121
+ const timeoutP = new Promise((_resolve, reject) => {
122
+ timer = setTimeout(() => {
123
+ const err = new Error(`timeout after ${timeoutMs}ms`);
124
+ err.code = "ToolTimeout";
125
+ reject(err);
126
+ }, timeoutMs);
127
+ });
128
+ try {
129
+ return await Promise.race([execP, timeoutP]);
130
+ }
131
+ catch (e) {
132
+ if (isCancelError(e))
133
+ throw e;
134
+ if (e?.code === "ToolTimeout" || e?.message?.startsWith("timeout after ")) {
135
+ return `Error: ${name} timed out after ${timeoutMs}ms — retry with a narrower scope or smaller input.`;
136
+ }
137
+ throw e;
138
+ }
139
+ }
140
+ finally {
141
+ if (timer)
142
+ clearTimeout(timer);
143
+ }
144
+ }
145
+ // Execute one parsed tool call through validation + permission +
146
+ // ask_question gates. Model mistakes (unknown name, invalid args) return
147
+ // repairs-oriented results WITHOUT executing; cancellations propagate as
148
+ // LoopCancelledError (never a result, never retried). Everything else
149
+ // returns a result string fed back to the model:
150
+ // - ask_question never needs approval; without an askUser hook it resolves
151
+ // to "Error: ask_question has no UI hook".
152
+ // - write/edit/bash consult the approve hook when one is provided; a "no"
153
+ // resolves to "Error: denied by user: <tool>" (final, no retry/rollback).
154
+ // Without a hook every tool executes immediately.
155
+ async function runOneTool(call, parsed, opts, execute) {
156
+ const name = call?.function?.name ?? "(unknown)";
157
+ // Unknown tool: model mistake — list actual names, never execute.
158
+ if (!toolNames().includes(name)) {
159
+ return `Error: unknown tool "${name}". Available: ${toolNames().join(", ")}`;
160
+ }
161
+ // Argument validation BEFORE approval/execution: model mistake, never runs.
162
+ const detail = validateToolArgs(name, parsed);
163
+ if (detail) {
164
+ return invalidCall(detail);
165
+ }
166
+ if (name === "ask_question") {
167
+ throwIfCancelled(opts?.signal);
168
+ // If the signal aborts during the modal, runAskQuestion rejects with
169
+ // LoopCancelledError (no result). If it resolves just as the signal
170
+ // aborts, return the result — the loop records it, then stops before
171
+ // the next POST (no new POSTs, pairing stays valid until rollback).
172
+ return runAskQuestion(parsed, opts?.askUser, opts?.signal);
173
+ }
174
+ if (opts?.approve && needsApproval(name)) {
175
+ let decision;
176
+ try {
177
+ decision = await opts.approve(name, parsed);
178
+ }
179
+ catch (e) {
180
+ // Whole-turn cancellation must propagate (Ctrl+C cancels the turn,
181
+ // not just deny one call). Anything else is a denial.
182
+ if (isCancelError(e) || opts?.signal?.aborted)
183
+ throw new LoopCancelledError();
184
+ decision = "no";
185
+ }
186
+ // Abort that lands as a resolved denial still cancels the whole turn.
187
+ throwIfCancelled(opts?.signal);
188
+ if (decision === "no") {
189
+ return `Error: denied by user: ${name}`;
190
+ }
191
+ // "once" runs this call; "always" runs it too (the caller caches the
192
+ // always-allowed set session-wide so later calls skip the prompt).
193
+ }
194
+ // No new executions after a cancel: stop after the current tool finishes.
195
+ // The current tool (if already running) is awaited to completion and its
196
+ // result IS recorded — the loop then stops before the next tool/POST, so
197
+ // assistant/tool pairing stays valid until the caller rolls back.
198
+ throwIfCancelled(opts?.signal);
199
+ const timeoutMs = resolveToolTimeoutMs(opts?.toolTimeoutMs);
200
+ const doNormalize = opts?.normalizeResults !== false;
201
+ try {
202
+ const raw = await executeWithTimeout(execute, name, parsed, timeoutMs, opts?.signal);
203
+ if (doNormalize)
204
+ return normalizeToolResult(raw);
205
+ return typeof raw === "string" ? raw : normalizeToolResult(raw);
206
+ }
207
+ catch (e) {
208
+ if (isCancelError(e) || opts?.signal?.aborted)
209
+ throw new LoopCancelledError();
210
+ throw e;
211
+ }
212
+ }
213
+ async function runAskQuestion(parsed, askUser, signal) {
214
+ const invalid = validateAskQuestionArgs(parsed);
215
+ if (invalid)
216
+ return invalid;
217
+ if (!askUser)
218
+ return "Error: ask_question has no UI hook";
219
+ const q = parsed;
220
+ const allowCustom = q.allowCustom === true;
221
+ try {
222
+ const answer = await askUser(q.question, q.options, allowCustom);
223
+ if (typeof answer === "string" && answer.startsWith("Error:"))
224
+ return answer;
225
+ return JSON.stringify({ answer });
226
+ }
227
+ catch (e) {
228
+ // Whole-turn cancellation (Ctrl+C) propagates — it is NOT the Esc
229
+ // question-cancel result below.
230
+ if (isCancelError(e) || signal?.aborted)
231
+ throw new LoopCancelledError();
232
+ const msg = e instanceof Error ? e.message : String(e);
233
+ if (/cancel/i.test(msg))
234
+ return "Error: question cancelled by user";
235
+ return `Error: ${msg}`;
236
+ }
237
+ }
238
+ // Shared agentic-loop core: the SINGLE loop implementation backing both
239
+ // runAgenticLoop and runAgenticLoopForProvider (same tool/rollback contract).
240
+ // Sequencing: each assistant message's tool_calls block is partitioned by
241
+ // planBatches — a batch of parallel-safe calls runs concurrently and its
242
+ // results commit in call order (re-paired by index, one transcript entry per
243
+ // call); everything else executes strictly serially in program order. A
244
+ // failure in one call NEVER skips the remaining commits of its block when
245
+ // the results are values (each result pairs with its tool_call_id in
246
+ // order); malformed calls (bad JSON, unknown name, failed validation) yield
247
+ // their error result inline and the block continues. Validation/unknown/
248
+ // denial/cancel are never retried — only transient transport failures retry
249
+ // (inside chatCompletion). A thrown execution error (or cancel) aborts the
250
+ // turn exactly as the old serial loop did — the caller rolls the partial
251
+ // turn back, so assistant/tool pairing stays valid.
252
+ export async function runLoopWithChat(chatFn, history, opts) {
253
+ const execute = opts?.execute ?? executeTool;
254
+ const maxSteps = opts?.maxSteps ?? toolStepBudget();
255
+ const signal = opts?.signal ?? null;
256
+ // Local observability sink (see src/telemetry.ts): optional, observer-only.
257
+ // Every hook call below is guarded, so telemetry can never break the turn;
258
+ // absent → a few Date.now() reads per call, negligible and identical.
259
+ const telemetry = opts?.telemetry;
260
+ const telemetryIso = (ms) => {
261
+ try {
262
+ return new Date(ms).toISOString();
263
+ }
264
+ catch {
265
+ return new Date().toISOString();
266
+ }
267
+ };
268
+ const telemetryArgsJson = (value) => {
269
+ try {
270
+ const s = JSON.stringify(value ?? {});
271
+ return typeof s === "string" ? s : "{}";
272
+ }
273
+ catch {
274
+ return "{}";
275
+ }
276
+ };
277
+ const reportModelCall = (info) => {
278
+ try {
279
+ telemetry?.onModelCall?.(info);
280
+ }
281
+ catch {
282
+ // observer errors never break the loop
283
+ }
284
+ };
285
+ const reportToolCall = (info) => {
286
+ try {
287
+ telemetry?.onToolCall?.(info);
288
+ }
289
+ catch {
290
+ // observer errors never break the loop
291
+ }
292
+ };
293
+ // Explicit verification state (no transcript parsing — the gate reads
294
+ // these, never model prose):
295
+ // - filesWritten: any write/edit executed (legacy compat signal).
296
+ // - needsVerification: a CODE-path write/edit is still awaiting a passing
297
+ // check (docs/configs never arm it — no false positives).
298
+ // - unverifiedPaths: which code paths (insertion order, unique) for messages.
299
+ // - verifiedAfterWrite: an exit-0 verification command ran (clears the rest).
300
+ // Only evidence AFTER the last write counts, so each new write resets.
301
+ let filesWritten = false;
302
+ let verifiedAfterWrite = false;
303
+ let needsVerification = false;
304
+ let unverifiedPaths = [];
305
+ // Verification-gate nag cycles spent (bounds the continue loop alongside
306
+ // the step budget — a model that never verifies still terminates).
307
+ let verifyRounds = 0;
308
+ // At most one truncation notice per turn; silence when nothing dropped.
309
+ let truncationNoticed = false;
310
+ // Window-aware trimmer when the caller knows the model (App passes it);
311
+ // created once per turn — ceiling sources resolve once, history is
312
+ // re-measured every step. Absent → the legacy fixed caps below.
313
+ const contextManager = opts?.context
314
+ ? createContextManager({ model: opts.context.model, toolsChars: opts.context.toolsChars })
315
+ : null;
316
+ // ---- Hardened-loop state (additive; defaults preserve the pinned
317
+ // maxSteps contract — see AgenticOpts docs) ----
318
+ const maxTotalToolCalls = resolveMaxTotalToolCalls(opts?.maxTotalToolCalls);
319
+ const repGuard = new RepetitionGuard({ maxRepeatedCalls: opts?.maxRepeatedCalls });
320
+ const errStreak = new ErrorStreakTracker(opts?.maxConsecutiveErrors);
321
+ const turnStartMs = Date.now();
322
+ let startChars = 0;
323
+ try {
324
+ startChars = historyChars(history);
325
+ }
326
+ catch {
327
+ startChars = 0;
328
+ }
329
+ let cacheHitsStart = 0;
330
+ try {
331
+ cacheHitsStart = getReadCacheStats().hits;
332
+ }
333
+ catch {
334
+ cacheHitsStart = 0;
335
+ }
336
+ let modelCalls = 0;
337
+ let toolCalls = 0;
338
+ let failures = 0;
339
+ let droppedTurnsTotal = 0;
340
+ let bottleneck = null;
341
+ const noteBottleneck = (name, durationMs) => {
342
+ if (!Number.isFinite(durationMs) || durationMs < 0)
343
+ return;
344
+ if (!bottleneck || durationMs > bottleneck.durationMs) {
345
+ bottleneck = { name, durationMs: Math.floor(durationMs) };
346
+ }
347
+ };
348
+ const finishStats = () => {
349
+ try {
350
+ let endChars = startChars;
351
+ try {
352
+ endChars = historyChars(history);
353
+ }
354
+ catch {
355
+ endChars = startChars;
356
+ }
357
+ let cacheHits = 0;
358
+ try {
359
+ cacheHits = Math.max(0, getReadCacheStats().hits - cacheHitsStart);
360
+ }
361
+ catch {
362
+ cacheHits = 0;
363
+ }
364
+ const stats = {
365
+ steps: modelCalls,
366
+ modelCalls,
367
+ toolCalls,
368
+ failures,
369
+ repetitionHits: repGuard.hitCount,
370
+ cacheHits,
371
+ truncationNotices: droppedTurnsTotal,
372
+ durationMs: Math.max(0, Date.now() - turnStartMs),
373
+ bottleneck,
374
+ contextGrowthChars: endChars - startChars,
375
+ };
376
+ opts?.onLoopStats?.(stats);
377
+ }
378
+ catch {
379
+ // observer errors never break the turn
380
+ }
381
+ };
382
+ try {
383
+ for (let step = 0;; step++) {
384
+ throwIfCancelled(signal);
385
+ // Steering seam: drain one pending steer message (if any) at this safe
386
+ // point — previous tool batches are fully committed, so assistant/tool
387
+ // pairing can never split. Runs before the budget trim so truncation
388
+ // accounts for the injected message. No-op without the hook.
389
+ try {
390
+ opts?.drainSteer?.();
391
+ }
392
+ catch {
393
+ // observer errors never break the loop
394
+ }
395
+ // History budget (uniform for all providers — every POST flows through
396
+ // here): trim oldest user-turns first before each send.
397
+ const trimmed = contextManager
398
+ ? contextManager.trimForSend(history, truncationNoticed
399
+ ? undefined
400
+ : (notice) => {
401
+ try {
402
+ opts?.onWarning?.(notice);
403
+ }
404
+ catch {
405
+ // ignore observer errors
406
+ }
407
+ }, undefined, openTodoNeedles())
408
+ : truncateHistory(history, truncationNoticed
409
+ ? undefined
410
+ : (notice) => {
411
+ try {
412
+ opts?.onWarning?.(notice);
413
+ }
414
+ catch {
415
+ // ignore observer errors
416
+ }
417
+ });
418
+ if (trimmed.droppedTurns > 0) {
419
+ truncationNoticed = true;
420
+ droppedTurnsTotal += trimmed.droppedTurns;
421
+ }
422
+ let msg;
423
+ const modelStart = Date.now();
424
+ try {
425
+ msg = await chatFn(history, {
426
+ onToken: opts?.onToken,
427
+ onPhase: opts?.onPhase,
428
+ onToolDelta: opts?.onToolDelta,
429
+ onWarning: opts?.onWarning,
430
+ onThinking: opts?.onThinking,
431
+ sleep: opts?.sleep,
432
+ reasoningEffort: opts?.reasoningEffort,
433
+ signal,
434
+ });
435
+ }
436
+ catch (e) {
437
+ // A failed POST still records its model call (with the error) so the
438
+ // trace shows what was attempted — the caller still rolls back.
439
+ const modelEnd = Date.now();
440
+ reportModelCall({
441
+ step,
442
+ startedAt: telemetryIso(modelStart),
443
+ endedAt: telemetryIso(modelEnd),
444
+ durationMs: Math.max(0, modelEnd - modelStart),
445
+ usageReported: false,
446
+ toolCallCount: 0,
447
+ finishReason: "error",
448
+ error: e instanceof Error ? e.message : String(e),
449
+ });
450
+ if (isCancelError(e) || signal?.aborted)
451
+ throw new LoopCancelledError();
452
+ throw e;
453
+ }
454
+ throwIfCancelled(signal);
455
+ // Defensive normalization (malformed custom chatFn responses never crash
456
+ // the commit path): dropped calls surface via onWarning, pairing stays
457
+ // valid because only validated calls reach the batch planner.
458
+ try {
459
+ const norm = normalizeChatResult(msg);
460
+ if (norm.warnings.length > 0) {
461
+ for (const w of norm.warnings) {
462
+ try {
463
+ opts?.onWarning?.(w);
464
+ }
465
+ catch {
466
+ // ignore observer errors
467
+ }
468
+ }
469
+ }
470
+ msg = norm.result;
471
+ }
472
+ catch {
473
+ // normalization never breaks the turn; the raw message stands
474
+ }
475
+ modelCalls += 1;
476
+ if (msg.usage !== undefined) {
477
+ // Spend accounting: EVERY POST that reports usage forwards it, and the
478
+ // caller accumulates each report as billed spend — tool-round POSTs,
479
+ // summary POSTs, and successful retries each count once. Attempts that
480
+ // fail (HTTP/network/truncation) report no usage, so there is nothing
481
+ // to dedupe: each attempt that reached the provider and reported counts
482
+ // exactly once. Usage is never synthesized or estimated here.
483
+ try {
484
+ opts?.onUsage?.(msg.usage);
485
+ }
486
+ catch {
487
+ // ignore
488
+ }
489
+ }
490
+ if (msg.reasoning !== undefined) {
491
+ try {
492
+ opts?.onReasoning?.(msg.reasoning);
493
+ }
494
+ catch {
495
+ // ignore
496
+ }
497
+ }
498
+ {
499
+ // Completed model call: usage is forwarded only when the response
500
+ // actually carried it (usageReported) — never synthesized here.
501
+ const modelEnd = Date.now();
502
+ const callsCount = (msg.tool_calls ?? []).length;
503
+ reportModelCall({
504
+ step,
505
+ startedAt: telemetryIso(modelStart),
506
+ endedAt: telemetryIso(modelEnd),
507
+ durationMs: Math.max(0, modelEnd - modelStart),
508
+ usage: msg.usage,
509
+ usageReported: msg.usage !== undefined,
510
+ reasoningLabel: msg.reasoning,
511
+ toolCallCount: callsCount,
512
+ finishReason: callsCount === 0 ? "final" : "tool_calls",
513
+ });
514
+ }
515
+ const calls = msg.tool_calls ?? [];
516
+ if (calls.length === 0) {
517
+ // Turn-continuation seam (ticket 03): the todo guard and verification
518
+ // gate run as entries in TURN_END_GATES — one chain, one commit point.
519
+ // Behavior is byte-identical to the two inline blocks this replaced.
520
+ const outcome = evaluateTurnEnd(msg.content ?? "", {
521
+ step,
522
+ maxSteps,
523
+ filesWritten,
524
+ verifiedAfterWrite,
525
+ needsVerification,
526
+ unverifiedPaths: [...unverifiedPaths],
527
+ verifyRounds,
528
+ });
529
+ if (outcome.kind === "continue") {
530
+ // Verification-gate continues are bounded per turn (alongside the
531
+ // step budget) so a model that never verifies still terminates.
532
+ if (outcome.via === "verification")
533
+ verifyRounds += 1;
534
+ history.push({ role: "assistant", content: outcome.assistantText });
535
+ history.push({ role: "user", content: outcome.followUp });
536
+ continue;
537
+ }
538
+ // Error-streak recovery (additive, after the pinned gates): ending on
539
+ // sustained unaddressed `Error:` results is almost always premature.
540
+ // Single errors still end normally (the model may be reporting a
541
+ // blocker); a streak holds final text for one fix-forward attempt,
542
+ // bounded to 2 holds per turn.
543
+ if (errStreak.shouldHoldFinal(2)) {
544
+ const streak = errStreak.current;
545
+ history.push({ role: "assistant", content: outcome.finalText });
546
+ history.push({ role: "user", content: errorStreakFollowUp(streak) });
547
+ continue;
548
+ }
549
+ history.push({ role: "assistant", content: outcome.finalText });
550
+ try {
551
+ opts?.onPhase?.("done");
552
+ }
553
+ catch {
554
+ // ignore
555
+ }
556
+ return outcome.finalText;
557
+ }
558
+ if (step >= maxSteps) {
559
+ const base = msg.content ?? "";
560
+ const notice = `${base}${base ? "\n" : ""}(stopped: too many tool steps) (limit is ${maxSteps}; raise with ATOM_MAX_TOOL_STEPS=<n>)`;
561
+ history.push({ role: "assistant", content: notice });
562
+ try {
563
+ opts?.onPhase?.("done");
564
+ }
565
+ catch {
566
+ // ignore
567
+ }
568
+ return notice;
569
+ }
570
+ // Total tool-call budget (parallel-batch explosion guard): counts every
571
+ // tool_call the model emits, mirroring the maxSteps stop contract. The
572
+ // default (200) never binds the pinned suites (~30 calls/turn).
573
+ if (toolCalls + calls.length > maxTotalToolCalls) {
574
+ const base = msg.content ?? "";
575
+ const notice = `${base}${base ? "\n" : ""}(stopped: too many tool calls) (limit is ${maxTotalToolCalls} per turn)`;
576
+ history.push({ role: "assistant", content: notice });
577
+ try {
578
+ opts?.onPhase?.("done");
579
+ }
580
+ catch {
581
+ // ignore
582
+ }
583
+ return notice;
584
+ }
585
+ history.push({ role: "assistant", content: msg.content ?? null, tool_calls: calls });
586
+ // Commit helper shared by the serial and parallel paths: Task 7
587
+ // bookkeeping + one ordered transcript entry per call. Only successful
588
+ // executions count — denials, validation errors, and unknown tools (all
589
+ // `Error:` results) never ran, so they neither arm nor clear the gate.
590
+ const commitToolResult = (name, parsed, call, result, durationMs) => {
591
+ const isError = typeof result === "string" && result.startsWith("Error");
592
+ toolCalls += 1;
593
+ if (isError)
594
+ failures += 1;
595
+ errStreak.noteResult(isError);
596
+ if (typeof durationMs === "number")
597
+ noteBottleneck(name, durationMs);
598
+ if (!isError && (name === "write" || name === "edit")) {
599
+ filesWritten = true;
600
+ verifiedAfterWrite = false;
601
+ const p = typeof parsed["path"] === "string" ? parsed["path"] : "";
602
+ if (isCodePath(p)) {
603
+ needsVerification = true;
604
+ if (p.length > 0 && !unverifiedPaths.includes(p))
605
+ unverifiedPaths.push(p);
606
+ }
607
+ }
608
+ else if (!isError && name === "bash") {
609
+ const command = parsed["command"];
610
+ if (typeof command === "string" && isVerificationCommand(command) && filesWritten) {
611
+ const exit = bashExitCode(result);
612
+ if (exit === null || exit === 0) {
613
+ // Passing check (or a legacy runner that reports no envelope):
614
+ // clears everything the gate tracks.
615
+ verifiedAfterWrite = true;
616
+ needsVerification = false;
617
+ unverifiedPaths = [];
618
+ }
619
+ else {
620
+ // A FAILED check is evidence of failure, not of verification:
621
+ // the gate stays armed so the model fixes forward instead of
622
+ // finishing on red output.
623
+ verifiedAfterWrite = false;
624
+ }
625
+ }
626
+ }
627
+ history.push({ role: "tool", tool_call_id: call?.id ?? "", content: result });
628
+ try {
629
+ opts?.onToolActivity?.(describeToolCall(name, parsed), result, isError);
630
+ }
631
+ catch {
632
+ // ignore observer errors
633
+ }
634
+ };
635
+ for (const batch of planBatches(calls)) {
636
+ // No new executions after a cancel: the current tool (if any) already
637
+ // finished; stop before starting the next batch.
638
+ throwIfCancelled(signal);
639
+ if (batch.length === 1) {
640
+ // Serial path: byte-identical to the pre-05 loop body.
641
+ const call = batch[0].call;
642
+ const name = call?.function?.name ?? "(unknown)";
643
+ try {
644
+ opts?.onPhase?.("tool", name);
645
+ }
646
+ catch {
647
+ // ignore
648
+ }
649
+ const toolStart = Date.now();
650
+ let parsed;
651
+ try {
652
+ const raw = call?.function?.arguments ?? "{}";
653
+ const v = JSON.parse(typeof raw === "string" ? raw : "{}");
654
+ parsed = typeof v === "object" && v !== null ? v : {};
655
+ }
656
+ catch {
657
+ parsed = {};
658
+ const result = `Error: invalid call: invalid JSON arguments for tool "${name}" (arguments must be valid JSON). Fix the arguments and retry.`;
659
+ toolCalls += 1;
660
+ failures += 1;
661
+ errStreak.noteResult(true);
662
+ repGuard.note(toolSignature(name, parsed), name);
663
+ noteBottleneck(name, Date.now() - toolStart);
664
+ history.push({ role: "tool", tool_call_id: call?.id ?? "", content: result });
665
+ try {
666
+ opts?.onToolActivity?.(describeToolCall(name, {}), result, true);
667
+ }
668
+ catch {
669
+ // ignore observer errors
670
+ }
671
+ const toolEnd = Date.now();
672
+ reportToolCall({
673
+ step,
674
+ toolCallId: call?.id ?? "",
675
+ name,
676
+ startedAt: telemetryIso(toolStart),
677
+ endedAt: telemetryIso(toolEnd),
678
+ durationMs: Math.max(0, toolEnd - toolStart),
679
+ argsJson: telemetryArgsJson(call?.function?.arguments ?? "{}"),
680
+ result,
681
+ batchIndex: 0,
682
+ batchSize: 1,
683
+ });
684
+ continue;
685
+ }
686
+ let result;
687
+ // Repetition guard (opt-in via maxRepeatedCalls; unset = track-only
688
+ // so the pinned maxSteps contract holds): a repeated signature skips
689
+ // execution and yields a guidance error; exhausted nudges stop hard.
690
+ const repSig = toolSignature(name, parsed);
691
+ const repNote = repGuard.note(repSig, name);
692
+ if (repNote.intervened) {
693
+ const toolEndRep = Date.now();
694
+ if (repGuard.consumeNudge()) {
695
+ const guarded = `Error: invalid call: ${repetitionFollowUp(repSig, repNote.consecutive)} Fix the approach and retry.`;
696
+ reportToolCall({
697
+ step,
698
+ toolCallId: call?.id ?? "",
699
+ name,
700
+ startedAt: telemetryIso(toolStart),
701
+ endedAt: telemetryIso(toolEndRep),
702
+ durationMs: 0,
703
+ argsJson: telemetryArgsJson(parsed),
704
+ result: guarded,
705
+ batchIndex: 0,
706
+ batchSize: 1,
707
+ });
708
+ commitToolResult(name, parsed, call, guarded, 0);
709
+ continue;
710
+ }
711
+ const guarded = `Error: invalid call: ${repetitionFollowUp(repSig, repNote.consecutive)} Fix the approach and retry.`;
712
+ reportToolCall({
713
+ step,
714
+ toolCallId: call?.id ?? "",
715
+ name,
716
+ startedAt: telemetryIso(toolStart),
717
+ endedAt: telemetryIso(toolEndRep),
718
+ durationMs: 0,
719
+ argsJson: telemetryArgsJson(parsed),
720
+ result: guarded,
721
+ batchIndex: 0,
722
+ batchSize: 1,
723
+ });
724
+ commitToolResult(name, parsed, call, guarded, 0);
725
+ const stopBase = msg.content ?? "";
726
+ const stopNotice = `${stopBase}${stopBase ? "\n" : ""}${repetitionStopNotice(repSig, repNote.consecutive)}`;
727
+ history.push({ role: "assistant", content: stopNotice });
728
+ try {
729
+ opts?.onPhase?.("done");
730
+ }
731
+ catch {
732
+ // ignore
733
+ }
734
+ return stopNotice;
735
+ }
736
+ try {
737
+ result = await runOneTool(call, parsed, opts, execute);
738
+ }
739
+ catch (e) {
740
+ // A cancelled/throwing tool still records its attempt (with the
741
+ // cause) so the trace shows what was in flight — then the turn
742
+ // aborts exactly as before.
743
+ const toolEnd = Date.now();
744
+ const cancelled = isCancelError(e) || signal?.aborted;
745
+ if (!cancelled) {
746
+ failures += 1;
747
+ noteBottleneck(name, Math.max(0, toolEnd - toolStart));
748
+ }
749
+ reportToolCall({
750
+ step,
751
+ toolCallId: call?.id ?? "",
752
+ name,
753
+ startedAt: telemetryIso(toolStart),
754
+ endedAt: telemetryIso(toolEnd),
755
+ durationMs: Math.max(0, toolEnd - toolStart),
756
+ argsJson: telemetryArgsJson(parsed),
757
+ result: e instanceof Error ? e.message : String(e),
758
+ cancelled: cancelled ? true : undefined,
759
+ threw: cancelled ? undefined : true,
760
+ batchIndex: 0,
761
+ batchSize: 1,
762
+ });
763
+ if (cancelled)
764
+ throw new LoopCancelledError();
765
+ throw e;
766
+ }
767
+ {
768
+ const toolEnd = Date.now();
769
+ reportToolCall({
770
+ step,
771
+ toolCallId: call?.id ?? "",
772
+ name,
773
+ startedAt: telemetryIso(toolStart),
774
+ endedAt: telemetryIso(toolEnd),
775
+ durationMs: Math.max(0, toolEnd - toolStart),
776
+ argsJson: telemetryArgsJson(parsed),
777
+ result,
778
+ batchIndex: 0,
779
+ batchSize: 1,
780
+ });
781
+ }
782
+ commitToolResult(name, parsed, call, result, Math.max(0, Date.now() - toolStart));
783
+ continue;
784
+ }
785
+ // Parallel batch: every member is pre-validated parallel-safe (see
786
+ // planToolBatches), so runOneTool neither prompts nor blocks here.
787
+ // Phases fire upfront in call order; results commit in call order, so
788
+ // each call still shows separately and tool_call_ids re-pair by index.
789
+ // A throw (cancel or execution error) aborts the turn exactly like the
790
+ // serial path — the caller rolls the partial turn back.
791
+ for (const member of batch) {
792
+ try {
793
+ opts?.onPhase?.("tool", member.call?.function?.name ?? "(unknown)");
794
+ }
795
+ catch {
796
+ // ignore
797
+ }
798
+ }
799
+ let results;
800
+ const memberDurations = new Array(batch.length).fill(0);
801
+ // Repetition pre-notes (synchronous, in call order — deterministic):
802
+ // intervened members skip execution with a guidance error; exhausted
803
+ // nudges arm a hard stop after this batch commits (pairing stays valid).
804
+ const repNotes = batch.map((member) => repGuard.note(toolSignature(member.call?.function?.name ?? "(unknown)", member.parsed), member.call?.function?.name ?? "(unknown)"));
805
+ let repHardStop = null;
806
+ for (let i = 0; i < batch.length; i++) {
807
+ const note = repNotes[i];
808
+ if (note.intervened && !repGuard.consumeNudge() && !repHardStop) {
809
+ repHardStop = { sig: note.signature, consecutive: note.consecutive };
810
+ }
811
+ }
812
+ try {
813
+ // Each member is timed individually (concurrent wall-clock per call,
814
+ // not the whole batch attributed to each) and reported in call order
815
+ // below. A throw still aborts the turn exactly like the serial path.
816
+ results = await Promise.all(batch.map(async (member, index) => {
817
+ const memberStart = Date.now();
818
+ const note = repNotes[index];
819
+ const memberName = member.call?.function?.name ?? "(unknown)";
820
+ if (note.intervened) {
821
+ const guarded = `Error: invalid call: ${repetitionFollowUp(note.signature, note.consecutive)} Fix the approach and retry.`;
822
+ const memberEnd = Date.now();
823
+ memberDurations[index] = 0;
824
+ reportToolCall({
825
+ step,
826
+ toolCallId: member.call?.id ?? "",
827
+ name: memberName,
828
+ startedAt: telemetryIso(memberStart),
829
+ endedAt: telemetryIso(memberEnd),
830
+ durationMs: 0,
831
+ argsJson: telemetryArgsJson(member.parsed),
832
+ result: guarded,
833
+ batchIndex: index,
834
+ batchSize: batch.length,
835
+ });
836
+ return guarded;
837
+ }
838
+ try {
839
+ const r = await runOneTool(member.call, member.parsed, opts, execute);
840
+ const memberEnd = Date.now();
841
+ memberDurations[index] = Math.max(0, memberEnd - memberStart);
842
+ reportToolCall({
843
+ step,
844
+ toolCallId: member.call?.id ?? "",
845
+ name: member.call?.function?.name ?? "(unknown)",
846
+ startedAt: telemetryIso(memberStart),
847
+ endedAt: telemetryIso(memberEnd),
848
+ durationMs: Math.max(0, memberEnd - memberStart),
849
+ argsJson: telemetryArgsJson(member.parsed),
850
+ result: r,
851
+ batchIndex: index,
852
+ batchSize: batch.length,
853
+ });
854
+ return r;
855
+ }
856
+ catch (e) {
857
+ const memberEnd = Date.now();
858
+ const cancelled = isCancelError(e) || signal?.aborted;
859
+ if (!cancelled) {
860
+ failures += 1;
861
+ noteBottleneck(member.call?.function?.name ?? "(unknown)", Math.max(0, memberEnd - memberStart));
862
+ }
863
+ reportToolCall({
864
+ step,
865
+ toolCallId: member.call?.id ?? "",
866
+ name: member.call?.function?.name ?? "(unknown)",
867
+ startedAt: telemetryIso(memberStart),
868
+ endedAt: telemetryIso(memberEnd),
869
+ durationMs: Math.max(0, memberEnd - memberStart),
870
+ argsJson: telemetryArgsJson(member.parsed),
871
+ result: e instanceof Error ? e.message : String(e),
872
+ cancelled: cancelled ? true : undefined,
873
+ threw: cancelled ? undefined : true,
874
+ batchIndex: index,
875
+ batchSize: batch.length,
876
+ });
877
+ throw e;
878
+ }
879
+ }));
880
+ }
881
+ catch (e) {
882
+ if (isCancelError(e) || signal?.aborted)
883
+ throw new LoopCancelledError();
884
+ throw e;
885
+ }
886
+ for (let i = 0; i < batch.length; i++) {
887
+ const member = batch[i];
888
+ commitToolResult(member.call?.function?.name ?? "(unknown)", member.parsed, member.call, results[i], memberDurations[i]);
889
+ }
890
+ if (repHardStop) {
891
+ const stopBase = msg.content ?? "";
892
+ const stopNotice = `${stopBase}${stopBase ? "\n" : ""}${repetitionStopNotice(repHardStop.sig, repHardStop.consecutive)}`;
893
+ history.push({ role: "assistant", content: stopNotice });
894
+ try {
895
+ opts?.onPhase?.("done");
896
+ }
897
+ catch {
898
+ // ignore
899
+ }
900
+ return stopNotice;
901
+ }
902
+ }
903
+ }
904
+ }
905
+ finally {
906
+ finishStats();
907
+ }
908
+ }