atom-agent 1.0.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.
@@ -13,14 +13,18 @@
13
13
  // serial loop did — the caller rolls the partial turn back, so
14
14
  // assistant/tool pairing stays valid.
15
15
  //
16
- // Dependency direction: agent/loop -> {tools, scheduler, config,
17
- // context-manager, agent/gates, agent/types} and NOT zen (transports stay in
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
18
19
  // zen.ts; runAgenticLoopForProvider wraps this loop from there).
19
20
  import { loadAtomConfig } from "../config.js";
20
- import { createContextManager, historyCharBudget, historyMessageBudget, truncateHistoryWithCaps, } from "../context-manager.js";
21
+ import { createContextManager, historyCharBudget, historyChars, historyMessageBudget, truncateHistoryWithCaps, } from "../context-manager.js";
21
22
  import { planBatches } from "../scheduler.js";
22
23
  import { describeToolCall, executeTool, invalidCall, MAX_TOOL_STEPS, needsApproval, toolNames, validateAskQuestionArgs, validateToolArgs, } from "../tools.js";
24
+ import { getReadCacheStats } from "../tools/read-cache.js";
23
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";
24
28
  // Whole-turn cancellation: thrown when the user cancels (Ctrl+C) mid-loop.
25
29
  // The App catches it, rolls the partial turn back (same splice contract as
26
30
  // POST failure), renders one dim `(cancelled)` line, and returns to a clean
@@ -71,6 +75,73 @@ export function toolStepBudget() {
71
75
  export function truncateHistory(history, notify, reserve) {
72
76
  return truncateHistoryWithCaps(history, { maxMessages: historyMessageBudget(), maxChars: historyCharBudget() }, { notify, reserve, todoNeedles: openTodoNeedles() });
73
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
+ }
74
145
  // Execute one parsed tool call through validation + permission +
75
146
  // ask_question gates. Model mistakes (unknown name, invalid args) return
76
147
  // repairs-oriented results WITHOUT executing; cancellations propagate as
@@ -125,8 +196,13 @@ async function runOneTool(call, parsed, opts, execute) {
125
196
  // result IS recorded — the loop then stops before the next tool/POST, so
126
197
  // assistant/tool pairing stays valid until the caller rolls back.
127
198
  throwIfCancelled(opts?.signal);
199
+ const timeoutMs = resolveToolTimeoutMs(opts?.toolTimeoutMs);
200
+ const doNormalize = opts?.normalizeResults !== false;
128
201
  try {
129
- return await execute(name, parsed);
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);
130
206
  }
131
207
  catch (e) {
132
208
  if (isCancelError(e) || opts?.signal?.aborted)
@@ -237,363 +313,596 @@ export async function runLoopWithChat(chatFn, history, opts) {
237
313
  const contextManager = opts?.context
238
314
  ? createContextManager({ model: opts.context.model, toolsChars: opts.context.toolsChars })
239
315
  : 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
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) };
251
346
  }
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();
347
+ };
348
+ const finishStats = () => {
279
349
  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.
350
+ let endChars = startChars;
317
351
  try {
318
- opts?.onUsage?.(msg.usage);
352
+ endChars = historyChars(history);
319
353
  }
320
354
  catch {
321
- // ignore
355
+ endChars = startChars;
322
356
  }
323
- }
324
- if (msg.reasoning !== undefined) {
357
+ let cacheHits = 0;
325
358
  try {
326
- opts?.onReasoning?.(msg.reasoning);
359
+ cacheHits = Math.max(0, getReadCacheStats().hits - cacheHitsStart);
327
360
  }
328
361
  catch {
329
- // ignore
362
+ cacheHits = 0;
330
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);
331
377
  }
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
- });
378
+ catch {
379
+ // observer errors never break the turn
348
380
  }
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 });
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.
373
389
  try {
374
- opts?.onPhase?.("done");
390
+ opts?.drainSteer?.();
375
391
  }
