atom-agent 1.0.0 → 1.2.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 (46) hide show
  1. package/CHANGELOG.md +62 -2
  2. package/README.md +17 -16
  3. package/dist/App.js +1010 -77
  4. package/dist/adapters.js +108 -8
  5. package/dist/agent/gates.js +14 -1
  6. package/dist/agent/loop-guard.js +182 -0
  7. package/dist/agent/loop.js +781 -329
  8. package/dist/agent/normalize.js +151 -0
  9. package/dist/cli.js +16 -2
  10. package/dist/compact.js +128 -2
  11. package/dist/env-block.js +43 -5
  12. package/dist/scheduler.js +101 -21
  13. package/dist/sessions.js +524 -0
  14. package/dist/system.js +89 -12
  15. package/dist/telemetry-dashboard.js +19 -1
  16. package/dist/telemetry.js +55 -0
  17. package/dist/tools/dir-cache.js +214 -0
  18. package/dist/tools/filesystem.js +43 -3
  19. package/dist/tools/read-cache.js +160 -0
  20. package/dist/tools/registry.js +80 -0
  21. package/dist/tools/ripgrep.js +256 -0
  22. package/dist/tools/search.js +147 -80
  23. package/dist/tools/shared.js +39 -0
  24. package/dist/tools/shell.js +26 -5
  25. package/dist/tools/todo.js +1 -1
  26. package/dist/tools/web.js +6 -6
  27. package/dist/tools.js +3 -0
  28. package/dist/ui/diff-panel.js +55 -0
  29. package/dist/ui/diff-view.js +117 -0
  30. package/dist/ui/diff.js +422 -0
  31. package/dist/ui/highlight.js +120 -0
  32. package/dist/ui/live-host.js +18 -0
  33. package/dist/ui/live-tail.js +9 -3
  34. package/dist/ui/markdown.js +26 -2
  35. package/dist/ui/modals.js +22 -5
  36. package/dist/ui/palette.js +12 -2
  37. package/dist/ui/side-by-side.js +144 -0
  38. package/dist/ui/status-bar.js +20 -4
  39. package/dist/ui/status-host.js +22 -0
  40. package/dist/ui/stream-store.js +48 -0
  41. package/dist/ui/theme.js +6 -0
  42. package/dist/ui/todo-panel.js +10 -2
  43. package/dist/ui/tool-inspector.js +7 -1
  44. package/dist/ui/transcript.js +105 -39
  45. package/dist/zen.js +97 -20
  46. package/package.json +1 -1
@@ -9,18 +9,25 @@
9
9
  // call); everything else executes strictly serially in program order. A
10
10
  // failure in one call NEVER skips the remaining commits of its block when
11
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 didthe caller rolls the partial turn back, so
14
- // assistant/tool pairing stays valid.
12
+ // A length-truncated response (`truncated: true`: the output limit cut tool
13
+ // arguments off) executes nothing each carried call commits a
14
+ // repair-oriented error result and the turn continues to the next model
15
+ // round, bounded by the step/total-call budgets. A thrown execution error
16
+ // (or cancel) aborts the turn exactly as the old serial loop did — the
17
+ // caller rolls the partial turn back, so assistant/tool pairing stays valid.
15
18
  //
16
- // Dependency direction: agent/loop -> {tools, scheduler, config,
17
- // context-manager, agent/gates, agent/types} and NOT zen (transports stay in
19
+ // Dependency direction: agent/loop -> {tools, tools/read-cache,
20
+ // scheduler, config, context-manager, agent/gates, agent/loop-guard,
21
+ // agent/normalize, agent/types} and NOT zen (transports stay in
18
22
  // zen.ts; runAgenticLoopForProvider wraps this loop from there).
19
23
  import { loadAtomConfig } from "../config.js";
20
- import { createContextManager, historyCharBudget, historyMessageBudget, truncateHistoryWithCaps, } from "../context-manager.js";
24
+ import { createContextManager, historyCharBudget, historyChars, historyMessageBudget, truncateHistoryWithCaps, } from "../context-manager.js";
21
25
  import { planBatches } from "../scheduler.js";
22
- import { describeToolCall, executeTool, invalidCall, MAX_TOOL_STEPS, needsApproval, toolNames, validateAskQuestionArgs, validateToolArgs, } from "../tools.js";
26
+ import { describeToolCall, executeTool, invalidCall, needsApproval, toolNames, validateAskQuestionArgs, validateToolArgs, } from "../tools.js";
27
+ import { getReadCacheStats } from "../tools/read-cache.js";
23
28
  import { bashExitCode, evaluateTurnEnd, isCodePath, isVerificationCommand, openTodoNeedles, } from "./gates.js";
29
+ import { errorStreakFollowUp, ErrorStreakTracker, repetitionFollowUp, RepetitionGuard, repetitionStopNotice, } from "./loop-guard.js";
30
+ import { normalizeChatResult, normalizeToolResult, toolSignature } from "./normalize.js";
24
31
  // Whole-turn cancellation: thrown when the user cancels (Ctrl+C) mid-loop.
25
32
  // The App catches it, rolls the partial turn back (same splice contract as
26
33
  // POST failure), renders one dim `(cancelled)` line, and returns to a clean
@@ -49,9 +56,10 @@ export function throwIfCancelled(signal) {
49
56
  if (signal?.aborted)
50
57
  throw new LoopCancelledError();
51
58
  }
52
- // Tool-round budget for one agentic turn (env → atom.json → 30).
53
- // A real explore implement verify task needs 15–30 tool rounds, so the
54
- // default is 30; an explicit `opts.maxSteps` still wins (tests inject it).
59
+ // Tool-round budget for one agentic turn (env → atom.json → unlimited).
60
+ // No default cap: the turn runs until the model ends it, a gate stops it,
61
+ // or the user cancels. An explicit `opts.maxSteps` (or env/file value) still
62
+ // caps the turn (tests inject it).
55
63
  export function toolStepBudget() {
56
64
  const raw = process.env.ATOM_MAX_TOOL_STEPS;
57
65
  if (raw !== undefined) {
@@ -62,7 +70,7 @@ export function toolStepBudget() {
62
70
  return Math.min(Math.max(Math.floor(n), 5), 100);
63
71
  }
64
72
  }
65
- return loadAtomConfig().config.maxToolSteps ?? MAX_TOOL_STEPS;
73
+ return loadAtomConfig().config.maxToolSteps ?? Number.POSITIVE_INFINITY;
66
74
  }
67
75
  // Legacy trim entry: byte-identical contract (legacy env/config/default caps
68
76
  // + live todo pinning, same notice, same in-place splice). New code should
