atom-agent 1.1.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.
package/dist/adapters.js CHANGED
@@ -318,6 +318,24 @@ async function collectSSEText(res) {
318
318
  const body = res.body;
319
319
  const decoder = new TextDecoder();
320
320
  let rawText = "";
321
+ // Data-silence tracking (mirrors zen.readSSEMessage): queue comments and
322
+ // keep-alives carry bytes but no model output, so only chunks containing a
323
+ // `data:` line start refresh the clock. The tail window catches a marker
324
+ // split across chunk boundaries.
325
+ let lastDataAt = Date.now();
326
+ let tail = "";
327
+ const noteChunk = (chunkText) => {
328
+ const joined = tail + chunkText;
329
+ if (/(?:^|\n)data:/.test(joined))
330
+ lastDataAt = Date.now();
331
+ tail = joined.slice(-8);
332
+ };
333
+ const throwIfDataStalled = () => {
334
+ const budget = sseStallTimeoutMs();
335
+ if (Date.now() - lastDataAt > budget) {
336
+ throw new Error(`Truncated stream from model (stall: no output for ${budget}ms — queued or stalled upstream; resend to retry).`);
337
+ }
338
+ };
321
339
  if (body == null)
322
340
  return { rawText, events: [] };
323
341
  try {
@@ -346,10 +364,12 @@ async function collectSSEText(res) {
346
364
  if (chunk.done)
347
365
  break;
348
366
  const v = chunk.value;
349
- rawText +=
350
- typeof v === "string"
351
- ? v
352
- : decoder.decode(v, { stream: true });
367
+ const textPart = typeof v === "string"
368
+ ? v
369
+ : decoder.decode(v, { stream: true });
370
+ rawText += textPart;
371
+ noteChunk(textPart);
372
+ throwIfDataStalled();
353
373
  }
354
374
  }
355
375
  finally {
@@ -369,10 +389,12 @@ async function collectSSEText(res) {
369
389
  if (step.done)
370
390
  break;
371
391
  const v = step.value;
372
- rawText +=
373
- typeof v === "string"
374
- ? v
375
- : decoder.decode(v, { stream: true });
392
+ const textPart = typeof v === "string"
393
+ ? v
394
+ : decoder.decode(v, { stream: true });
395
+ rawText += textPart;
396
+ noteChunk(textPart);
397
+ throwIfDataStalled();
376
398
  }
377
399
  }
378
400
  finally {
@@ -19,6 +19,15 @@ export function todoCompletionGate(finalText, ctx) {
19
19
  finalText: `${finalText}${finalText ? "\n" : ""}(blocked: ${open.length} open todo(s) — resolve with todo_update/todowrite before ending the turn:\n${items})`,
20
20
  };
21
21
  }
22
+ // Guard cycles are bounded (like the verification gate): a model that
23
+ // keeps answering without resolving todos ends with a blocked statement
24
+ // instead of looping forever. Normal flows resolve within a round or two.
25
+ if ((ctx.todoRounds ?? 0) >= MAX_TODO_ROUNDS) {
26
+ return {
27
+ action: "end",
28
+ finalText: `${finalText}${finalText ? "\n" : ""}(blocked: ${open.length} open todo(s) remain after ${MAX_TODO_ROUNDS} guard rounds — resolve with todo_update/todowrite before ending the turn:\n${items})`,
29
+ };
30
+ }
22
31
  return {
23
32
  action: "continue",
24
33
  assistantText: finalText,
@@ -30,11 +39,15 @@ export function todoCompletionGate(finalText, ctx) {
30
39
  // report — the system prompt forbids unverified finishes, so the runtime
31
40
  // must not terminate while just labeling): the attempt is recorded and a
32
41
  // verification follow-up re-enters the loop, exactly like the todo guard.
33
- // Two bounded exits: spent step budget, or MAX_VERIFY_ROUNDS nag cycles
42
+ // Two bounded exits: an explicit step budget spent, or MAX_VERIFY_ROUNDS nag
34
43
  // without a passing run — both end with an explicit labeled statement naming
35
44
  // what is unverified and why the loop stopped. Turns with no code writes
36
45
  // (questions, docs, explanations, read-only work) are unaffected.
37
46
  export const MAX_VERIFY_ROUNDS = 3;
47
+ // Todo-guard continues before the turn ends blocked: a model that keeps
48
+ // answering final text without resolving open todos is sent back at most
49
+ // this many times. Mirrors MAX_VERIFY_ROUNDS so no gate can spin forever.
50
+ export const MAX_TODO_ROUNDS = 3;
38
51
  // Source-code extensions whose writes require a passing verification run.
39
52
  // Curated heuristic boundary (not a parser): docs, configs, data, and
40
53
  // extensionless files never arm the gate, so a README edit finishes clean.
@@ -1,20 +1,18 @@
1
1
  // Loop-guard: repetition/runaway detection + error-streak recovery for the
2
2
  // agentic loop. Pure state machines, no I/O, never throw.
3
3
  //
4
- // Why this exists: maxSteps (30 tool rounds) is the ultimate backstop, but
5
- // a model stuck calling `read <same path>` 30 times burns 30 POSTs before it
6
- // trips. The guard spots the pattern early (consecutive identical signatures)
7
- // and the loop nudges the model toward a different approach with a bounded
8
- // follow-up — then stops hard if the pattern survives the nudges. Error
9
- // streaks get the same treatment: ending on 3+ unaddressed `Error:` results
10
- // is almost always premature, so the loop asks for a fix-forward attempt
11
- // before accepting final text.
4
+ // Why this exists: turns are uncapped by default, so a model stuck calling
5
+ // `read <same path>` forever burns POSTs without end. The guard spots the
6
+ // pattern early (consecutive identical signatures) and the loop nudges the
7
+ // model toward a different approach with a bounded follow-up — then stops
8
+ // hard if the pattern survives the nudges. Error streaks get the same
9
+ // treatment: ending on 3+ unaddressed `Error:` results is almost always
10
+ // premature, so the loop asks for a fix-forward attempt before accepting
11
+ // final text.
12
12
  //
13
- // Defaults preserve the pinned maxSteps contract: repetition intervention is
14
- // OPT-IN (maxRepeatedCalls set by the caller; unset = track-only for stats),
15
- // because the existing suites pin "always same call 31 POSTs stopped
16
- // notice". Error-streak recovery defaults to 3 (single errors still end
17
- // normally — the model may be reporting a blocker).
13
+ // Repetition intervention is OPT-IN (maxRepeatedCalls set by the caller;
14
+ // unset = track-only for stats). Error-streak recovery defaults to 3
15
+ // (single errors still end normally the model may be reporting a blocker).
18
16
  //
19
17
  // All thresholds clamp to sane minima; every method is safe to call with any
20
18
  // input.
@@ -9,9 +9,12 @@
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
19
  // Dependency direction: agent/loop -> {tools, tools/read-cache,
17
20
  // scheduler, config, context-manager, agent/gates, agent/loop-guard,
@@ -20,7 +23,7 @@
20
23
  import { loadAtomConfig } from "../config.js";
21
24
  import { createContextManager, historyCharBudget, historyChars, historyMessageBudget, truncateHistoryWithCaps, } from "../context-manager.js";
22
25
  import { planBatches } from "../scheduler.js";
23
- 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";
24
27
  import { getReadCacheStats } from "../tools/read-cache.js";
25
28
  import { bashExitCode, evaluateTurnEnd, isCodePath, isVerificationCommand, openTodoNeedles, } from "./gates.js";
26
29
  import { errorStreakFollowUp, ErrorStreakTracker, repetitionFollowUp, RepetitionGuard, repetitionStopNotice, } from "./loop-guard.js";
@@ -53,9 +56,10 @@ export function throwIfCancelled(signal) {
53
56
  if (signal?.aborted)
54
57
  throw new LoopCancelledError();
55
58
  }
56
- // Tool-round budget for one agentic turn (env → atom.json → 30).
57
- // A real explore implement verify task needs 15–30 tool rounds, so the
58
- // default is 30; an explicit `opts.maxSteps` still wins (tests inject it).
59
+ // 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).
59
63
  export function toolStepBudget() {
60
64
  const raw = process.env.ATOM_MAX_TOOL_STEPS;
61
65
  if (raw !== undefined) {
@@ -66,7 +70,7 @@ export function toolStepBudget() {
66
70
  return Math.min(Math.max(Math.floor(n), 5), 100);
67
71
  }
68
72
  }
69
- return loadAtomConfig().config.maxToolSteps ?? MAX_TOOL_STEPS;
73
+ return loadAtomConfig().config.maxToolSteps ?? Number.POSITIVE_INFINITY;
70
74
  }
71
75
  // Legacy trim entry: byte-identical contract (legacy env/config/default caps
72
76
  // + live todo pinning, same notice, same in-place splice). New code should
@@ -75,6 +79,24 @@ export function toolStepBudget() {
75
79
  export function truncateHistory(history, notify, reserve) {
76
80
  return truncateHistoryWithCaps(history, { maxMessages: historyMessageBudget(), maxChars: historyCharBudget() }, { notify, reserve, todoNeedles: openTodoNeedles() });
77
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
+ }
78
100
  // Per-tool outer timeout (ms): undefined → default 60s (enabled); explicit
79
101
  // <=0/NaN → disabled (direct await, zero overhead). Clamped 1s–120s when
80
102
  // enabled so a stuck executor can never hang the turn past the bash ceiling.
@@ -88,12 +110,13 @@ export function resolveToolTimeoutMs(raw) {
88
110
  return null;
89
111
  return Math.min(Math.max(Math.floor(raw), 1000), 120_000);
90
112
  }
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;
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;
94
117
  export function resolveMaxTotalToolCalls(raw) {
95
118
  if (typeof raw !== "number" || !Number.isFinite(raw))
96
- return DEFAULT_MAX_TOTAL_TOOL_CALLS;
119
+ return Number.POSITIVE_INFINITY;
97
120
  return Math.max(1, Math.floor(raw));
98
121
  }
99
122
  // Race one execution against the outer timeout. Timeout resolves to an
@@ -142,6 +165,27 @@ export async function executeWithTimeout(execute, name, parsed, timeoutMs, signa
142
165
  clearTimeout(timer);
143
166
  }
144
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
+ }
145
189
  // Execute one parsed tool call through validation + permission +
146
190
  // ask_question gates. Model mistakes (unknown name, invalid args) return
147
191
  // repairs-oriented results WITHOUT executing; cancellations propagate as
@@ -149,10 +193,11 @@ export async function executeWithTimeout(execute, name, parsed, timeoutMs, signa
149
193
  // returns a result string fed back to the model:
150
194
  // - ask_question never needs approval; without an askUser hook it resolves
151
195
  // to "Error: ask_question has no UI hook".
152
- // - write/edit/bash consult the approve hook when one is provided; a "no"
153
- // resolves to "Error: denied by user: <tool>" (final, no retry/rollback).
154
- // Without a hook every tool executes immediately.
155
- async function runOneTool(call, parsed, opts, execute) {
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) {
156
201
  const name = call?.function?.name ?? "(unknown)";
157
202
  // Unknown tool: model mistake — list actual names, never execute.
158
203
  if (!toolNames().includes(name)) {
@@ -171,26 +216,12 @@ async function runOneTool(call, parsed, opts, execute) {
171
216
  // the next POST (no new POSTs, pairing stays valid until rollback).
172
217
  return runAskQuestion(parsed, opts?.askUser, opts?.signal);
173
218
  }
174
- if (opts?.approve && needsApproval(name)) {
175
- let decision;
176
- try {
177
- decision = await opts.approve(name, parsed);
178
- }
179
- catch (e) {
180
- // Whole-turn cancellation must propagate (Ctrl+C cancels the turn,
181
- // not just deny one call). Anything else is a denial.
182
- if (isCancelError(e) || opts?.signal?.aborted)
183
- throw new LoopCancelledError();
184
- decision = "no";
185
- }
186
- // Abort that lands as a resolved denial still cancels the whole turn.
187
- throwIfCancelled(opts?.signal);
188
- if (decision === "no") {
189
- return `Error: denied by user: ${name}`;
190
- }
191
- // "once" runs this call; "always" runs it too (the caller caches the
192
- // always-allowed set session-wide so later calls skip the prompt).
219
+ const decision = preDecision ?? (await resolveApproval(name, parsed, opts));
220
+ if (decision === "no") {
221
+ return `Error: denied by user: ${name}`;
193
222
  }
223
+ // "once"/"always" run this call (the caller caches the always-allowed set
224
+ // session-wide so later calls skip the prompt).
194
225
  // No new executions after a cancel: stop after the current tool finishes.
195
226
  // The current tool (if already running) is awaited to completion and its
196
227
  // result IS recorded — the loop then stops before the next tool/POST, so
@@ -210,6 +241,35 @@ async function runOneTool(call, parsed, opts, execute) {
210
241
  throw e;
211
242
  }
212
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
+ }
213
273
  async function runAskQuestion(parsed, askUser, signal) {
214
274
  const invalid = validateAskQuestionArgs(parsed);
215
275
  if (invalid)
@@ -302,9 +362,12 @@ export async function runLoopWithChat(chatFn, history, opts) {
302
362
  let verifiedAfterWrite = false;
303
363
  let needsVerification = false;
304
364
  let unverifiedPaths = [];
305
- // Verification-gate nag cycles spent (bounds the continue loop alongside
306
- // 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).
307
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;
308
371
  // At most one truncation notice per turn; silence when nothing dropped.
309
372
  let truncationNoticed = false;
310
373
  // Window-aware trimmer when the caller knows the model (App passes it);
@@ -313,8 +376,8 @@ export async function runLoopWithChat(chatFn, history, opts) {
313
376
  const contextManager = opts?.context
314
377
  ? createContextManager({ model: opts.context.model, toolsChars: opts.context.toolsChars })
315
378
  : null;
316
- // ---- Hardened-loop state (additive; defaults preserve the pinned
317
- // maxSteps contract — see AgenticOpts docs) ----
379
+ // ---- Hardened-loop state (additive; explicit caps still honored —
380
+ // see AgenticOpts docs) ----
318
381
  const maxTotalToolCalls = resolveMaxTotalToolCalls(opts?.maxTotalToolCalls);
319
382
  const repGuard = new RepetitionGuard({ maxRepeatedCalls: opts?.maxRepeatedCalls });
320
383
  const errStreak = new ErrorStreakTracker(opts?.maxConsecutiveErrors);
@@ -337,6 +400,9 @@ export async function runLoopWithChat(chatFn, history, opts) {
337
400
  let toolCalls = 0;
338
401
  let failures = 0;
339
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;
340
406
  let bottleneck = null;
341
407
  const noteBottleneck = (name, durationMs) => {
342
408
  if (!Number.isFinite(durationMs) || durationMs < 0)
@@ -449,6 +515,16 @@ export async function runLoopWithChat(chatFn, history, opts) {
449
515
  });
450
516
  if (isCancelError(e) || signal?.aborted)
451
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
+ }
452
528
  throw e;
453
529
  }
454
530
  throwIfCancelled(signal);
@@ -525,12 +601,15 @@ export async function runLoopWithChat(chatFn, history, opts) {
525
601
  needsVerification,
526
602
  unverifiedPaths: [...unverifiedPaths],
527
603
  verifyRounds,
604
+ todoRounds,
528
605
  });
529
606
  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.
607
+ // Guard continues are bounded per turn so a model that never
608
+ // verifies or never resolves todos still terminates.
532
609
  if (outcome.via === "verification")
533
610
  verifyRounds += 1;
611
+ else if (outcome.via === "todoCompletionGate")
612
+ todoRounds += 1;
534
613
  history.push({ role: "assistant", content: outcome.assistantText });
535
614
  history.push({ role: "user", content: outcome.followUp });
536
615
  continue;
@@ -567,9 +646,9 @@ export async function runLoopWithChat(chatFn, history, opts) {
567
646
  }
568
647
  return notice;
569
648
  }
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).
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.
573
652
  if (toolCalls + calls.length > maxTotalToolCalls) {
574
653
  const base = msg.content ?? "";
575
654
  const notice = `${base}${base ? "\n" : ""}(stopped: too many tool calls) (limit is ${maxTotalToolCalls} per turn)`;
@@ -587,8 +666,15 @@ export async function runLoopWithChat(chatFn, history, opts) {
587
666
  // bookkeeping + one ordered transcript entry per call. Only successful
588
667
  // executions count — denials, validation errors, and unknown tools (all
589
668
  // `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");
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;
592
678
  toolCalls += 1;
593
679
  if (isError)
594
680
  failures += 1;
@@ -608,7 +694,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
608
694
  else if (!isError && name === "bash") {
609
695
  const command = parsed["command"];
610
696
  if (typeof command === "string" && isVerificationCommand(command) && filesWritten) {
611
- const exit = bashExitCode(result);
697
+ const exit = bashExitCode(finalResult);
612
698
  if (exit === null || exit === 0) {
613
699
  // Passing check (or a legacy runner that reports no envelope):
614
700
  // clears everything the gate tracks.
@@ -624,14 +710,59 @@ export async function runLoopWithChat(chatFn, history, opts) {
624
710
  }
625
711
  }
626
712
  }
627
- history.push({ role: "tool", tool_call_id: call?.id ?? "", content: result });
713
+ history.push({ role: "tool", tool_call_id: call?.id ?? "", content: finalResult });
628
714
  try {
629
- opts?.onToolActivity?.(describeToolCall(name, parsed), result, isError);
715
+ opts?.onToolActivity?.(describeToolCall(name, parsed), finalResult, isError);
630
716
  }
631
717
  catch {
632
718
  // ignore observer errors
633
719
  }
720
+ return true;
634
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();
738
+ reportToolCall({
739
+ step,
740
+ toolCallId: call?.id ?? "",
741
+ name,
742
+ startedAt: telemetryIso(toolAt),
743
+ endedAt: telemetryIso(toolAt),
744
+ durationMs: 0,
745
+ argsJson: telemetryArgsJson(call?.function?.arguments ?? "{}"),
746
+ result,
747
+ batchIndex: i,
748
+ batchSize: calls.length,
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);
763
+ }
764
+ continue;
765
+ }
635
766
  for (const batch of planBatches(calls)) {
636
767
  // No new executions after a cancel: the current tool (if any) already
637
768
  // finished; stop before starting the next batch.
@@ -656,18 +787,12 @@ export async function runLoopWithChat(chatFn, history, opts) {
656
787
  catch {
657
788
  parsed = {};
658
789
  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);
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.
662
795
  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
796
  const toolEnd = Date.now();
672
797
  reportToolCall({
673
798
  step,
@@ -681,12 +806,13 @@ export async function runLoopWithChat(chatFn, history, opts) {
681
806
  batchIndex: 0,
682
807
  batchSize: 1,
683
808
  });
809
+ await commitToolResult(name, parsed, call, result, Math.max(0, toolEnd - toolStart));
684
810
  continue;
685
811
  }
686
812
  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.
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.
690
816
  const repSig = toolSignature(name, parsed);
691
817
  const repNote = repGuard.note(repSig, name);
692
818
  if (repNote.intervened) {
@@ -705,7 +831,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
705
831
  batchIndex: 0,
706
832
  batchSize: 1,
707
833
  });
708
- commitToolResult(name, parsed, call, guarded, 0);
834
+ await commitToolResult(name, parsed, call, guarded, 0);
709
835
  continue;
710
836
  }
711
837
  const guarded = `Error: invalid call: ${repetitionFollowUp(repSig, repNote.consecutive)} Fix the approach and retry.`;
@@ -721,7 +847,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
721
847
  batchIndex: 0,
722
848
  batchSize: 1,
723
849
  });
724
- commitToolResult(name, parsed, call, guarded, 0);
850
+ await commitToolResult(name, parsed, call, guarded, 0);
725
851
  const stopBase = msg.content ?? "";
726
852
  const stopNotice = `${stopBase}${stopBase ? "\n" : ""}${repetitionStopNotice(repSig, repNote.consecutive)}`;
727
853
  history.push({ role: "assistant", content: stopNotice });
@@ -779,11 +905,13 @@ export async function runLoopWithChat(chatFn, history, opts) {
779
905
  batchSize: 1,
780
906
  });
781
907
  }
782
- commitToolResult(name, parsed, call, result, Math.max(0, Date.now() - toolStart));
908
+ await commitToolResult(name, parsed, call, result, Math.max(0, Date.now() - toolStart));
783
909
  continue;
784
910
  }
785
911
  // Parallel batch: every member is pre-validated parallel-safe (see
786
- // planToolBatches), so runOneTool neither prompts nor blocks here.
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.
787
915
  // Phases fire upfront in call order; results commit in call order, so
788
916
  // each call still shows separately and tool_call_ids re-pair by index.
789
917
  // A throw (cancel or execution error) aborts the turn exactly like the
@@ -809,6 +937,21 @@ export async function runLoopWithChat(chatFn, history, opts) {
809
937
  repHardStop = { sig: note.signature, consecutive: note.consecutive };
810
938
  }
811
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
+ }
812
955
  try {
813
956
  // Each member is timed individually (concurrent wall-clock per call,
814
957
  // not the whole batch attributed to each) and reported in call order
@@ -836,7 +979,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
836
979
  return guarded;
837
980
  }
838
981
  try {
839
- const r = await runOneTool(member.call, member.parsed, opts, execute);
982
+ const r = await runOneTool(member.call, member.parsed, opts, execute, preDecisions.get(index) ?? null);
840
983
  const memberEnd = Date.now();
841
984
  memberDurations[index] = Math.max(0, memberEnd - memberStart);
842
985
  reportToolCall({
@@ -885,7 +1028,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
885
1028
  }
886
1029
  for (let i = 0; i < batch.length; i++) {
887
1030
  const member = batch[i];
888
- commitToolResult(member.call?.function?.name ?? "(unknown)", member.parsed, member.call, results[i], memberDurations[i]);
1031
+ await commitToolResult(member.call?.function?.name ?? "(unknown)", member.parsed, member.call, results[i], memberDurations[i]);
889
1032
  }
890
1033
  if (repHardStop) {
891
1034
  const stopBase = msg.content ?? "";
@@ -1,3 +1,4 @@
1
+ import { truncateHead } from "../tools/shared.js";
1
2
  // Safety net for custom executors (built-in tools already cap: read 64KB
2
3
  // head + truncation note + overflow pointer ≈ 66KB, bash 8KB, webfetch 64KB
3
4
  // + notes). The cap sits at 128KB so legitimate built-in outputs (overflow
@@ -27,8 +28,8 @@ export function normalizeToolResult(result) {
27
28
  }
28
29
  }
29
30
  if (text.length > TOOL_RESULT_CAP_CHARS) {
30
- return (text.slice(0, TOOL_RESULT_CAP_CHARS) +
31
- `\n[truncated: tool result exceeded ${TOOL_RESULT_CAP_CHARS} chars]`);
31
+ const t = truncateHead(text, TOOL_RESULT_CAP_CHARS, `\n[truncated: tool result exceeded ${TOOL_RESULT_CAP_CHARS} chars]`);
32
+ return t.head + t.note;
32
33
  }
33
34
  return text;
34
35
  }
@@ -92,6 +93,10 @@ export function normalizeChatResult(raw) {
92
93
  result["usage"] = m["usage"];
93
94
  if (m["reasoning"] !== undefined)
94
95
  result["reasoning"] = m["reasoning"];
96
+ // Length-truncation flag survives normalization (no calls or not — the
97
+ // loop decides; truncated-without-calls behaves as before).
98
+ if (m["truncated"] === true)
99
+ result.truncated = true;
95
100
  return { result: result, warnings };
96
101
  }
97
102
  if (!Array.isArray(callsRaw)) {
@@ -140,5 +145,7 @@ export function normalizeChatResult(raw) {
140
145
  out["usage"] = m["usage"];
141
146
  if (m["reasoning"] !== undefined)
142
147
  out["reasoning"] = m["reasoning"];
148
+ if (m["truncated"] === true)
149
+ out.truncated = true;
143
150
  return { result: out, warnings };
144
151
  }