376
392
  catch {
377
- // ignore
393
+ // observer errors never break the loop
378
394
  }
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 });
385
- try {
386
- opts?.onPhase?.("done");
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;
387
421
  }
388
- catch {
389
- // ignore
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
+ });
390
435
  }
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);
408
- }
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;
409
453
  }
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;
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
+ }
426
468
  }
427
469
  }
428
- }
429
- history.push({ role: "tool", tool_call_id: call?.id ?? "", content: result });
430
- try {
431
- opts?.onToolActivity?.(describeToolCall(name, parsed), result, isError);
470
+ msg = norm.result;
432
471
  }
433
472
  catch {
434
- // ignore observer errors
473
+ // normalization never breaks the turn; the raw message stands
435
474
  }
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)";
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.
445
483
  try {
446
- opts?.onPhase?.("tool", name);
484
+ opts?.onUsage?.(msg.usage);
447
485
  }
448
486
  catch {
449
487
  // ignore
450
488
  }
451
- const toolStart = Date.now();
452
- let parsed;
489
+ }
490
+ if (msg.reasoning !== undefined) {
453
491
  try {
454
- const raw = call?.function?.arguments ?? "{}";
455
- const v = JSON.parse(typeof raw === "string" ? raw : "{}");
456
- parsed = typeof v === "object" && v !== null ? v : {};
492
+ opts?.onReasoning?.(msg.reasoning);
457
493
  }
458
494
  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);
464
- }
465
- catch {
466
- // ignore observer errors
467
- }
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
- });
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) });
481
547
  continue;
482
548
  }
483
- let result;
549
+ history.push({ role: "assistant", content: outcome.finalText });
484
550
  try {
485
- result = await runOneTool(call, parsed, opts, execute);
551
+ opts?.onPhase?.("done");
486
552
  }
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;
553
+ catch {
554
+ // ignore
510
555
  }
511
- {
512
- const toolEnd = Date.now();
513
- reportToolCall({
514
- step,
515
- toolCallId: call?.id ?? "",
516
- name,
517
- startedAt: telemetryIso(toolStart),
518
- endedAt: telemetryIso(toolEnd),
519
- durationMs: Math.max(0, toolEnd - toolStart),
520
- argsJson: telemetryArgsJson(parsed),
521
- result,
522
- batchIndex: 0,
523
- batchSize: 1,
524
- });
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");
525
564
  }
526
- commitToolResult(name, parsed, call, result);
527
- continue;
565
+ catch {
566
+ // ignore
567
+ }
568
+ return notice;
528
569
  }
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) {
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 });
536
577
  try {
537
- opts?.onPhase?.("tool", member.call?.function?.name ?? "(unknown)");
578
+ opts?.onPhase?.("done");
538
579
  }
539
580
  catch {
540
581
  // ignore
541
582
  }
583
+ return notice;
542
584
  }
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();
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;
550
651
  try {
551
- const r = await runOneTool(member.call, member.parsed, opts, execute);
552
- const memberEnd = Date.now();
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();
553
672
  reportToolCall({
554
673
  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,
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,
564
683
  });
565
- return r;
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);
566
738
  }
567
739
  catch (e) {
568
- const memberEnd = Date.now();
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();
569
744
  const cancelled = isCancelError(e) || signal?.aborted;
745
+ if (!cancelled) {
746
+ failures += 1;
747
+ noteBottleneck(name, Math.max(0, toolEnd - toolStart));
748
+ }
570
749
  reportToolCall({
571
750
  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),
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),
578
757
  result: e instanceof Error ? e.message : String(e),
579
758
  cancelled: cancelled ? true : undefined,
580
759
  threw: cancelled ? undefined : true,
581
- batchIndex: index,
582
- batchSize: batch.length,
760
+ batchIndex: 0,
761
+ batchSize: 1,
583
762
  });
763
+ if (cancelled)
764
+ throw new LoopCancelledError();
584
765
  throw e;
585
766
  }
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]);
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
+ }
596
902
  }
597
903
  }
598
904
  }
905
+ finally {
906
+ finishStats();
907
+ }
599
908
  }