@@ -71,6 +79,113 @@ export function toolStepBudget() {
71
79
  export function truncateHistory(history, notify, reserve) {
72
80
  return truncateHistoryWithCaps(history, { maxMessages: historyMessageBudget(), maxChars: historyCharBudget() }, { notify, reserve, todoNeedles: openTodoNeedles() });
73
81
  }
82
+ // Empty-response recovery (live-proven on free-tier gateways: a 200-OK
83
+ // stream can carry only queue comments and reasoning with zero answer text
84
+ // and zero tool calls, which the transport reports as an `Empty reply`
85
+ // error). A failed POST normally aborts the turn — EXCEPT this one: ending
86
+ // the turn on model silence with no fallback makes flaky backends fatal, so
87
+ // the loop spends a bounded number of extra POSTs asking the model to repair
88
+ // (same assistant+user follow-up shape as the turn-end gates, so pairing
89
+ // stays valid). When the budget is spent the original error throws, exactly
90
+ // as before — the caller rolls back and the user sees it.
91
+ export const MAX_EMPTY_ROUNDS = 2;
92
+ export function isEmptyReplyError(e) {
93
+ return e instanceof Error && e.message.startsWith("Empty reply");
94
+ }
95
+ export function emptyResponseFollowUp(attempt) {
96
+ return (`(empty response: attempt ${attempt} returned no text and no tool calls — ` +
97
+ `the turn cannot end on silence. Continue with tool calls toward the goal, ` +
98
+ `or answer in text. If there is genuinely nothing to do, end by saying so explicitly.)`);
99
+ }
100
+ // Per-tool outer timeout (ms): undefined → default 60s (enabled); explicit
101
+ // <=0/NaN → disabled (direct await, zero overhead). Clamped 1s–120s when
102
+ // enabled so a stuck executor can never hang the turn past the bash ceiling.
103
+ export const DEFAULT_TOOL_TIMEOUT_MS = 60_000;
104
+ export function resolveToolTimeoutMs(raw) {
105
+ if (raw === undefined)
106
+ return DEFAULT_TOOL_TIMEOUT_MS;
107
+ if (typeof raw !== "number" || !Number.isFinite(raw))
108
+ return DEFAULT_TOOL_TIMEOUT_MS;
109
+ if (raw <= 0)
110
+ return null;
111
+ return Math.min(Math.max(Math.floor(raw), 1000), 120_000);
112
+ }
113
+ // Total tool-call budget per turn (no default cap; explicit opts value only,
114
+ // min 1). Previously defaulted to 200 as a parallel-batch explosion guard.
115
+ // Deprecated alias kept for import compatibility; the loop no longer uses it.
116
+ export const DEFAULT_MAX_TOTAL_TOOL_CALLS = Number.POSITIVE_INFINITY;
117
+ export function resolveMaxTotalToolCalls(raw) {
118
+ if (typeof raw !== "number" || !Number.isFinite(raw))
119
+ return Number.POSITIVE_INFINITY;
120
+ return Math.max(1, Math.floor(raw));
121
+ }
122
+ // Race one execution against the outer timeout. Timeout resolves to an
123
+ // `Error:` result (the model adapts); the underlying promise is left to
124
+ // settle — executors own their own cleanup.
125
+ //
126
+ // Cancellation is DELIBERATELY not raced here: the pinned contract is that
127
+ // an in-flight tool runs to completion and its result IS recorded, with the
128
+ // cancel stopping the turn before the next batch/POST (see the
129
+ // throwIfCancelled checks between batches and before each POST). Racing
130
+ // abort against the execution would drop the in-flight result and break
131
+ // assistant/tool pairing guarantees the tests pin. A hung tool + cancel
132
+ // therefore waits for the timeout (≤60s), commits the timeout error, then
133
+ // the next boundary check throws LoopCancelledError.
134
+ export async function executeWithTimeout(execute, name, parsed, timeoutMs, signal) {
135
+ // No new executions after a cancel: refuse to start when already aborted.
136
+ if (signal?.aborted)
137
+ throw new LoopCancelledError();
138
+ if (timeoutMs === null) {
139
+ return execute(name, parsed);
140
+ }
141
+ let timer = null;
142
+ try {
143
+ const execP = execute(name, parsed);
144
+ const timeoutP = new Promise((_resolve, reject) => {
145
+ timer = setTimeout(() => {
146
+ const err = new Error(`timeout after ${timeoutMs}ms`);
147
+ err.code = "ToolTimeout";
148
+ reject(err);
149
+ }, timeoutMs);
150
+ });
151
+ try {
152
+ return await Promise.race([execP, timeoutP]);
153
+ }
154
+ catch (e) {
155
+ if (isCancelError(e))
156
+ throw e;
157
+ if (e?.code === "ToolTimeout" || e?.message?.startsWith("timeout after ")) {
158
+ return `Error: ${name} timed out after ${timeoutMs}ms — retry with a narrower scope or smaller input.`;
159
+ }
160
+ throw e;
161
+ }
162
+ }
163
+ finally {
164
+ if (timer)
165
+ clearTimeout(timer);
166
+ }
167
+ }
168
+ // Permission-gate resolution shared by the serial path and the parallel
169
+ // pre-pass: returns the hook's decision, or null when no approval applies
170
+ // (no hook, or a tool that never needs it). A "no" (or a throwing hook)
171
+ // denies without executing; cancellation always propagates.
172
+ async function resolveApproval(name, parsed, opts) {
173
+ if (!opts?.approve || !needsApproval(name))
174
+ return null;
175
+ try {
176
+ const decision = await opts.approve(name, parsed);
177
+ // Abort that lands as a resolved denial still cancels the whole turn.
178
+ throwIfCancelled(opts?.signal);
179
+ return decision;
180
+ }
181
+ catch (e) {
182
+ // Whole-turn cancellation must propagate (Ctrl+C cancels the turn,
183
+ // not just deny one call). Anything else is a denial.
184
+ if (isCancelError(e) || opts?.signal?.aborted)
185
+ throw new LoopCancelledError();
186
+ return "no";
187
+ }
188
+ }
74
189
  // Execute one parsed tool call through validation + permission +
75
190
  // ask_question gates. Model mistakes (unknown name, invalid args) return
76
191
  // repairs-oriented results WITHOUT executing; cancellations propagate as
@@ -78,10 +193,11 @@ export function truncateHistory(history, notify, reserve) {
78
193
  // returns a result string fed back to the model:
79
194
  // - ask_question never needs approval; without an askUser hook it resolves
80
195
  // to "Error: ask_question has no UI hook".
81
- // - write/edit/bash consult the approve hook when one is provided; a "no"
82
- // resolves to "Error: denied by user: <tool>" (final, no retry/rollback).
83
- // Without a hook every tool executes immediately.
84
- async function runOneTool(call, parsed, opts, execute) {
196
+ // - write/edit/bash consult the approve hook when one is provided (or a
197
+ // pre-resolved batch decision); a "no" resolves to
198
+ // "Error: denied by user: <tool>" (final, no retry/rollback). Without a
199
+ // hook every tool executes immediately.
200
+ async function runOneTool(call, parsed, opts, execute, preDecision) {
85
201
  const name = call?.function?.name ?? "(unknown)";
86
202
  // Unknown tool: model mistake — list actual names, never execute.
87
203
  if (!toolNames().includes(name)) {
@@ -100,33 +216,24 @@ async function runOneTool(call, parsed, opts, execute) {
100
216
  // the next POST (no new POSTs, pairing stays valid until rollback).
101
217
  return runAskQuestion(parsed, opts?.askUser, opts?.signal);
102
218
  }
103
- if (opts?.approve && needsApproval(name)) {
104
- let decision;
105
- try {
106
- decision = await opts.approve(name, parsed);
107
- }
108
- catch (e) {
109
- // Whole-turn cancellation must propagate (Ctrl+C cancels the turn,
110
- // not just deny one call). Anything else is a denial.
111
- if (isCancelError(e) || opts?.signal?.aborted)
112
- throw new LoopCancelledError();
113
- decision = "no";
114
- }
115
- // Abort that lands as a resolved denial still cancels the whole turn.
116
- throwIfCancelled(opts?.signal);
117
- if (decision === "no") {
118
- return `Error: denied by user: ${name}`;
119
- }
120
- // "once" runs this call; "always" runs it too (the caller caches the
121
- // always-allowed set session-wide so later calls skip the prompt).
219
+ const decision = preDecision ?? (await resolveApproval(name, parsed, opts));
220
+ if (decision === "no") {
221
+ return `Error: denied by user: ${name}`;
122
222
  }
223
+ // "once"/"always" run this call (the caller caches the always-allowed set
224
+ // session-wide so later calls skip the prompt).
123
225
  // No new executions after a cancel: stop after the current tool finishes.
124
226
  // The current tool (if already running) is awaited to completion and its
125
227
  // result IS recorded — the loop then stops before the next tool/POST, so
126
228
  // assistant/tool pairing stays valid until the caller rolls back.
127
229
  throwIfCancelled(opts?.signal);
230
+ const timeoutMs = resolveToolTimeoutMs(opts?.toolTimeoutMs);
231
+ const doNormalize = opts?.normalizeResults !== false;
128
232
  try {
129
- return await execute(name, parsed);
233
+ const raw = await executeWithTimeout(execute, name, parsed, timeoutMs, opts?.signal);
234
+ if (doNormalize)
235
+ return normalizeToolResult(raw);
236
+ return typeof raw === "string" ? raw : normalizeToolResult(raw);
130
237
  }
131
238
  catch (e) {
132
239
  if (isCancelError(e) || opts?.signal?.aborted)
@@ -134,6 +241,35 @@ async function runOneTool(call, parsed, opts, execute) {
134
241
  throw e;
135
242
  }
136
243
  }
244
+ // After-tool-call result hook (issue 06): the single rewrite seam between
245
+ // execution and commit. Absent hook (or null/undefined/void/non-object
246
+ // return) → the original result, byte-identical. A string return replaces
247
+ // content (error flag kept); an object may replace content, override
248
+ // isError, or veto the commit. Hook failures degrade to the original — the
249
+ // turn never breaks. Telemetry keeps the pre-hook execution result; only the
250
+ // committed history/transcript/activity sees the rewrite.
251
+ async function applyToolResultHook(hook, name, parsed, result, isError) {
252
+ if (!hook)
253
+ return { content: result, isError, veto: false };
254
+ try {
255
+ const decision = await hook({ name, args: parsed, result, isError });
256
+ if (decision === null || decision === undefined)
257
+ return { content: result, isError, veto: false };
258
+ if (typeof decision === "string")
259
+ return { content: decision, isError, veto: false };
260
+ if (typeof decision === "object") {
261
+ if (decision.veto === true)
262
+ return { content: result, isError, veto: true };
263
+ const content = typeof decision.content === "string" ? decision.content : result;
264
+ const nextIsError = typeof decision.isError === "boolean" ? decision.isError : isError;
265
+ return { content, isError: nextIsError, veto: false };
266
+ }
267
+ return { content: result, isError, veto: false };
268
+ }
269
+ catch {
270
+ return { content: result, isError, veto: false };
271
+ }
272
+ }
137
273
  async function runAskQuestion(parsed, askUser, signal) {
138
274
  const invalid = validateAskQuestionArgs(parsed);
139
275
  if (invalid)
@@ -226,9 +362,12 @@ export async function runLoopWithChat(chatFn, history, opts) {
226
362
  let verifiedAfterWrite = false;
227
363
  let needsVerification = false;
228
364
  let unverifiedPaths = [];
229
- // Verification-gate nag cycles spent (bounds the continue loop alongside
230
- // the step budget — a model that never verifies still terminates).
365
+ // Verification-gate nag cycles spent (bounds the continue loop via
366
+ // MAX_VERIFY_ROUNDS — a model that never verifies still terminates).
231
367
  let verifyRounds = 0;
368
+ // Todo-guard cycles spent (bounds guard continues via MAX_TODO_ROUNDS —
369
+ // a model that never resolves open todos still terminates).
370
+ let todoRounds = 0;
232
371
  // At most one truncation notice per turn; silence when nothing dropped.
233
372
  let truncationNoticed = false;
234
373
  // Window-aware trimmer when the caller knows the model (App passes it);
@@ -237,363 +376,676 @@ export async function runLoopWithChat(chatFn, history, opts) {
237
376
  const contextManager = opts?.context
238
377
  ? createContextManager({ model: opts.context.model, toolsChars: opts.context.toolsChars })
239
378
  : null;
240
- for (let step = 0;; step++) {
241
- throwIfCancelled(signal);
242
- // Steering seam: drain one pending steer message (if any) at this safe
243
- // point previous tool batches are fully committed, so assistant/tool
244
- // pairing can never split. Runs before the budget trim so truncation
245
- // accounts for the injected message. No-op without the hook.
246
- try {
247
- opts?.drainSteer?.();
248
- }
249
- catch {
250
- // observer errors never break the loop
379
+ // ---- Hardened-loop state (additive; explicit caps still honored
380
+ // see AgenticOpts docs) ----
381
+ const maxTotalToolCalls = resolveMaxTotalToolCalls(opts?.maxTotalToolCalls);
382
+ const repGuard = new RepetitionGuard({ maxRepeatedCalls: opts?.maxRepeatedCalls });
383
+ const errStreak = new ErrorStreakTracker(opts?.maxConsecutiveErrors);
384
+ const turnStartMs = Date.now();
385
+ let startChars = 0;
386
+ try {
387
+ startChars = historyChars(history);
388
+ }
389
+ catch {
390
+ startChars = 0;
391
+ }
392
+ let cacheHitsStart = 0;
393
+ try {
394
+ cacheHitsStart = getReadCacheStats().hits;
395
+ }
396
+ catch {
397
+ cacheHitsStart = 0;
398
+ }
399
+ let modelCalls = 0;
400
+ let toolCalls = 0;
401
+ let failures = 0;
402
+ let droppedTurnsTotal = 0;
403
+ // Empty-response repairs spent (bounded by MAX_EMPTY_ROUNDS — a model
404
+ // that only answers silence still terminates).
405
+ let emptyRounds = 0;
406
+ let bottleneck = null;
407
+ const noteBottleneck = (name, durationMs) => {
408
+ if (!Number.isFinite(durationMs) || durationMs < 0)
409
+ return;
410
+ if (!bottleneck || durationMs > bottleneck.durationMs) {
411
+ bottleneck = { name, durationMs: Math.floor(durationMs) };
251
412
  }
252
- // History budget (uniform for all providers — every POST flows through
253
- // here): trim oldest user-turns first before each send.
254
- const trimmed = contextManager
255
- ? contextManager.trimForSend(history, truncationNoticed
256
- ? undefined
257
- : (notice) => {
258
- try {
259
- opts?.onWarning?.(notice);
260
- }
261
- catch {
262
- // ignore observer errors
263
- }
264
- }, undefined, openTodoNeedles())
265
- : truncateHistory(history, truncationNoticed
266
- ? undefined
267
- : (notice) => {
268
- try {
269
- opts?.onWarning?.(notice);
270
- }
271
- catch {
272
- // ignore observer errors
273
- }
274
- });
275
- if (trimmed.droppedTurns > 0)
276
- truncationNoticed = true;
277
- let msg;
278
- const modelStart = Date.now();
413
+ };
414
+ const finishStats = () => {
279
415
  try {
280
- msg = await chatFn(history, {
281
- onToken: opts?.onToken,
282
- onPhase: opts?.onPhase,
283
- onToolDelta: opts?.onToolDelta,
284
- onWarning: opts?.onWarning,
285
- onThinking: opts?.onThinking,
286
- sleep: opts?.sleep,
287
- reasoningEffort: opts?.reasoningEffort,
288
- signal,
289
- });
290
- }
291
- catch (e) {
292
- // A failed POST still records its model call (with the error) so the
293
- // trace shows what was attempted — the caller still rolls back.
294
- const modelEnd = Date.now();
295
- reportModelCall({
296
- step,
297
- startedAt: telemetryIso(modelStart),
298
- endedAt: telemetryIso(modelEnd),
299
- durationMs: Math.max(0, modelEnd - modelStart),
300
- usageReported: false,
301
- toolCallCount: 0,
302
- finishReason: "error",
303
- error: e instanceof Error ? e.message : String(e),
304
- });
305
- if (isCancelError(e) || signal?.aborted)
306
- throw new LoopCancelledError();
307
- throw e;
308
- }
309
- throwIfCancelled(signal);
310
- if (msg.usage !== undefined) {
311
- // Spend accounting: EVERY POST that reports usage forwards it, and the
312
- // caller accumulates each report as billed spend — tool-round POSTs,
313
- // summary POSTs, and successful retries each count once. Attempts that
314
- // fail (HTTP/network/truncation) report no usage, so there is nothing
315
- // to dedupe: each attempt that reached the provider and reported counts
316
- // exactly once. Usage is never synthesized or estimated here.
416
+ let endChars = startChars;
317
417
  try {
318
- opts?.onUsage?.(msg.usage);
418
+ endChars = historyChars(history);
319
419
  }
320
420
  catch {
321
- // ignore
421
+ endChars = startChars;
322
422
  }
323
- }
324
- if (msg.reasoning !== undefined) {
423
+ let cacheHits = 0;
325
424
  try {
326
- opts?.onReasoning?.(msg.reasoning);
425
+ cacheHits = Math.max(0, getReadCacheStats().hits - cacheHitsStart);
327
426
  }
328
427
  catch {
329
- // ignore
428
+ cacheHits = 0;
330
429
  }
430
+ const stats = {
431
+ steps: modelCalls,
432
+ modelCalls,
433
+ toolCalls,
434
+ failures,
435
+ repetitionHits: repGuard.hitCount,
436
+ cacheHits,
437
+ truncationNotices: droppedTurnsTotal,
438
+ durationMs: Math.max(0, Date.now() - turnStartMs),
439
+ bottleneck,
440
+ contextGrowthChars: endChars - startChars,
441
+ };
442
+ opts?.onLoopStats?.(stats);
331
443
  }
332
- {
333
- // Completed model call: usage is forwarded only when the response
334
- // actually carried it (usageReported) — never synthesized here.
335
- const modelEnd = Date.now();
336
- const callsCount = (msg.tool_calls ?? []).length;
337
- reportModelCall({
338
- step,
339
- startedAt: telemetryIso(modelStart),
340
- endedAt: telemetryIso(modelEnd),
341
- durationMs: Math.max(0, modelEnd - modelStart),
342
- usage: msg.usage,
343
- usageReported: msg.usage !== undefined,
344
- reasoningLabel: msg.reasoning,
345
- toolCallCount: callsCount,
346
- finishReason: callsCount === 0 ? "final" : "tool_calls",
347
- });
444
+ catch {
445
+ // observer errors never break the turn
348
446
  }
349
- const calls = msg.tool_calls ?? [];
350
- if (calls.length === 0) {
351
- // Turn-continuation seam (ticket 03): the todo guard and verification
352
- // gate run as entries in TURN_END_GATES — one chain, one commit point.
353
- // Behavior is byte-identical to the two inline blocks this replaced.
354
- const outcome = evaluateTurnEnd(msg.content ?? "", {
355
- step,
356
- maxSteps,
357
- filesWritten,
358
- verifiedAfterWrite,
359
- needsVerification,
360
- unverifiedPaths: [...unverifiedPaths],
361
- verifyRounds,
362
- });
363
- if (outcome.kind === "continue") {
364
- // Verification-gate continues are bounded per turn (alongside the
365
- // step budget) so a model that never verifies still terminates.
366
- if (outcome.via === "verification")
367
- verifyRounds += 1;
368
- history.push({ role: "assistant", content: outcome.assistantText });
369
- history.push({ role: "user", content: outcome.followUp });
370
- continue;
371
- }
372
- history.push({ role: "assistant", content: outcome.finalText });
447
+ };
448
+ try {
449
+ for (let step = 0;; step++) {
450
+ throwIfCancelled(signal);
451
+ // Steering seam: drain one pending steer message (if any) at this safe
452
+ // point previous tool batches are fully committed, so assistant/tool
453
+ // pairing can never split. Runs before the budget trim so truncation
454
+ // accounts for the injected message. No-op without the hook.
373
455
  try {
374
- opts?.onPhase?.("done");
456
+ opts?.drainSteer?.();
375
457
  }
376
458
  catch {
377
- // ignore
459
+ // observer errors never break the loop
378
460
  }
379
- return outcome.finalText;
380
- }
381
- if (step >= maxSteps) {
382
- const base = msg.content ?? "";
383
- const notice = `${base}${base ? "\n" : ""}(stopped: too many tool steps) (limit is ${maxSteps}; raise with ATOM_MAX_TOOL_STEPS=<n>)`;
384
- history.push({ role: "assistant", content: notice });
461
+ // History budget (uniform for all providers — every POST flows through
462
+ // here): trim oldest user-turns first before each send.
463
+ const trimmed = contextManager
464
+ ? contextManager.trimForSend(history, truncationNoticed
465
+ ? undefined
466
+ : (notice) => {
467
+ try {
468
+ opts?.onWarning?.(notice);
469
+ }
470
+ catch {
471
+ // ignore observer errors
472
+ }
473
+ }, undefined, openTodoNeedles())
474
+ : truncateHistory(history, truncationNoticed
475
+ ? undefined
476
+ : (notice) => {
477
+ try {
478
+ opts?.onWarning?.(notice);
479
+ }
480
+ catch {
481
+ // ignore observer errors
482
+ }
483
+ });
484
+ if (trimmed.droppedTurns > 0) {
485
+ truncationNoticed = true;
486
+ droppedTurnsTotal += trimmed.droppedTurns;
487
+ }
488
+ let msg;
489
+ const modelStart = Date.now();
490
+ try {
491
+ msg = await chatFn(history, {
492
+ onToken: opts?.onToken,
493
+ onPhase: opts?.onPhase,
494
+ onToolDelta: opts?.onToolDelta,
495
+ onWarning: opts?.onWarning,
496
+ onThinking: opts?.onThinking,
497
+ sleep: opts?.sleep,
498
+ reasoningEffort: opts?.reasoningEffort,
499
+ signal,
500
+ });
501
+ }
502
+ catch (e) {
503
+ // A failed POST still records its model call (with the error) so the
504
+ // trace shows what was attempted — the caller still rolls back.
505
+ const modelEnd = Date.now();
506
+ reportModelCall({
507
+ step,
508
+ startedAt: telemetryIso(modelStart),
509
+ endedAt: telemetryIso(modelEnd),
510
+ durationMs: Math.max(0, modelEnd - modelStart),
511
+ usageReported: false,
512
+ toolCallCount: 0,
513
+ finishReason: "error",
514
+ error: e instanceof Error ? e.message : String(e),
515
+ });
516
+ if (isCancelError(e) || signal?.aborted)
517
+ throw new LoopCancelledError();
518
+ // Empty-response recovery: a silent POST spends repair budget instead
519
+ // of aborting the turn (see MAX_EMPTY_ROUNDS). Cancellation above still
520
+ // wins; every other failure throws exactly as before.
521
+ if (isEmptyReplyError(e) && emptyRounds < MAX_EMPTY_ROUNDS) {
522
+ emptyRounds += 1;
523
+ failures += 1;
524
+ history.push({ role: "assistant", content: "" });
525
+ history.push({ role: "user", content: emptyResponseFollowUp(emptyRounds) });
526
+ continue;
527
+ }
528
+ throw e;
529
+ }
530
+ throwIfCancelled(signal);
531
+ // Defensive normalization (malformed custom chatFn responses never crash
532
+ // the commit path): dropped calls surface via onWarning, pairing stays
533
+ // valid because only validated calls reach the batch planner.
385
534
  try {
386
- opts?.onPhase?.("done");
535
+ const norm = normalizeChatResult(msg);
536
+ if (norm.warnings.length > 0) {
537
+ for (const w of norm.warnings) {
538
+ try {
539
+ opts?.onWarning?.(w);
540
+ }
541
+ catch {
542
+ // ignore observer errors
543
+ }
544
+ }
545
+ }
546
+ msg = norm.result;
387
547
  }
388
548
  catch {
389
- // ignore
549
+ // normalization never breaks the turn; the raw message stands
390
550
  }
391
- return notice;
392
- }
393
- history.push({ role: "assistant", content: msg.content ?? null, tool_calls: calls });
394
- // Commit helper shared by the serial and parallel paths: Task 7
395
- // bookkeeping + one ordered transcript entry per call. Only successful
396
- // executions count denials, validation errors, and unknown tools (all
397
- // `Error:` results) never ran, so they neither arm nor clear the gate.
398
- const commitToolResult = (name, parsed, call, result) => {
399
- const isError = typeof result === "string" && result.startsWith("Error");
400
- if (!isError && (name === "write" || name === "edit")) {
401
- filesWritten = true;
402
- verifiedAfterWrite = false;
403
- const p = typeof parsed["path"] === "string" ? parsed["path"] : "";
404
- if (isCodePath(p)) {
405
- needsVerification = true;
406
- if (p.length > 0 && !unverifiedPaths.includes(p))
407
- unverifiedPaths.push(p);
551
+ modelCalls += 1;
552
+ if (msg.usage !== undefined) {
553
+ // Spend accounting: EVERY POST that reports usage forwards it, and the
554
+ // caller accumulates each report as billed spend tool-round POSTs,
555
+ // summary POSTs, and successful retries each count once. Attempts that
556
+ // fail (HTTP/network/truncation) report no usage, so there is nothing
557
+ // to dedupe: each attempt that reached the provider and reported counts
558
+ // exactly once. Usage is never synthesized or estimated here.
559
+ try {
560
+ opts?.onUsage?.(msg.usage);
561
+ }
562
+ catch {
563
+ // ignore
408
564
  }
409
565
  }
410
- else if (!isError && name === "bash") {
411
- const command = parsed["command"];
412
- if (typeof command === "string" && isVerificationCommand(command) && filesWritten) {
413
- const exit = bashExitCode(result);
414
- if (exit === null || exit === 0) {
415
- // Passing check (or a legacy runner that reports no envelope):
416
- // clears everything the gate tracks.
417
- verifiedAfterWrite = true;
418
- needsVerification = false;
419
- unverifiedPaths = [];
420
- }
421
- else {
422
- // A FAILED check is evidence of failure, not of verification:
423
- // the gate stays armed so the model fixes forward instead of
424
- // finishing on red output.
425
- verifiedAfterWrite = false;
426
- }
566
+ if (msg.reasoning !== undefined) {
567
+ try {
568
+ opts?.onReasoning?.(msg.reasoning);
569
+ }
570
+ catch {
571
+ // ignore
427
572
  }
428
573
  }
429
- history.push({ role: "tool", tool_call_id: call?.id ?? "", content: result });
430
- try {
431
- opts?.onToolActivity?.(describeToolCall(name, parsed), result, isError);
574
+ {
575
+ // Completed model call: usage is forwarded only when the response
576
+ // actually carried it (usageReported) never synthesized here.
577
+ const modelEnd = Date.now();
578
+ const callsCount = (msg.tool_calls ?? []).length;
579
+ reportModelCall({
580
+ step,
581
+ startedAt: telemetryIso(modelStart),
582
+ endedAt: telemetryIso(modelEnd),
583
+ durationMs: Math.max(0, modelEnd - modelStart),
584
+ usage: msg.usage,
585
+ usageReported: msg.usage !== undefined,
586
+ reasoningLabel: msg.reasoning,
587
+ toolCallCount: callsCount,
588
+ finishReason: callsCount === 0 ? "final" : "tool_calls",
589
+ });
432
590
  }
433
- catch {
434
- // ignore observer errors
591
+ const calls = msg.tool_calls ?? [];
592
+ if (calls.length === 0) {
593
+ // Turn-continuation seam (ticket 03): the todo guard and verification
594
+ // gate run as entries in TURN_END_GATES — one chain, one commit point.
595
+ // Behavior is byte-identical to the two inline blocks this replaced.
596
+ const outcome = evaluateTurnEnd(msg.content ?? "", {
597
+ step,
598
+ maxSteps,
599
+ filesWritten,
600
+ verifiedAfterWrite,
601
+ needsVerification,
602
+ unverifiedPaths: [...unverifiedPaths],
603
+ verifyRounds,
604
+ todoRounds,
605
+ });
606
+ if (outcome.kind === "continue") {
607
+ // Guard continues are bounded per turn so a model that never
608
+ // verifies or never resolves todos still terminates.
609
+ if (outcome.via === "verification")
610
+ verifyRounds += 1;
611
+ else if (outcome.via === "todoCompletionGate")
612
+ todoRounds += 1;
613
+ history.push({ role: "assistant", content: outcome.assistantText });
614
+ history.push({ role: "user", content: outcome.followUp });
615
+ continue;
616
+ }
617
+ // Error-streak recovery (additive, after the pinned gates): ending on
618
+ // sustained unaddressed `Error:` results is almost always premature.
619
+ // Single errors still end normally (the model may be reporting a
620
+ // blocker); a streak holds final text for one fix-forward attempt,
621
+ // bounded to 2 holds per turn.
622
+ if (errStreak.shouldHoldFinal(2)) {
623
+ const streak = errStreak.current;
624
+ history.push({ role: "assistant", content: outcome.finalText });
625
+ history.push({ role: "user", content: errorStreakFollowUp(streak) });
626
+ continue;
627
+ }
628
+ history.push({ role: "assistant", content: outcome.finalText });
629
+ try {
630
+ opts?.onPhase?.("done");
631
+ }
632
+ catch {
633
+ // ignore
634
+ }
635
+ return outcome.finalText;
435
636
  }
436
- };
437
- for (const batch of planBatches(calls)) {
438
- // No new executions after a cancel: the current tool (if any) already
439
- // finished; stop before starting the next batch.
440
- throwIfCancelled(signal);
441
- if (batch.length === 1) {
442
- // Serial path: byte-identical to the pre-05 loop body.
443
- const call = batch[0].call;
444
- const name = call?.function?.name ?? "(unknown)";
637
+ if (step >= maxSteps) {
638
+ const base = msg.content ?? "";
639
+ const notice = `${base}${base ? "\n" : ""}(stopped: too many tool steps) (limit is ${maxSteps}; raise with ATOM_MAX_TOOL_STEPS=<n>)`;
640
+ history.push({ role: "assistant", content: notice });
445
641
  try {
446
- opts?.onPhase?.("tool", name);
642
+ opts?.onPhase?.("done");
447
643
  }
448
644
  catch {
449
645
  // ignore
450
646
  }
451
- const toolStart = Date.now();
452
- let parsed;
647
+ return notice;
648
+ }
649
+ // Total tool-call budget (explicit `opts.maxTotalToolCalls` only;
650
+ // uncapped by default): counts every tool_call the model emits and stops
651
+ // the turn when the explicit budget is exceeded.
652
+ if (toolCalls + calls.length > maxTotalToolCalls) {
653
+ const base = msg.content ?? "";
654
+ const notice = `${base}${base ? "\n" : ""}(stopped: too many tool calls) (limit is ${maxTotalToolCalls} per turn)`;
655
+ history.push({ role: "assistant", content: notice });
453
656
  try {
454
- const raw = call?.function?.arguments ?? "{}";
455
- const v = JSON.parse(typeof raw === "string" ? raw : "{}");
456
- parsed = typeof v === "object" && v !== null ? v : {};
657
+ opts?.onPhase?.("done");
457
658
  }
458
659
  catch {
459
- parsed = {};
460
- const result = `Error: invalid call: invalid JSON arguments for tool "${name}" (arguments must be valid JSON). Fix the arguments and retry.`;
461
- history.push({ role: "tool", tool_call_id: call?.id ?? "", content: result });
462
- try {
463
- opts?.onToolActivity?.(describeToolCall(name, {}), result, true);
660
+ // ignore
661
+ }
662
+ return notice;
663
+ }
664
+ history.push({ role: "assistant", content: msg.content ?? null, tool_calls: calls });
665
+ // Commit helper shared by the serial and parallel paths: Task 7
666
+ // bookkeeping + one ordered transcript entry per call. Only successful
667
+ // executions count — denials, validation errors, and unknown tools (all
668
+ // `Error:` results) never ran, so they neither arm nor clear the gate.
669
+ const commitToolResult = async (name, parsed, call, result, durationMs) => {
670
+ const baseIsError = typeof result === "string" && result.startsWith("Error");
671
+ const hooked = await applyToolResultHook(opts?.onToolResult, name, parsed, result, baseIsError);
672
+ // Veto: skip the commit entirely — no counters, no gates, no history,
673
+ // no activity. The turn continues; pairing risk is the hook author's.
674
+ if (hooked.veto)
675
+ return false;
676
+ const finalResult = hooked.content;
677
+ const isError = hooked.isError;
678
+ toolCalls += 1;
679
+ if (isError)
680
+ failures += 1;
681
+ errStreak.noteResult(isError);
682
+ if (typeof durationMs === "number")
683
+ noteBottleneck(name, durationMs);
684
+ if (!isError && (name === "write" || name === "edit")) {
685
+ filesWritten = true;
686
+ verifiedAfterWrite = false;
687
+ const p = typeof parsed["path"] === "string" ? parsed["path"] : "";
688
+ if (isCodePath(p)) {
689
+ needsVerification = true;
690
+ if (p.length > 0 && !unverifiedPaths.includes(p))
691
+ unverifiedPaths.push(p);
464
692
  }
465
- catch {
466
- // ignore observer errors
693
+ }
694
+ else if (!isError && name === "bash") {
695
+ const command = parsed["command"];
696
+ if (typeof command === "string" && isVerificationCommand(command) && filesWritten) {
697
+ const exit = bashExitCode(finalResult);
698
+ if (exit === null || exit === 0) {
699
+ // Passing check (or a legacy runner that reports no envelope):
700
+ // clears everything the gate tracks.
701
+ verifiedAfterWrite = true;
702
+ needsVerification = false;
703
+ unverifiedPaths = [];
704
+ }
705
+ else {
706
+ // A FAILED check is evidence of failure, not of verification:
707
+ // the gate stays armed so the model fixes forward instead of
708
+ // finishing on red output.
709
+ verifiedAfterWrite = false;
710
+ }
467
711
  }
468
- const toolEnd = Date.now();
469
- reportToolCall({
470
- step,
471
- toolCallId: call?.id ?? "",
472
- name,
473
- startedAt: telemetryIso(toolStart),
474
- endedAt: telemetryIso(toolEnd),
475
- durationMs: Math.max(0, toolEnd - toolStart),
476
- argsJson: telemetryArgsJson(call?.function?.arguments ?? "{}"),
477
- result,
478
- batchIndex: 0,
479
- batchSize: 1,
480
- });
481
- continue;
482
712
  }
483
- let result;
713
+ history.push({ role: "tool", tool_call_id: call?.id ?? "", content: finalResult });
484
714
  try {
485
- result = await runOneTool(call, parsed, opts, execute);
715
+ opts?.onToolActivity?.(describeToolCall(name, parsed), finalResult, isError);
486
716
  }
487
- catch (e) {
488
- // A cancelled/throwing tool still records its attempt (with the
489
- // cause) so the trace shows what was in flight — then the turn
490
- // aborts exactly as before.
491
- const toolEnd = Date.now();
492
- const cancelled = isCancelError(e) || signal?.aborted;
493
- reportToolCall({
494
- step,
495
- toolCallId: call?.id ?? "",
496
- name,
497
- startedAt: telemetryIso(toolStart),
498
- endedAt: telemetryIso(toolEnd),
499
- durationMs: Math.max(0, toolEnd - toolStart),
500
- argsJson: telemetryArgsJson(parsed),
501
- result: e instanceof Error ? e.message : String(e),
502
- cancelled: cancelled ? true : undefined,
503
- threw: cancelled ? undefined : true,
504
- batchIndex: 0,
505
- batchSize: 1,
506
- });
507
- if (cancelled)
508
- throw new LoopCancelledError();
509
- throw e;
717
+ catch {
718
+ // ignore observer errors
510
719
  }
511
- {
512
- const toolEnd = Date.now();
720
+ return true;
721
+ };
722
+ // Length-truncated response (the output limit cut the tool arguments
723
+ // off): NOTHING executes — every carried call commits a repair-oriented
724
+ // error result and the turn continues to the next model round. The
725
+ // results are `Error:` strings, so failure/error-streak accounting flows
726
+ // through commitToolResult exactly like any other tool error, and the
727
+ // step/total-call budgets above keep bounding runaway retries.
728
+ // Truncated-without-calls never reaches here (handled by the turn-end
729
+ // gates above, exactly as before).
730
+ if (msg.truncated === true) {
731
+ for (let i = 0; i < calls.length; i++) {
732
+ const call = calls[i];
733
+ const name = call?.function?.name ?? "(unknown)";
734
+ const result = `Error: truncated response: the output limit cut off the arguments for tool "${name}" — ` +
735
+ `nothing was executed. Re-issue the call with complete arguments ` +
736
+ `(narrow the scope or split into smaller calls if it keeps truncating).`;
737
+ const toolAt = Date.now();
513
738
  reportToolCall({
514
739
  step,
515
740
  toolCallId: call?.id ?? "",
516
741
  name,
517
- startedAt: telemetryIso(toolStart),
518
- endedAt: telemetryIso(toolEnd),
519
- durationMs: Math.max(0, toolEnd - toolStart),
520
- argsJson: telemetryArgsJson(parsed),
742
+ startedAt: telemetryIso(toolAt),
743
+ endedAt: telemetryIso(toolAt),
744
+ durationMs: 0,
745
+ argsJson: telemetryArgsJson(call?.function?.arguments ?? "{}"),
521
746
  result,
522
- batchIndex: 0,
523
- batchSize: 1,
747
+ batchIndex: i,
748
+ batchSize: calls.length,
524
749
  });
750
+ // Truncated arguments are often not valid JSON (cut mid-string) —
751
+ // fall back to {} for bookkeeping (an error result never arms the
752
+ // verification gate, so this only shapes the activity label).
753
+ let parsed;
754
+ try {
755
+ const raw = call?.function?.arguments ?? "{}";
756
+ const v = JSON.parse(typeof raw === "string" ? raw : "{}");
757
+ parsed = typeof v === "object" && v !== null ? v : {};
758
+ }
759
+ catch {
760
+ parsed = {};
761
+ }
762
+ await commitToolResult(name, parsed, call, result);
525
763
  }
526
- commitToolResult(name, parsed, call, result);
527
764
  continue;
528
765
  }
529
- // Parallel batch: every member is pre-validated parallel-safe (see
530
- // planToolBatches), so runOneTool neither prompts nor blocks here.
531
- // Phases fire upfront in call order; results commit in call order, so
532
- // each call still shows separately and tool_call_ids re-pair by index.
533
- // A throw (cancel or execution error) aborts the turn exactly like the
534
- // serial path the caller rolls the partial turn back.
535
- for (const member of batch) {
536
- try {
537
- opts?.onPhase?.("tool", member.call?.function?.name ?? "(unknown)");
538
- }
539
- catch {
540
- // ignore
541
- }
542
- }
543
- let results;
544
- try {
545
- // Each member is timed individually (concurrent wall-clock per call,
546
- // not the whole batch attributed to each) and reported in call order
547
- // below. A throw still aborts the turn exactly like the serial path.
548
- results = await Promise.all(batch.map(async (member, index) => {
549
- const memberStart = Date.now();
766
+ for (const batch of planBatches(calls)) {
767
+ // No new executions after a cancel: the current tool (if any) already
768
+ // finished; stop before starting the next batch.
769
+ throwIfCancelled(signal);
770
+ if (batch.length === 1) {
771
+ // Serial path: byte-identical to the pre-05 loop body.
772
+ const call = batch[0].call;
773
+ const name = call?.function?.name ?? "(unknown)";
774
+ try {
775
+ opts?.onPhase?.("tool", name);
776
+ }
777
+ catch {
778
+ // ignore
779
+ }
780
+ const toolStart = Date.now();
781
+ let parsed;
550
782
  try {
551
- const r = await runOneTool(member.call, member.parsed, opts, execute);
552
- const memberEnd = Date.now();
783
+ const raw = call?.function?.arguments ?? "{}";
784
+ const v = JSON.parse(typeof raw === "string" ? raw : "{}");
785
+ parsed = typeof v === "object" && v !== null ? v : {};
786
+ }
787
+ catch {
788
+ parsed = {};
789
+ const result = `Error: invalid call: invalid JSON arguments for tool "${name}" (arguments must be valid JSON). Fix the arguments and retry.`;
790
+ // Invalid JSON never executes — route through the commit funnel so
791
+ // the result hook still sees every committed result. Bookkeeping
792
+ // (counters, error streak, bottleneck, history, activity) is
793
+ // identical to the inline block this replaced; only the committed
794
+ // content may differ when a hook rewrites it.
795
+ repGuard.note(toolSignature(name, parsed), name);
796
+ const toolEnd = Date.now();
797
+ reportToolCall({
798
+ step,
799
+ toolCallId: call?.id ?? "",
800
+ name,
801
+ startedAt: telemetryIso(toolStart),
802
+ endedAt: telemetryIso(toolEnd),
803
+ durationMs: Math.max(0, toolEnd - toolStart),
804
+ argsJson: telemetryArgsJson(call?.function?.arguments ?? "{}"),
805
+ result,
806
+ batchIndex: 0,
807
+ batchSize: 1,
808
+ });
809
+ await commitToolResult(name, parsed, call, result, Math.max(0, toolEnd - toolStart));
810
+ continue;
811
+ }
812
+ let result;
813
+ // Repetition guard (opt-in via maxRepeatedCalls; unset = track-only):
814
+ // a repeated signature skips execution and yields a guidance error;
815
+ // exhausted nudges stop hard.
816
+ const repSig = toolSignature(name, parsed);
817
+ const repNote = repGuard.note(repSig, name);
818
+ if (repNote.intervened) {
819
+ const toolEndRep = Date.now();
820
+ if (repGuard.consumeNudge()) {
821
+ const guarded = `Error: invalid call: ${repetitionFollowUp(repSig, repNote.consecutive)} Fix the approach and retry.`;
822
+ reportToolCall({
823
+ step,
824
+ toolCallId: call?.id ?? "",
825
+ name,
826
+ startedAt: telemetryIso(toolStart),
827
+ endedAt: telemetryIso(toolEndRep),
828
+ durationMs: 0,
829
+ argsJson: telemetryArgsJson(parsed),
830
+ result: guarded,
831
+ batchIndex: 0,
832
+ batchSize: 1,
833
+ });
834
+ await commitToolResult(name, parsed, call, guarded, 0);
835
+ continue;
836
+ }
837
+ const guarded = `Error: invalid call: ${repetitionFollowUp(repSig, repNote.consecutive)} Fix the approach and retry.`;
553
838
  reportToolCall({
554
839
  step,
555
- toolCallId: member.call?.id ?? "",
556
- name: member.call?.function?.name ?? "(unknown)",
557
- startedAt: telemetryIso(memberStart),
558
- endedAt: telemetryIso(memberEnd),
559
- durationMs: Math.max(0, memberEnd - memberStart),
560
- argsJson: telemetryArgsJson(member.parsed),
561
- result: r,
562
- batchIndex: index,
563
- batchSize: batch.length,
840
+ toolCallId: call?.id ?? "",
841
+ name,
842
+ startedAt: telemetryIso(toolStart),
843
+ endedAt: telemetryIso(toolEndRep),
844
+ durationMs: 0,
845
+ argsJson: telemetryArgsJson(parsed),
846
+ result: guarded,
847
+ batchIndex: 0,
848
+ batchSize: 1,
564
849
  });
565
- return r;
850
+ await commitToolResult(name, parsed, call, guarded, 0);
851
+ const stopBase = msg.content ?? "";
852
+ const stopNotice = `${stopBase}${stopBase ? "\n" : ""}${repetitionStopNotice(repSig, repNote.consecutive)}`;
853
+ history.push({ role: "assistant", content: stopNotice });
854
+ try {
855
+ opts?.onPhase?.("done");
856
+ }
857
+ catch {
858
+ // ignore
859
+ }
860
+ return stopNotice;
861
+ }
862
+ try {
863
+ result = await runOneTool(call, parsed, opts, execute);
566
864
  }
567
865
  catch (e) {
568
- const memberEnd = Date.now();
866
+ // A cancelled/throwing tool still records its attempt (with the
867
+ // cause) so the trace shows what was in flight — then the turn
868
+ // aborts exactly as before.
869
+ const toolEnd = Date.now();
569
870
  const cancelled = isCancelError(e) || signal?.aborted;
871
+ if (!cancelled) {
872
+ failures += 1;
873
+ noteBottleneck(name, Math.max(0, toolEnd - toolStart));
874
+ }
570
875
  reportToolCall({
571
876
  step,
572
- toolCallId: member.call?.id ?? "",
573
- name: member.call?.function?.name ?? "(unknown)",
574
- startedAt: telemetryIso(memberStart),
575
- endedAt: telemetryIso(memberEnd),
576
- durationMs: Math.max(0, memberEnd - memberStart),
577
- argsJson: telemetryArgsJson(member.parsed),
877
+ toolCallId: call?.id ?? "",
878
+ name,
879
+ startedAt: telemetryIso(toolStart),
880
+ endedAt: telemetryIso(toolEnd),
881
+ durationMs: Math.max(0, toolEnd - toolStart),
882
+ argsJson: telemetryArgsJson(parsed),
578
883
  result: e instanceof Error ? e.message : String(e),
579
884
  cancelled: cancelled ? true : undefined,
580
885
  threw: cancelled ? undefined : true,
581
- batchIndex: index,
582
- batchSize: batch.length,
886
+ batchIndex: 0,
887
+ batchSize: 1,
583
888
  });
889
+ if (cancelled)
890
+ throw new LoopCancelledError();
584
891
  throw e;
585
892
  }
586
- }));
587
- }
588
- catch (e) {
589
- if (isCancelError(e) || signal?.aborted)
590
- throw new LoopCancelledError();
591
- throw e;
592
- }
593
- for (let i = 0; i < batch.length; i++) {
594
- const member = batch[i];
595
- commitToolResult(member.call?.function?.name ?? "(unknown)", member.parsed, member.call, results[i]);
893
+ {
894
+ const toolEnd = Date.now();
895
+ reportToolCall({
896
+ step,
897
+ toolCallId: call?.id ?? "",
898
+ name,
899
+ startedAt: telemetryIso(toolStart),
900
+ endedAt: telemetryIso(toolEnd),
901
+ durationMs: Math.max(0, toolEnd - toolStart),
902
+ argsJson: telemetryArgsJson(parsed),
903
+ result,
904
+ batchIndex: 0,
905
+ batchSize: 1,
906
+ });
907
+ }
908
+ await commitToolResult(name, parsed, call, result, Math.max(0, Date.now() - toolStart));
909
+ continue;
910
+ }
911
+ // Parallel batch: every member is pre-validated parallel-safe (see
912
+ // planToolBatches). Approval-gated members (parallel writes) resolve
913
+ // their decisions serially in call order first, so prompts never run
914
+ // concurrently; denied members yield inline errors without executing.
915
+ // Phases fire upfront in call order; results commit in call order, so
916
+ // each call still shows separately and tool_call_ids re-pair by index.
917
+ // A throw (cancel or execution error) aborts the turn exactly like the
918
+ // serial path — the caller rolls the partial turn back.
919
+ for (const member of batch) {
920
+ try {
921
+ opts?.onPhase?.("tool", member.call?.function?.name ?? "(unknown)");
922
+ }
923
+ catch {
924
+ // ignore
925
+ }
926
+ }
927
+ let results;
928
+ const memberDurations = new Array(batch.length).fill(0);
929
+ // Repetition pre-notes (synchronous, in call order — deterministic):
930
+ // intervened members skip execution with a guidance error; exhausted
931
+ // nudges arm a hard stop after this batch commits (pairing stays valid).
932
+ const repNotes = batch.map((member) => repGuard.note(toolSignature(member.call?.function?.name ?? "(unknown)", member.parsed), member.call?.function?.name ?? "(unknown)"));
933
+ let repHardStop = null;
934
+ for (let i = 0; i < batch.length; i++) {
935
+ const note = repNotes[i];
936
+ if (note.intervened && !repGuard.consumeNudge() && !repHardStop) {
937
+ repHardStop = { sig: note.signature, consecutive: note.consecutive };
938
+ }
939
+ }
940
+ // Serial approval pre-pass (in call order, skipping repetition-guarded
941
+ // members exactly as the serial path would): prompts resolve before
942
+ // any member executes, so concurrent writes never prompt at once.
943
+ // Cancel between prompts aborts the batch with nothing executed.
944
+ const preDecisions = new Map();
945
+ for (let i = 0; i < batch.length; i++) {
946
+ throwIfCancelled(signal);
947
+ if (repNotes[i].intervened)
948
+ continue;
949
+ const member = batch[i];
950
+ const memberName = member.call?.function?.name ?? "(unknown)";
951
+ const decision = await resolveApproval(memberName, member.parsed, opts);
952
+ if (decision !== null)
953
+ preDecisions.set(i, decision);
954
+ }
955
+ try {
956
+ // Each member is timed individually (concurrent wall-clock per call,
957
+ // not the whole batch attributed to each) and reported in call order
958
+ // below. A throw still aborts the turn exactly like the serial path.
959
+ results = await Promise.all(batch.map(async (member, index) => {
960
+ const memberStart = Date.now();
961
+ const note = repNotes[index];
962
+ const memberName = member.call?.function?.name ?? "(unknown)";
963
+ if (note.intervened) {
964
+ const guarded = `Error: invalid call: ${repetitionFollowUp(note.signature, note.consecutive)} Fix the approach and retry.`;
965
+ const memberEnd = Date.now();
966
+ memberDurations[index] = 0;
967
+ reportToolCall({
968
+ step,
969
+ toolCallId: member.call?.id ?? "",
970
+ name: memberName,
971
+ startedAt: telemetryIso(memberStart),
972
+ endedAt: telemetryIso(memberEnd),
973
+ durationMs: 0,
974
+ argsJson: telemetryArgsJson(member.parsed),
975
+ result: guarded,
976
+ batchIndex: index,
977
+ batchSize: batch.length,
978
+ });
979
+ return guarded;
980
+ }
981
+ try {
982
+ const r = await runOneTool(member.call, member.parsed, opts, execute, preDecisions.get(index) ?? null);
983
+ const memberEnd = Date.now();
984
+ memberDurations[index] = Math.max(0, memberEnd - memberStart);
985
+ reportToolCall({
986
+ step,
987
+ toolCallId: member.call?.id ?? "",
988
+ name: member.call?.function?.name ?? "(unknown)",
989
+ startedAt: telemetryIso(memberStart),
990
+ endedAt: telemetryIso(memberEnd),
991
+ durationMs: Math.max(0, memberEnd - memberStart),
992
+ argsJson: telemetryArgsJson(member.parsed),
993
+ result: r,
994
+ batchIndex: index,
995
+ batchSize: batch.length,
996
+ });
997
+ return r;
998
+ }
999
+ catch (e) {
1000
+ const memberEnd = Date.now();
1001
+ const cancelled = isCancelError(e) || signal?.aborted;
1002
+ if (!cancelled) {
1003
+ failures += 1;
1004
+ noteBottleneck(member.call?.function?.name ?? "(unknown)", Math.max(0, memberEnd - memberStart));
1005
+ }
1006
+ reportToolCall({
1007
+ step,
1008
+ toolCallId: member.call?.id ?? "",
1009
+ name: member.call?.function?.name ?? "(unknown)",
1010
+ startedAt: telemetryIso(memberStart),
1011
+ endedAt: telemetryIso(memberEnd),
1012
+ durationMs: Math.max(0, memberEnd - memberStart),
1013
+ argsJson: telemetryArgsJson(member.parsed),
1014
+ result: e instanceof Error ? e.message : String(e),
1015
+ cancelled: cancelled ? true : undefined,
1016
+ threw: cancelled ? undefined : true,
1017
+ batchIndex: index,
1018
+ batchSize: batch.length,
1019
+ });
1020
+ throw e;
1021
+ }
1022
+ }));
1023
+ }
1024
+ catch (e) {
1025
+ if (isCancelError(e) || signal?.aborted)
1026
+ throw new LoopCancelledError();
1027
+ throw e;
1028
+ }
1029
+ for (let i = 0; i < batch.length; i++) {
1030
+ const member = batch[i];
1031
+ await commitToolResult(member.call?.function?.name ?? "(unknown)", member.parsed, member.call, results[i], memberDurations[i]);
1032
+ }
1033
+ if (repHardStop) {
1034
+ const stopBase = msg.content ?? "";
1035
+ const stopNotice = `${stopBase}${stopBase ? "\n" : ""}${repetitionStopNotice(repHardStop.sig, repHardStop.consecutive)}`;
1036
+ history.push({ role: "assistant", content: stopNotice });
1037
+ try {
1038
+ opts?.onPhase?.("done");
1039
+ }
1040
+ catch {
1041
+ // ignore
1042
+ }
1043
+ return stopNotice;
1044
+ }
596
1045
  }
597
1046
  }
598
1047
  }
1048
+ finally {
1049
+ finishStats();
1050
+ }
599
1051
  }