atom-agent 1.1.0 → 1.3.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 (74) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/README.md +18 -8
  3. package/atom.example.json +11 -0
  4. package/dist/App.js +1637 -255
  5. package/dist/adapters.js +112 -21
  6. package/dist/agent/gates.js +14 -1
  7. package/dist/agent/goal-evaluator.js +69 -0
  8. package/dist/agent/loop-guard.js +11 -13
  9. package/dist/agent/loop.js +716 -132
  10. package/dist/agent/normalize.js +9 -2
  11. package/dist/cli.js +25 -3
  12. package/dist/compact.js +169 -17
  13. package/dist/config.js +43 -7
  14. package/dist/context-manager.js +16 -198
  15. package/dist/context-windows.js +4 -2
  16. package/dist/env-block.js +46 -8
  17. package/dist/extension-commands.js +196 -0
  18. package/dist/extension-ui.js +153 -0
  19. package/dist/extensions.js +1571 -0
  20. package/dist/goal.js +583 -0
  21. package/dist/project-trust.js +96 -0
  22. package/dist/providers.js +6 -6
  23. package/dist/scheduler.js +159 -41
  24. package/dist/session.js +23 -5
  25. package/dist/sessions.js +543 -0
  26. package/dist/system.js +89 -13
  27. package/dist/telemetry-dashboard.js +28 -0
  28. package/dist/telemetry.js +39 -0
  29. package/dist/tools/compaction-hooks.js +165 -0
  30. package/dist/tools/custom.js +189 -0
  31. package/dist/tools/dir-cache.js +7 -0
  32. package/dist/tools/filesystem.js +3 -2
  33. package/dist/tools/intercept.js +145 -0
  34. package/dist/tools/overrides.js +105 -0
  35. package/dist/tools/provider-hooks.js +224 -0
  36. package/dist/tools/registry.js +247 -17
  37. package/dist/tools/ripgrep.js +256 -0
  38. package/dist/tools/search.js +119 -58
  39. package/dist/tools/shared.js +39 -0
  40. package/dist/tools/shell.js +7 -5
  41. package/dist/tools/web.js +6 -6
  42. package/dist/tools.js +45 -0
  43. package/dist/ui/diff-view.js +7 -2
  44. package/dist/ui/live-host.js +18 -0
  45. package/dist/ui/live-tail.js +9 -3
  46. package/dist/ui/markdown.js +26 -2
  47. package/dist/ui/palette.js +3 -1
  48. package/dist/ui/side-by-side.js +2 -2
  49. package/dist/ui/status-bar.js +80 -5
  50. package/dist/ui/status-host.js +22 -0
  51. package/dist/ui/stream-store.js +48 -0
  52. package/dist/ui/tool-inspector.js +7 -1
  53. package/dist/ui/transcript.js +92 -38
  54. package/dist/zen.js +370 -87
  55. package/documentation/architecture.md +114 -0
  56. package/documentation/cli.md +82 -0
  57. package/documentation/compaction.md +50 -0
  58. package/documentation/configuration.md +111 -0
  59. package/documentation/development.md +62 -0
  60. package/documentation/extensions.md +160 -0
  61. package/documentation/getting-started.md +63 -0
  62. package/documentation/goals.md +41 -0
  63. package/documentation/index.md +41 -0
  64. package/documentation/observability.md +70 -0
  65. package/documentation/permissions.md +66 -0
  66. package/documentation/providers.md +78 -0
  67. package/documentation/sessions.md +92 -0
  68. package/documentation/skills.md +57 -0
  69. package/documentation/tools.md +94 -0
  70. package/documentation/troubleshooting.md +54 -0
  71. package/examples/extensions/01-audit-gate.js +24 -0
  72. package/examples/extensions/02-notes-tool.js +32 -0
  73. package/examples/extensions/03-custom-command.js +32 -0
  74. package/package.json +6 -2
@@ -9,20 +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
19
  // Dependency direction: agent/loop -> {tools, tools/read-cache,
17
20
  // scheduler, config, context-manager, agent/gates, agent/loop-guard,
18
21
  // agent/normalize, agent/types} and NOT zen (transports stay in
19
22
  // zen.ts; runAgenticLoopForProvider wraps this loop from there).
20
23
  import { loadAtomConfig } from "../config.js";
21
- import { createContextManager, historyCharBudget, historyChars, historyMessageBudget, truncateHistoryWithCaps, } from "../context-manager.js";
24
+ import { historyChars } 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";
27
+ import { afterToolInterceptors, applyAfterInterceptors, applyBeforeInterceptors, beforeToolInterceptors, blockedToolResult, } from "../tools/intercept.js";
24
28
  import { getReadCacheStats } from "../tools/read-cache.js";
25
- import { bashExitCode, evaluateTurnEnd, isCodePath, isVerificationCommand, openTodoNeedles, } from "./gates.js";
29
+ import { emptyGoalProgress, GOAL_STALL_REPEATS, goalFollowUp, goalPausedNotice, goalReportAck, goalReportOutsideError, goalReportRejectedNotice, goalStallNudge, goalStallReached, goalVerdictNotice, noteGoalProgress, recentTurnsForJudge, resetGoalStall, sameGoalDisposition, updateGoalDisposition, validateUpdateGoalArgs, } from "../goal.js";
30
+ import { bashExitCode, evaluateTurnEnd, isCodePath, isVerificationCommand, todoCompletionGate, verificationGate, } from "./gates.js";
26
31
  import { errorStreakFollowUp, ErrorStreakTracker, repetitionFollowUp, RepetitionGuard, repetitionStopNotice, } from "./loop-guard.js";
27
32
  import { normalizeChatResult, normalizeToolResult, toolSignature } from "./normalize.js";
28
33
  // Whole-turn cancellation: thrown when the user cancels (Ctrl+C) mid-loop.
@@ -53,9 +58,10 @@ export function throwIfCancelled(signal) {
53
58
  if (signal?.aborted)
54
59
  throw new LoopCancelledError();
55
60
  }
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).
61
+ // Tool-round budget for one agentic turn (env → atom.json → unlimited).
62
+ // No default cap: the turn runs until the model ends it, a gate stops it,
63
+ // or the user cancels. An explicit `opts.maxSteps` (or env/file value) still
64
+ // caps the turn (tests inject it).
59
65
  export function toolStepBudget() {
60
66
  const raw = process.env.ATOM_MAX_TOOL_STEPS;
61
67
  if (raw !== undefined) {
@@ -66,14 +72,25 @@ export function toolStepBudget() {
66
72
  return Math.min(Math.max(Math.floor(n), 5), 100);
67
73
  }
68
74
  }
69
- return loadAtomConfig().config.maxToolSteps ?? MAX_TOOL_STEPS;
75
+ return loadAtomConfig().config.maxToolSteps ?? Number.POSITIVE_INFINITY;
70
76
  }
71
- // Legacy trim entry: byte-identical contract (legacy env/config/default caps
72
- // + live todo pinning, same notice, same in-place splice). New code should
73
- // use a ContextManager (derived, window-aware caps); the loop core does when
74
- // it knows the model (see AgenticOpts.context).
75
- export function truncateHistory(history, notify, reserve) {
76
- return truncateHistoryWithCaps(history, { maxMessages: historyMessageBudget(), maxChars: historyCharBudget() }, { notify, reserve, todoNeedles: openTodoNeedles() });
77
+ // Empty-response recovery (live-proven on free-tier gateways: a 200-OK
78
+ // stream can carry only queue comments and reasoning with zero answer text
79
+ // and zero tool calls, which the transport reports as an `Empty reply`
80
+ // error). A failed POST normally aborts the turn EXCEPT this one: ending
81
+ // the turn on model silence with no fallback makes flaky backends fatal, so
82
+ // the loop spends a bounded number of extra POSTs asking the model to repair
83
+ // (same assistant+user follow-up shape as the turn-end gates, so pairing
84
+ // stays valid). When the budget is spent the original error throws, exactly
85
+ // as before — the caller rolls back and the user sees it.
86
+ export const MAX_EMPTY_ROUNDS = 2;
87
+ export function isEmptyReplyError(e) {
88
+ return e instanceof Error && e.message.startsWith("Empty reply");
89
+ }
90
+ export function emptyResponseFollowUp(attempt) {
91
+ return (`(empty response: attempt ${attempt} returned no text and no tool calls — ` +
92
+ `the turn cannot end on silence. Continue with tool calls toward the goal, ` +
93
+ `or answer in text. If there is genuinely nothing to do, end by saying so explicitly.)`);
77
94
  }
78
95
  // Per-tool outer timeout (ms): undefined → default 60s (enabled); explicit
79
96
  // <=0/NaN → disabled (direct await, zero overhead). Clamped 1s–120s when
@@ -88,12 +105,13 @@ export function resolveToolTimeoutMs(raw) {
88
105
  return null;
89
106
  return Math.min(Math.max(Math.floor(raw), 1000), 120_000);
90
107
  }
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;
108
+ // Total tool-call budget per turn (no default cap; explicit opts value only,
109
+ // min 1). Previously defaulted to 200 as a parallel-batch explosion guard.
110
+ // Deprecated alias kept for import compatibility; the loop no longer uses it.
111
+ export const DEFAULT_MAX_TOTAL_TOOL_CALLS = Number.POSITIVE_INFINITY;
94
112
  export function resolveMaxTotalToolCalls(raw) {
95
113
  if (typeof raw !== "number" || !Number.isFinite(raw))
96
- return DEFAULT_MAX_TOTAL_TOOL_CALLS;
114
+ return Number.POSITIVE_INFINITY;
97
115
  return Math.max(1, Math.floor(raw));
98
116
  }
99
117
  // Race one execution against the outer timeout. Timeout resolves to an
@@ -142,26 +160,100 @@ export async function executeWithTimeout(execute, name, parsed, timeoutMs, signa
142
160
  clearTimeout(timer);
143
161
  }
144
162
  }
145
- // Execute one parsed tool call through validation + permission +
146
- // ask_question gates. Model mistakes (unknown name, invalid args) return
147
- // repairs-oriented results WITHOUT executing; cancellations propagate as
148
- // LoopCancelledError (never a result, never retried). Everything else
149
- // returns a result string fed back to the model:
163
+ // Permission-gate resolution shared by the serial path and the parallel
164
+ // pre-pass: returns the hook's decision, or null when no approval applies
165
+ // (no hook, or a tool that never needs it). A "no" (or a throwing hook)
166
+ // denies without executing; cancellation always propagates.
167
+ async function resolveApproval(name, parsed, opts) {
168
+ if (!opts?.approve || !needsApproval(name))
169
+ return null;
170
+ try {
171
+ const decision = await opts.approve(name, parsed);
172
+ // Abort that lands as a resolved denial still cancels the whole turn.
173
+ throwIfCancelled(opts?.signal);
174
+ return decision;
175
+ }
176
+ catch (e) {
177
+ // Whole-turn cancellation must propagate (Ctrl+C cancels the turn,
178
+ // not just deny one call). Anything else is a denial.
179
+ if (isCancelError(e) || opts?.signal?.aborted)
180
+ throw new LoopCancelledError();
181
+ return "no";
182
+ }
183
+ }
184
+ // Extension tool-call interception (ticket 03): global pre/post hooks
185
+ // registered via ExtensionAPI.onBeforeToolCall/onAfterToolCall. Both
186
+ // helpers snapshot the live handler list and never throw — a throwing
187
+ // before handler fails closed (blocked outcome), a throwing after handler
188
+ // fails open (original content). Cancellation checks stay where they are;
189
+ // hooks are in-process policy, not executions, so they run even when the
190
+ // signal is armed and the existing boundaries still stop the turn.
191
+ async function runBeforeIntercept(name, parsed) {
192
+ try {
193
+ return await applyBeforeInterceptors(beforeToolInterceptors(), name, parsed);
194
+ }
195
+ catch {
196
+ return { args: parsed, blocked: blockedToolResult(name, "(unknown)", "interception failed") };
197
+ }
198
+ }
199
+ async function runAfterIntercept(name, parsed, result, isError) {
200
+ try {
201
+ return await applyAfterInterceptors(afterToolInterceptors(), { name, args: parsed, result, isError });
202
+ }
203
+ catch {
204
+ return { content: result, isError };
205
+ }
206
+ }
207
+ // Execute one parsed tool call through interception + validation +
208
+ // permission + ask_question gates. Model mistakes (unknown name, invalid
209
+ // args) return repairs-oriented results WITHOUT executing; cancellations
210
+ // propagate as LoopCancelledError (never a result, never retried).
211
+ // Everything else returns the result string plus the effective (post-
212
+ // rewrite) args the commit funnel must use for gates, labels, and pairing:
213
+ // - Hook-vs-approval ordering: pre-hooks run BEFORE validation and
214
+ // approval. The hook sees the call pre-approval and may rewrite args
215
+ // before the approval prompt shows them; a block short-circuits approval
216
+ // entirely (no prompt for a call that never runs). Rewrites always
217
+ // re-validate before execution, so a hook can never smuggle unvalidated
218
+ // args into an executor.
219
+ // - Unknown names never reach hooks (model mistake — nothing would run).
220
+ // update_goal is the one exemption (ticket 03): a loop-intercepted tool
221
+ // like ask_question, resolved without an executor via onUpdateGoal.
150
222
  // - ask_question never needs approval; without an askUser hook it resolves
151
223
  // 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) {
224
+ // - update_goal never needs approval either; without the per-turn recorder
225
+ // (only runLoopWithChat supplies it) it resolves to the outside-turn error.
226
+ // - write/edit/bash consult the approve hook when one is provided (or a
227
+ // pre-resolved batch decision); a "no" resolves to
228
+ // "Error: denied by user: <tool>" (final, no retry/rollback). Without a
229
+ // hook every tool executes immediately.
230
+ async function runOneTool(call, parsed, opts, execute, preDecision, onUpdateGoal) {
156
231
  const name = call?.function?.name ?? "(unknown)";
157
- // Unknown tool: model mistake list actual names, never execute.
158
- if (!toolNames().includes(name)) {
159
- return `Error: unknown tool "${name}". Available: ${toolNames().join(", ")}`;
232
+ // update_goal bypasses the unknown-name gate (ticket 03): a
233
+ // loop-intercepted tool like ask_question — the registry owns builtins
234
+ // only, so the loop exempts it by name here; runOneToolWithArgs below
235
+ // validates it and resolves it without an executor (never needs approval).
236
+ if (name !== "update_goal" && !toolNames().includes(name)) {
237
+ return { result: `Error: unknown tool "${name}". Available: ${toolNames().join(", ")}`, args: parsed };
238
+ }
239
+ const pre = await runBeforeIntercept(name, parsed);
240
+ if (pre.blocked !== null) {
241
+ return { result: pre.blocked, args: pre.args };
160
242
  }
243
+ return runOneToolWithArgs(call, pre.args, opts, execute, preDecision, onUpdateGoal);
244
+ }
245
+ // Inner execution after pre-interception: validate (rewritten) args, then
246
+ // permission + ask_question/update_goal gates, then the executor. Shared by
247
+ // the serial path (via runOneTool) and the parallel batch (via the pre-pass
248
+ // below, which resolves pre-hooks serially so prompts never run concurrently).
249
+ async function runOneToolWithArgs(call, parsed, opts, execute, preDecision, onUpdateGoal) {
250
+ const name = call?.function?.name ?? "(unknown)";
161
251
  // Argument validation BEFORE approval/execution: model mistake, never runs.
252
+ // Pre-hook rewrites arrive here already applied, so they re-validate on
253
+ // exactly what would execute — invalid rewrites never reach an executor.
162
254
  const detail = validateToolArgs(name, parsed);
163
255
  if (detail) {
164
- return invalidCall(detail);
256
+ return { result: invalidCall(detail), args: parsed };
165
257
  }
166
258
  if (name === "ask_question") {
167
259
  throwIfCancelled(opts?.signal);
@@ -169,28 +261,26 @@ async function runOneTool(call, parsed, opts, execute) {
169
261
  // LoopCancelledError (no result). If it resolves just as the signal
170
262
  // aborts, return the result — the loop records it, then stops before
171
263
  // the next POST (no new POSTs, pairing stays valid until rollback).
172
- return runAskQuestion(parsed, opts?.askUser, opts?.signal);
264
+ return { result: await runAskQuestion(parsed, opts?.askUser, opts?.signal), args: parsed };
173
265
  }
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.
266
+ if (name === "update_goal") {
187
267
  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).
268
+ // Goal-disposition report (ticket 03): ask_question-shaped — validated,
269
+ // never needs approval, resolved without an executor (the per-turn slot
270
+ // recorder owns the record; execute never sees this name, so plan-mode
271
+ // and approval policy are untouched). Without the recorder (never from
272
+ // runLoopWithChat both call sites thread it) the call is outside any
273
+ // goal turn by construction.
274
+ if (!onUpdateGoal)
275
+ return { result: goalReportOutsideError(), args: parsed };
276
+ return { result: onUpdateGoal(parsed), args: parsed };
277
+ }
278
+ const decision = preDecision ?? (await resolveApproval(name, parsed, opts));
279
+ if (decision === "no") {
280
+ return { result: `Error: denied by user: ${name}`, args: parsed };
193
281
  }
282
+ // "once"/"always" run this call (the caller caches the always-allowed set
283
+ // session-wide so later calls skip the prompt).
194
284
  // No new executions after a cancel: stop after the current tool finishes.
195
285
  // The current tool (if already running) is awaited to completion and its
196
286
  // result IS recorded — the loop then stops before the next tool/POST, so
@@ -201,8 +291,8 @@ async function runOneTool(call, parsed, opts, execute) {
201
291
  try {
202
292
  const raw = await executeWithTimeout(execute, name, parsed, timeoutMs, opts?.signal);
203
293
  if (doNormalize)
204
- return normalizeToolResult(raw);
205
- return typeof raw === "string" ? raw : normalizeToolResult(raw);
294
+ return { result: normalizeToolResult(raw), args: parsed };
295
+ return { result: typeof raw === "string" ? raw : normalizeToolResult(raw), args: parsed };
206
296
  }
207
297
  catch (e) {
208
298
  if (isCancelError(e) || opts?.signal?.aborted)
@@ -210,6 +300,35 @@ async function runOneTool(call, parsed, opts, execute) {
210
300
  throw e;
211
301
  }
212
302
  }
303
+ // After-tool-call result hook (issue 06): the single rewrite seam between
304
+ // execution and commit. Absent hook (or null/undefined/void/non-object
305
+ // return) → the original result, byte-identical. A string return replaces
306
+ // content (error flag kept); an object may replace content, override
307
+ // isError, or veto the commit. Hook failures degrade to the original — the
308
+ // turn never breaks. Telemetry keeps the pre-hook execution result; only the
309
+ // committed history/transcript/activity sees the rewrite.
310
+ async function applyToolResultHook(hook, name, parsed, result, isError) {
311
+ if (!hook)
312
+ return { content: result, isError, veto: false };
313
+ try {
314
+ const decision = await hook({ name, args: parsed, result, isError });
315
+ if (decision === null || decision === undefined)
316
+ return { content: result, isError, veto: false };
317
+ if (typeof decision === "string")
318
+ return { content: decision, isError, veto: false };
319
+ if (typeof decision === "object") {
320
+ if (decision.veto === true)
321
+ return { content: result, isError, veto: true };
322
+ const content = typeof decision.content === "string" ? decision.content : result;
323
+ const nextIsError = typeof decision.isError === "boolean" ? decision.isError : isError;
324
+ return { content, isError: nextIsError, veto: false };
325
+ }
326
+ return { content: result, isError, veto: false };
327
+ }
328
+ catch {
329
+ return { content: result, isError, veto: false };
330
+ }
331
+ }
213
332
  async function runAskQuestion(parsed, askUser, signal) {
214
333
  const invalid = validateAskQuestionArgs(parsed);
215
334
  if (invalid)
@@ -302,19 +421,104 @@ export async function runLoopWithChat(chatFn, history, opts) {
302
421
  let verifiedAfterWrite = false;
303
422
  let needsVerification = false;
304
423
  let unverifiedPaths = [];
305
- // Verification-gate nag cycles spent (bounds the continue loop alongside
306
- // the step budget — a model that never verifies still terminates).
424
+ // Verification-gate nag cycles spent (bounds the continue loop via
425
+ // MAX_VERIFY_ROUNDS — a model that never verifies still terminates).
307
426
  let verifyRounds = 0;
308
- // At most one truncation notice per turn; silence when nothing dropped.
309
- let truncationNoticed = false;
310
- // Window-aware trimmer when the caller knows the model (App passes it);
311
- // created once per turn ceiling sources resolve once, history is
312
- // re-measured every step. Absent the legacy fixed caps below.
313
- const contextManager = opts?.context
314
- ? createContextManager({ model: opts.context.model, toolsChars: opts.context.toolsChars })
315
- : null;
316
- // ---- Hardened-loop state (additive; defaults preserve the pinned
317
- // maxSteps contract see AgenticOpts docs) ----
427
+ // Todo-guard cycles spent (bounds guard continues via MAX_TODO_ROUNDS
428
+ // a model that never resolves open todos still terminates).
429
+ let todoRounds = 0;
430
+ // Goal engagement (ticket 02): set the first time a completed POST
431
+ // observes a live goal. A goal cleared mid-run still counts its final
432
+ // turn (the work happened); a run that never saw a goal counts nothing.
433
+ let goalEngaged = false;
434
+ // Disposition slot (ticket 03): the model-reported outcome for the CURRENT
435
+ // goal turn, recorded by update_goal calls and consumed at the next turn
436
+ // end BEFORE the auto-continue decision. Reset per run and on every
437
+ // consumption — a report never leaks into the following turn.
438
+ let pendingDisposition = null;
439
+ // Read the live goal without ever throwing (a failing accessor ends the
440
+ // turn normally — the goal simply does not continue).
441
+ const readLiveGoal = () => {
442
+ try {
443
+ return opts?.goal?.getGoal?.() ?? null;
444
+ }
445
+ catch {
446
+ return null;
447
+ }
448
+ };
449
+ // Pause-with-preservation via the App-owned callback (state flip plus a
450
+ // visible notice; the goal text and stats survive). Never throws.
451
+ const pauseLiveGoal = (objective, reason) => {
452
+ try {
453
+ opts?.goal?.pauseGoal(goalPausedNotice(objective, reason));
454
+ }
455
+ catch {
456
+ // observer errors never break the loop
457
+ }
458
+ };
459
+ // Verdict pause (ticket 03): terminal dispositions pause with their own
460
+ // verdict wording — no goalPausedNotice wrap, the verdict IS the notice
461
+ // (it already names the objective and carries the reason). Never throws.
462
+ const pauseLiveGoalWithNotice = (notice) => {
463
+ try {
464
+ opts?.goal?.pauseGoal(notice);
465
+ }
466
+ catch {
467
+ // observer errors never break the loop
468
+ }
469
+ };
470
+ // Record one update_goal report into the per-turn slot (ticket 03), in
471
+ // order: bad args are a model mistake (invalid-call error, nothing
472
+ // recorded); with no live active goal the call is outside any goal turn
473
+ // (structured error, zero state change); after a terminal report anything
474
+ // further is rejected with a notice (first report sticks); the same value
475
+ // twice is idempotent; otherwise the report replaces any pending
476
+ // non-terminal one (last wins). Pure parts live in goal.ts; this closure
477
+ // only owns the slot read/write. Never throws — results are strings.
478
+ const recordGoalReport = (parsed) => {
479
+ const detail = validateUpdateGoalArgs(parsed);
480
+ if (detail)
481
+ return invalidCall(detail);
482
+ const live = readLiveGoal();
483
+ if (live === null || !live.active)
484
+ return goalReportOutsideError();
485
+ const next = updateGoalDisposition(parsed);
486
+ if (pendingDisposition !== null && pendingDisposition.status !== "continue") {
487
+ return goalReportRejectedNotice(pendingDisposition);
488
+ }
489
+ if (pendingDisposition !== null && sameGoalDisposition(pendingDisposition, next)) {
490
+ return goalReportAck(next);
491
+ }
492
+ pendingDisposition = next;
493
+ return goalReportAck(next);
494
+ };
495
+ // Take the pending report and clear the slot (ticket 03 consumption reads
496
+ // through here so the declared return type drives the turn-end checks —
497
+ // the captured slot's direct-flow narrowing would otherwise collapse the
498
+ // read to null). A report never leaks into the following turn.
499
+ const takeDisposition = () => {
500
+ const current = pendingDisposition;
501
+ pendingDisposition = null;
502
+ return current;
503
+ };
504
+ // Novelty progress (ticket 05): per-run memory of committed-result
505
+ // fingerprints plus the consecutive-non-novel streak. Recorded in the
506
+ // commit funnel below; read at goal turn end for the stall redirect.
507
+ // Loop-local is sufficient: a whole goal run normally lives inside one
508
+ // runLoopWithChat call (continuations `continue` the loop; only
509
+ // terminal/cancel/budget/failure return).
510
+ const goalProgress = emptyGoalProgress();
511
+ // One goal turn taken (guarded — accounting never breaks the turn).
512
+ const noteGoalTurn = () => {
513
+ try {
514
+ opts?.goal?.onGoalTurn?.();
515
+ }
516
+ catch {
517
+ // observer errors never break the loop
518
+ }
519
+ };
520
+ // ---- Hardened-loop state (additive; explicit caps still honored —
521
+ // see AgenticOpts docs) ----
318
522
  const maxTotalToolCalls = resolveMaxTotalToolCalls(opts?.maxTotalToolCalls);
319
523
  const repGuard = new RepetitionGuard({ maxRepeatedCalls: opts?.maxRepeatedCalls });
320
524
  const errStreak = new ErrorStreakTracker(opts?.maxConsecutiveErrors);
@@ -336,7 +540,9 @@ export async function runLoopWithChat(chatFn, history, opts) {
336
540
  let modelCalls = 0;
337
541
  let toolCalls = 0;
338
542
  let failures = 0;
339
- let droppedTurnsTotal = 0;
543
+ // Empty-response repairs spent (bounded by MAX_EMPTY_ROUNDS — a model
544
+ // that only answers silence still terminates).
545
+ let emptyRounds = 0;
340
546
  let bottleneck = null;
341
547
  const noteBottleneck = (name, durationMs) => {
342
548
  if (!Number.isFinite(durationMs) || durationMs < 0)
@@ -368,7 +574,6 @@ export async function runLoopWithChat(chatFn, history, opts) {
368
574
  failures,
369
575
  repetitionHits: repGuard.hitCount,
370
576
  cacheHits,
371
- truncationNotices: droppedTurnsTotal,
372
577
  durationMs: Math.max(0, Date.now() - turnStartMs),
373
578
  bottleneck,
374
579
  contextGrowthChars: endChars - startChars,
@@ -384,41 +589,13 @@ export async function runLoopWithChat(chatFn, history, opts) {
384
589
  throwIfCancelled(signal);
385
590
  // Steering seam: drain one pending steer message (if any) at this safe
386
591
  // 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.
592
+ // pairing can never split. No-op without the hook.
389
593
  try {
390
594
  opts?.drainSteer?.();
391
595
  }
392
596
  catch {
393
597
  // observer errors never break the loop
394
598
  }
395
- // History budget (uniform for all providers — every POST flows through
396
- // here): trim oldest user-turns first before each send.
397
- const trimmed = contextManager
398
- ? contextManager.trimForSend(history, truncationNoticed
399
- ? undefined
400
- : (notice) => {
401
- try {
402
- opts?.onWarning?.(notice);
403
- }
404
- catch {
405
- // ignore observer errors
406
- }
407
- }, undefined, openTodoNeedles())
408
- : truncateHistory(history, truncationNoticed
409
- ? undefined
410
- : (notice) => {
411
- try {
412
- opts?.onWarning?.(notice);
413
- }
414
- catch {
415
- // ignore observer errors
416
- }
417
- });
418
- if (trimmed.droppedTurns > 0) {
419
- truncationNoticed = true;
420
- droppedTurnsTotal += trimmed.droppedTurns;
421
- }
422
599
  let msg;
423
600
  const modelStart = Date.now();
424
601
  try {
@@ -449,6 +626,16 @@ export async function runLoopWithChat(chatFn, history, opts) {
449
626
  });
450
627
  if (isCancelError(e) || signal?.aborted)
451
628
  throw new LoopCancelledError();
629
+ // Empty-response recovery: a silent POST spends repair budget instead
630
+ // of aborting the turn (see MAX_EMPTY_ROUNDS). Cancellation above still
631
+ // wins; every other failure throws exactly as before.
632
+ if (isEmptyReplyError(e) && emptyRounds < MAX_EMPTY_ROUNDS) {
633
+ emptyRounds += 1;
634
+ failures += 1;
635
+ history.push({ role: "assistant", content: "" });
636
+ history.push({ role: "user", content: emptyResponseFollowUp(emptyRounds) });
637
+ continue;
638
+ }
452
639
  throw e;
453
640
  }
454
641
  throwIfCancelled(signal);
@@ -473,6 +660,17 @@ export async function runLoopWithChat(chatFn, history, opts) {
473
660
  // normalization never breaks the turn; the raw message stands
474
661
  }
475
662
  modelCalls += 1;
663
+ // Goal slice (ticket 02): one completed POST while a goal is live.
664
+ // Engages the run for turn accounting below; guarded, never breaks it.
665
+ try {
666
+ if (opts?.goal?.getGoal?.()?.active === true) {
667
+ goalEngaged = true;
668
+ opts.goal.onGoalRequest?.();
669
+ }
670
+ }
671
+ catch {
672
+ // observer errors never break the loop
673
+ }
476
674
  if (msg.usage !== undefined) {
477
675
  // Spend accounting: EVERY POST that reports usage forwards it, and the
478
676
  // caller accumulates each report as billed spend — tool-round POSTs,
@@ -525,16 +723,180 @@ export async function runLoopWithChat(chatFn, history, opts) {
525
723
  needsVerification,
526
724
  unverifiedPaths: [...unverifiedPaths],
527
725
  verifyRounds,
726
+ todoRounds,
528
727
  });
529
728
  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.
729
+ // Guard continues are bounded per turn so a model that never
730
+ // verifies or never resolves todos still terminates.
532
731
  if (outcome.via === "verification")
533
732
  verifyRounds += 1;
733
+ else if (outcome.via === "todoCompletionGate")
734
+ todoRounds += 1;
534
735
  history.push({ role: "assistant", content: outcome.assistantText });
535
736
  history.push({ role: "user", content: outcome.followUp });
536
737
  continue;
537
738
  }
739
+ // Disposition protocol (ticket 03): consume this turn's update_goal
740
+ // report BEFORE the auto-continue decision. Terminal dispositions stop
741
+ // the run with a verdict — `blocked` unconditionally (even with
742
+ // unaddressed tool errors), `complete` only through the honesty gate
743
+ // below (unverified code or open todos continue instead) — pausing
744
+ // the goal with the verdict as its notice (never clearing, like every
745
+ // other loop-driven goal ending). A `continue` report's next action
746
+ // becomes the follow-up below (generic text when absent); no report —
747
+ // or a goal gone mid-turn — flows into the existing path untouched.
748
+ // The slot clears on every consumption, so a report never leaks into
749
+ // the following turn; guard `continue`s above never reach here, so a
750
+ // report filed mid-turn survives them until a real turn end.
751
+ const dispositionGoal = readLiveGoal();
752
+ let disposition = takeDisposition();
753
+ let goalNextAction = null;
754
+ // Evaluator fallback (ticket 04): a report-less turn with a live goal
755
+ // gets exactly ONE bounded judge call before continuing — but only when
756
+ // a runner is configured. Without one the turn continues exactly as
757
+ // before (existing tests pin this). A clear verdict flows through the
758
+ // SAME terminal/continue handling below as a model report (a `complete`
759
+ // still passes the honesty gate below — one code path for both); a judge error
760
+ // or an unclear verdict pauses (preserves) instead of looping. The
761
+ // judge reads history only — this path pushes nothing, so the judge
762
+ // performs no state mutations.
763
+ if (disposition === null &&
764
+ dispositionGoal !== null &&
765
+ dispositionGoal.active &&
766
+ opts?.goalJudge) {
767
+ // Cancel wins before the extra POST: never judge into a lost turn.
768
+ throwIfCancelled(signal);
769
+ let verdict = null;
770
+ let judgeError = null;
771
+ try {
772
+ verdict = await opts.goalJudge({
773
+ goal: dispositionGoal.objective,
774
+ turns: recentTurnsForJudge(history),
775
+ });
776
+ }
777
+ catch (e) {
778
+ // Whole-turn cancellation still propagates (it pauses via the outer
779
+ // catch, like every other cancel) — anything else pauses here.
780
+ if (isCancelError(e) || signal?.aborted)
781
+ throw new LoopCancelledError();
782
+ const msg = e instanceof Error ? e.message : String(e);
783
+ judgeError = msg.length > 200 ? `${msg.slice(0, 200)}…` : msg;
784
+ }
785
+ // The goal may have cleared/paused mid-judge (slash runs while busy):
786
+ // then the verdict is dropped and the turn ends normally — never
787
+ // pause or continue a goal that is gone.
788
+ const liveAfterJudge = readLiveGoal();
789
+ if (liveAfterJudge === null || !liveAfterJudge.active) {
790
+ if (goalEngaged)
791
+ noteGoalTurn();
792
+ history.push({ role: "assistant", content: outcome.finalText });
793
+ try {
794
+ opts?.onPhase?.("done");
795
+ }
796
+ catch {
797
+ // ignore
798
+ }
799
+ return outcome.finalText;
800
+ }
801
+ if (judgeError !== null || verdict === null) {
802
+ // Pause-with-preservation (never clear): the goal, todos, and
803
+ // history stay for /goal resume. The turn was still taken.
804
+ pauseLiveGoal(liveAfterJudge.objective, judgeError !== null ? `(judge failed: ${judgeError})` : `(judge unclear — no clear verdict)`);
805
+ noteGoalTurn();
806
+ history.push({ role: "assistant", content: outcome.finalText });
807
+ try {
808
+ opts?.onPhase?.("done");
809
+ }
810
+ catch {
811
+ // ignore
812
+ }
813
+ return outcome.finalText;
814
+ }
815
+ disposition = verdict;
816
+ }
817
+ if (disposition !== null &&
818
+ disposition.status !== "continue" &&
819
+ dispositionGoal !== null &&
820
+ dispositionGoal.active) {
821
+ // Cancel wins even mid-verdict: never stop cleanly into a lost turn.
822
+ throwIfCancelled(signal);
823
+ // Honest completion gate (ticket 06): a `complete` only lands on
824
+ // genuinely finished work. With unverified code pending or todos
825
+ // still open the goal continues instead of stopping, naming the
826
+ // files/items through the gates' own follow-up voice (todos first,
827
+ // matching TURN_END_GATES order). `blocked` skips this entirely and
828
+ // stops unconditionally below. Evaluator verdicts share this path —
829
+ // they arrive in the same `disposition` slot, so one code path gates
830
+ // both. Guard `continue`s above never reach here, so a false
831
+ // `complete` filed mid-turn is still caught when its turn ends.
832
+ if (disposition.status === "complete") {
833
+ // Voice-only probe: budgets/rounds zeroed so the builders return
834
+ // their `continue` follow-up whenever their state is dirty — the
835
+ // live budget owns spinning protection in the branch below, and the
836
+ // pinned gates above own the per-turn nag bounds.
837
+ const honestCtx = {
838
+ step: 0,
839
+ maxSteps: Number.POSITIVE_INFINITY,
840
+ filesWritten,
841
+ verifiedAfterWrite,
842
+ needsVerification,
843
+ unverifiedPaths: [...unverifiedPaths],
844
+ verifyRounds: 0,
845
+ todoRounds: 0,
846
+ };
847
+ const todoProbe = todoCompletionGate(outcome.finalText, honestCtx);
848
+ const verifyProbe = verificationGate(outcome.finalText, honestCtx);
849
+ const honestBlock = todoProbe.action === "continue"
850
+ ? todoProbe
851
+ : verifyProbe.action === "continue"
852
+ ? verifyProbe
853
+ : null;
854
+ if (honestBlock !== null) {
855
+ // Spent budget cannot start another turn: pause (preserve) with
856
+ // the budget notice instead of completing dirty or spinning.
857
+ if (step >= maxSteps || toolCalls >= maxTotalToolCalls) {
858
+ pauseLiveGoal(dispositionGoal.objective, "(budget spent)");
859
+ noteGoalTurn();
860
+ history.push({ role: "assistant", content: outcome.finalText });
861
+ try {
862
+ opts?.onPhase?.("done");
863
+ }
864
+ catch {
865
+ // ignore
866
+ }
867
+ return outcome.finalText;
868
+ }
869
+ // A guard-style continue: not a turn end, so no goal-turn
870
+ // accounting — and the false report is already consumed, so the
871
+ // next turn must file fresh evidence.
872
+ history.push({ role: "assistant", content: honestBlock.assistantText });
873
+ history.push({ role: "user", content: honestBlock.followUp });
874
+ continue;
875
+ }
876
+ }
877
+ const verdict = disposition.status === "complete"
878
+ ? goalVerdictNotice(dispositionGoal.objective, "complete", disposition.reason, disposition.unverified)
879
+ : goalVerdictNotice(dispositionGoal.objective, disposition.status, disposition.reason);
880
+ pauseLiveGoalWithNotice(verdict);
881
+ noteGoalTurn();
882
+ const base = outcome.finalText;
883
+ const final = base ? `${base}\n${verdict}` : verdict;
884
+ history.push({ role: "assistant", content: final });
885
+ try {
886
+ opts?.onPhase?.("done");
887
+ }
888
+ catch {
889
+ // ignore
890
+ }
891
+ return final;
892
+ }
893
+ if (disposition !== null &&
894
+ disposition.status === "continue" &&
895
+ disposition.next !== undefined &&
896
+ dispositionGoal !== null &&
897
+ dispositionGoal.active) {
898
+ goalNextAction = disposition.next;
899
+ }
538
900
  // Error-streak recovery (additive, after the pinned gates): ending on
539
901
  // sustained unaddressed `Error:` results is almost always premature.
540
902
  // Single errors still end normally (the model may be reporting a
@@ -546,6 +908,58 @@ export async function runLoopWithChat(chatFn, history, opts) {
546
908
  history.push({ role: "user", content: errorStreakFollowUp(streak) });
547
909
  continue;
548
910
  }
911
+ // Goal auto-continue (tickets 02–03): with a live goal a would-be turn
912
+ // end starts the next turn through the same assistant+user follow-up
913
+ // seam the guards use above — the ONLY continuation message, so the
914
+ // transcript shows each turn normally with no synthetic user input
915
+ // beyond this mechanism. Unconditional (no turn cap): the run ends
916
+ // only via pause (cancel/spent budget), clear, a terminal disposition,
917
+ // or a thrown failure. A `continue` report's next action becomes the
918
+ // follow-up text (generic follow-up when absent or unreported).
919
+ // Runs after the error-streak hold so sustained tool failures still
920
+ // get their fix-forward guidance first (a hold is not a turn end, so
921
+ // it counts no goal turn — and a consumed `continue` report is dropped
922
+ // with it, so the fix-forward guidance wins that round).
923
+ const liveGoal = readLiveGoal();
924
+ if (liveGoal !== null && liveGoal.active) {
925
+ goalEngaged = true;
926
+ // Cancel wins even mid-continuation: never start another turn lost.
927
+ throwIfCancelled(signal);
928
+ // A spent budget can never make progress — pause (preserve) with a
929
+ // notice instead of POSTing forever. The turn was still taken.
930
+ if (step >= maxSteps || toolCalls >= maxTotalToolCalls) {
931
+ pauseLiveGoal(liveGoal.objective, "(budget spent)");
932
+ noteGoalTurn();
933
+ history.push({ role: "assistant", content: outcome.finalText });
934
+ try {
935
+ opts?.onPhase?.("done");
936
+ }
937
+ catch {
938
+ // ignore
939
+ }
940
+ return outcome.finalText;
941
+ }
942
+ noteGoalTurn();
943
+ history.push({ role: "assistant", content: outcome.finalText });
944
+ // Stall redirect (ticket 05): a run of exact repeats gets the replan
945
+ // nudge as its follow-up instead of the generic continuation — same
946
+ // assistant+user commit shape as the other gates, so the redirect is
947
+ // visible in the transcript. The epoch resets; the goal is never
948
+ // paused, cleared, or ended here (existing budgets still bound a run
949
+ // that keeps stalling, so recurring nudges cannot spin forever).
950
+ if (goalStallReached(goalProgress)) {
951
+ resetGoalStall(goalProgress);
952
+ history.push({ role: "user", content: goalStallNudge(liveGoal.objective, GOAL_STALL_REPEATS) });
953
+ }
954
+ else {
955
+ history.push({ role: "user", content: goalNextAction ?? goalFollowUp(liveGoal.objective) });
956
+ }
957
+ continue;
958
+ }
959
+ // Final turn of a run that engaged a goal before it cleared: the work
960
+ // happened, so it still counts (no continuation — the goal is gone).
961
+ if (goalEngaged)
962
+ noteGoalTurn();
549
963
  history.push({ role: "assistant", content: outcome.finalText });
550
964
  try {
551
965
  opts?.onPhase?.("done");
@@ -558,6 +972,18 @@ export async function runLoopWithChat(chatFn, history, opts) {
558
972
  if (step >= maxSteps) {
559
973
  const base = msg.content ?? "";
560
974
  const notice = `${base}${base ? "\n" : ""}(stopped: too many tool steps) (limit is ${maxSteps}; raise with ATOM_MAX_TOOL_STEPS=<n>)`;
975
+ // Spent budget during a goal run pauses (preserves) it with a notice
976
+ // instead of silently dropping it — the stop text stays the reply and
977
+ // the pause notice lands as its own line via the callback.
978
+ const overGoal = readLiveGoal();
979
+ if (overGoal !== null && overGoal.active) {
980
+ goalEngaged = true;
981
+ pauseLiveGoal(overGoal.objective, "(step budget spent)");
982
+ noteGoalTurn();
983
+ }
984
+ else if (goalEngaged) {
985
+ noteGoalTurn();
986
+ }
561
987
  history.push({ role: "assistant", content: notice });
562
988
  try {
563
989
  opts?.onPhase?.("done");
@@ -567,12 +993,22 @@ export async function runLoopWithChat(chatFn, history, opts) {
567
993
  }
568
994
  return notice;
569
995
  }
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).
996
+ // Total tool-call budget (explicit `opts.maxTotalToolCalls` only;
997
+ // uncapped by default): counts every tool_call the model emits and stops
998
+ // the turn when the explicit budget is exceeded.
573
999
  if (toolCalls + calls.length > maxTotalToolCalls) {
574
1000
  const base = msg.content ?? "";
575
1001
  const notice = `${base}${base ? "\n" : ""}(stopped: too many tool calls) (limit is ${maxTotalToolCalls} per turn)`;
1002
+ // Same pause-with-preservation contract as the step-budget stop above.
1003
+ const overGoal = readLiveGoal();
1004
+ if (overGoal !== null && overGoal.active) {
1005
+ goalEngaged = true;
1006
+ pauseLiveGoal(overGoal.objective, "(tool-call budget spent)");
1007
+ noteGoalTurn();
1008
+ }
1009
+ else if (goalEngaged) {
1010
+ noteGoalTurn();
1011
+ }
576
1012
  history.push({ role: "assistant", content: notice });
577
1013
  try {
578
1014
  opts?.onPhase?.("done");
@@ -587,12 +1023,34 @@ export async function runLoopWithChat(chatFn, history, opts) {
587
1023
  // bookkeeping + one ordered transcript entry per call. Only successful
588
1024
  // executions count — denials, validation errors, and unknown tools (all
589
1025
  // `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");
1026
+ const commitToolResult = async (name, parsed, call, result, durationMs) => {
1027
+ const baseIsError = typeof result === "string" && result.startsWith("Error");
1028
+ // Extension post-hooks observe the raw commit candidate first (every
1029
+ // committed result: executions, blocks, denials, validation errors);
1030
+ // the caller's onToolResult hook runs last on the patched version.
1031
+ // Patches apply per call in commit order, so tool_call_id re-pairing
1032
+ // and ordering are untouched; throwing patchers fail open above.
1033
+ const after = await runAfterIntercept(name, parsed, result, baseIsError);
1034
+ const hooked = await applyToolResultHook(opts?.onToolResult, name, parsed, after.content, after.isError);
1035
+ // Veto: skip the commit entirely — no counters, no gates, no history,
1036
+ // no activity. The turn continues; pairing risk is the hook author's.
1037
+ if (hooked.veto)
1038
+ return false;
1039
+ const finalResult = hooked.content;
1040
+ const isError = hooked.isError;
592
1041
  toolCalls += 1;
593
1042
  if (isError)
594
1043
  failures += 1;
595
1044
  errStreak.noteResult(isError);
1045
+ // Novelty progress (ticket 05): successful commits fingerprint into the
1046
+ // per-run seen-set (Error results are owned by the error-streak
1047
+ // machinery and never count). Guarded — accounting never breaks the turn.
1048
+ try {
1049
+ noteGoalProgress(goalProgress, name, parsed, finalResult, isError);
1050
+ }
1051
+ catch {
1052
+ // observer errors never break the loop
1053
+ }
596
1054
  if (typeof durationMs === "number")
597
1055
  noteBottleneck(name, durationMs);
598
1056
  if (!isError && (name === "write" || name === "edit")) {
@@ -608,7 +1066,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
608
1066
  else if (!isError && name === "bash") {
609
1067
  const command = parsed["command"];
610
1068
  if (typeof command === "string" && isVerificationCommand(command) && filesWritten) {
611
- const exit = bashExitCode(result);
1069
+ const exit = bashExitCode(finalResult);
612
1070
  if (exit === null || exit === 0) {
613
1071
  // Passing check (or a legacy runner that reports no envelope):
614
1072
  // clears everything the gate tracks.
@@ -624,14 +1082,59 @@ export async function runLoopWithChat(chatFn, history, opts) {
624
1082
  }
625
1083
  }
626
1084
  }
627
- history.push({ role: "tool", tool_call_id: call?.id ?? "", content: result });
1085
+ history.push({ role: "tool", tool_call_id: call?.id ?? "", content: finalResult });
628
1086
  try {
629
- opts?.onToolActivity?.(describeToolCall(name, parsed), result, isError);
1087
+ opts?.onToolActivity?.(describeToolCall(name, parsed), finalResult, isError);
630
1088
  }
631
1089
  catch {
632
1090
  // ignore observer errors
633
1091
  }
1092
+ return true;
634
1093
  };
1094
+ // Length-truncated response (the output limit cut the tool arguments
1095
+ // off): NOTHING executes — every carried call commits a repair-oriented
1096
+ // error result and the turn continues to the next model round. The
1097
+ // results are `Error:` strings, so failure/error-streak accounting flows
1098
+ // through commitToolResult exactly like any other tool error, and the
1099
+ // step/total-call budgets above keep bounding runaway retries.
1100
+ // Truncated-without-calls never reaches here (handled by the turn-end
1101
+ // gates above, exactly as before).
1102
+ if (msg.truncated === true) {
1103
+ for (let i = 0; i < calls.length; i++) {
1104
+ const call = calls[i];
1105
+ const name = call?.function?.name ?? "(unknown)";
1106
+ const result = `Error: truncated response: the output limit cut off the arguments for tool "${name}" — ` +
1107
+ `nothing was executed. Re-issue the call with complete arguments ` +
1108
+ `(narrow the scope or split into smaller calls if it keeps truncating).`;
1109
+ const toolAt = Date.now();
1110
+ reportToolCall({
1111
+ step,
1112
+ toolCallId: call?.id ?? "",
1113
+ name,
1114
+ startedAt: telemetryIso(toolAt),
1115
+ endedAt: telemetryIso(toolAt),
1116
+ durationMs: 0,
1117
+ argsJson: telemetryArgsJson(call?.function?.arguments ?? "{}"),
1118
+ result,
1119
+ batchIndex: i,
1120
+ batchSize: calls.length,
1121
+ });
1122
+ // Truncated arguments are often not valid JSON (cut mid-string) —
1123
+ // fall back to {} for bookkeeping (an error result never arms the
1124
+ // verification gate, so this only shapes the activity label).
1125
+ let parsed;
1126
+ try {
1127
+ const raw = call?.function?.arguments ?? "{}";
1128
+ const v = JSON.parse(typeof raw === "string" ? raw : "{}");
1129
+ parsed = typeof v === "object" && v !== null ? v : {};
1130
+ }
1131
+ catch {
1132
+ parsed = {};
1133
+ }
1134
+ await commitToolResult(name, parsed, call, result);
1135
+ }
1136
+ continue;
1137
+ }
635
1138
  for (const batch of planBatches(calls)) {
636
1139
  // No new executions after a cancel: the current tool (if any) already
637
1140
  // finished; stop before starting the next batch.
@@ -656,18 +1159,12 @@ export async function runLoopWithChat(chatFn, history, opts) {
656
1159
  catch {
657
1160
  parsed = {};
658
1161
  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);
1162
+ // Invalid JSON never executes — route through the commit funnel so
1163
+ // the result hook still sees every committed result. Bookkeeping
1164
+ // (counters, error streak, bottleneck, history, activity) is
1165
+ // identical to the inline block this replaced; only the committed
1166
+ // content may differ when a hook rewrites it.
662
1167
  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
1168
  const toolEnd = Date.now();
672
1169
  reportToolCall({
673
1170
  step,
@@ -681,12 +1178,16 @@ export async function runLoopWithChat(chatFn, history, opts) {
681
1178
  batchIndex: 0,
682
1179
  batchSize: 1,
683
1180
  });
1181
+ await commitToolResult(name, parsed, call, result, Math.max(0, toolEnd - toolStart));
684
1182
  continue;
685
1183
  }
686
1184
  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.
1185
+ // Effective (post-rewrite) args: what validated, approved, and ran —
1186
+ // telemetry and the commit below must see these, not the originals.
1187
+ let effectiveArgs = parsed;
1188
+ // Repetition guard (opt-in via maxRepeatedCalls; unset = track-only):
1189
+ // a repeated signature skips execution and yields a guidance error;
1190
+ // exhausted nudges stop hard.
690
1191
  const repSig = toolSignature(name, parsed);
691
1192
  const repNote = repGuard.note(repSig, name);
692
1193
  if (repNote.intervened) {
@@ -705,7 +1206,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
705
1206
  batchIndex: 0,
706
1207
  batchSize: 1,
707
1208
  });
708
- commitToolResult(name, parsed, call, guarded, 0);
1209
+ await commitToolResult(name, parsed, call, guarded, 0);
709
1210
  continue;
710
1211
  }
711
1212
  const guarded = `Error: invalid call: ${repetitionFollowUp(repSig, repNote.consecutive)} Fix the approach and retry.`;
@@ -721,7 +1222,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
721
1222
  batchIndex: 0,
722
1223
  batchSize: 1,
723
1224
  });
724
- commitToolResult(name, parsed, call, guarded, 0);
1225
+ await commitToolResult(name, parsed, call, guarded, 0);
725
1226
  const stopBase = msg.content ?? "";
726
1227
  const stopNotice = `${stopBase}${stopBase ? "\n" : ""}${repetitionStopNotice(repSig, repNote.consecutive)}`;
727
1228
  history.push({ role: "assistant", content: stopNotice });
@@ -734,7 +1235,9 @@ export async function runLoopWithChat(chatFn, history, opts) {
734
1235
  return stopNotice;
735
1236
  }
736
1237
  try {
737
- result = await runOneTool(call, parsed, opts, execute);
1238
+ const one = await runOneTool(call, parsed, opts, execute, undefined, recordGoalReport);
1239
+ result = one.result;
1240
+ effectiveArgs = one.args;
738
1241
  }
739
1242
  catch (e) {
740
1243
  // A cancelled/throwing tool still records its attempt (with the
@@ -773,17 +1276,19 @@ export async function runLoopWithChat(chatFn, history, opts) {
773
1276
  startedAt: telemetryIso(toolStart),
774
1277
  endedAt: telemetryIso(toolEnd),
775
1278
  durationMs: Math.max(0, toolEnd - toolStart),
776
- argsJson: telemetryArgsJson(parsed),
1279
+ argsJson: telemetryArgsJson(effectiveArgs),
777
1280
  result,
778
1281
  batchIndex: 0,
779
1282
  batchSize: 1,
780
1283
  });
781
1284
  }
782
- commitToolResult(name, parsed, call, result, Math.max(0, Date.now() - toolStart));
1285
+ await commitToolResult(name, effectiveArgs, call, result, Math.max(0, Date.now() - toolStart));
783
1286
  continue;
784
1287
  }
785
1288
  // Parallel batch: every member is pre-validated parallel-safe (see
786
- // planToolBatches), so runOneTool neither prompts nor blocks here.
1289
+ // planToolBatches). Approval-gated members (parallel writes) resolve
1290
+ // their decisions serially in call order first, so prompts never run
1291
+ // concurrently; denied members yield inline errors without executing.
787
1292
  // Phases fire upfront in call order; results commit in call order, so
788
1293
  // each call still shows separately and tool_call_ids re-pair by index.
789
1294
  // A throw (cancel or execution error) aborts the turn exactly like the
@@ -809,6 +1314,33 @@ export async function runLoopWithChat(chatFn, history, opts) {
809
1314
  repHardStop = { sig: note.signature, consecutive: note.consecutive };
810
1315
  }
811
1316
  }
1317
+ const memberPlans = new Map();
1318
+ const preDecisions = new Map();
1319
+ for (let i = 0; i < batch.length; i++) {
1320
+ throwIfCancelled(signal);
1321
+ if (repNotes[i].intervened)
1322
+ continue;
1323
+ const member = batch[i];
1324
+ const memberName = member.call?.function?.name ?? "(unknown)";
1325
+ if (!toolNames().includes(memberName)) {
1326
+ memberPlans.set(i, { args: member.parsed, blocked: null, invalid: null });
1327
+ continue;
1328
+ }
1329
+ const pre = await runBeforeIntercept(memberName, member.parsed);
1330
+ if (pre.blocked !== null) {
1331
+ memberPlans.set(i, { args: pre.args, blocked: pre.blocked, invalid: null });
1332
+ continue;
1333
+ }
1334
+ const invalid = validateToolArgs(memberName, pre.args);
1335
+ if (invalid) {
1336
+ memberPlans.set(i, { args: pre.args, blocked: null, invalid });
1337
+ continue;
1338
+ }
1339
+ memberPlans.set(i, { args: pre.args, blocked: null, invalid: null });
1340
+ const decision = await resolveApproval(memberName, pre.args, opts);
1341
+ if (decision !== null)
1342
+ preDecisions.set(i, decision);
1343
+ }
812
1344
  try {
813
1345
  // Each member is timed individually (concurrent wall-clock per call,
814
1346
  // not the whole batch attributed to each) and reported in call order
@@ -836,7 +1368,46 @@ export async function runLoopWithChat(chatFn, history, opts) {
836
1368
  return guarded;
837
1369
  }
838
1370
  try {
839
- const r = await runOneTool(member.call, member.parsed, opts, execute);
1371
+ // Pre-resolved members (blocked/invalid) never reach the
1372
+ // executor: their inline results commit in call order below.
1373
+ const plan = memberPlans.get(index);
1374
+ if (plan?.blocked !== null && plan?.blocked !== undefined) {
1375
+ const blocked = plan.blocked;
1376
+ const memberEnd = Date.now();
1377
+ memberDurations[index] = Math.max(0, memberEnd - memberStart);
1378
+ reportToolCall({
1379
+ step,
1380
+ toolCallId: member.call?.id ?? "",
1381
+ name: member.call?.function?.name ?? "(unknown)",
1382
+ startedAt: telemetryIso(memberStart),
1383
+ endedAt: telemetryIso(memberEnd),
1384
+ durationMs: Math.max(0, memberEnd - memberStart),
1385
+ argsJson: telemetryArgsJson(plan.args),
1386
+ result: blocked,
1387
+ batchIndex: index,
1388
+ batchSize: batch.length,
1389
+ });
1390
+ return blocked;
1391
+ }
1392
+ if (plan?.invalid) {
1393
+ const bad = invalidCall(plan.invalid);
1394
+ const memberEnd = Date.now();
1395
+ memberDurations[index] = Math.max(0, memberEnd - memberStart);
1396
+ reportToolCall({
1397
+ step,
1398
+ toolCallId: member.call?.id ?? "",
1399
+ name: member.call?.function?.name ?? "(unknown)",
1400
+ startedAt: telemetryIso(memberStart),
1401
+ endedAt: telemetryIso(memberEnd),
1402
+ durationMs: Math.max(0, memberEnd - memberStart),
1403
+ argsJson: telemetryArgsJson(plan.args),
1404
+ result: bad,
1405
+ batchIndex: index,
1406
+ batchSize: batch.length,
1407
+ });
1408
+ return bad;
1409
+ }
1410
+ const r = await runOneToolWithArgs(member.call, plan?.args ?? member.parsed, opts, execute, preDecisions.get(index) ?? null, recordGoalReport);
840
1411
  const memberEnd = Date.now();
841
1412
  memberDurations[index] = Math.max(0, memberEnd - memberStart);
842
1413
  reportToolCall({
@@ -846,12 +1417,12 @@ export async function runLoopWithChat(chatFn, history, opts) {
846
1417
  startedAt: telemetryIso(memberStart),
847
1418
  endedAt: telemetryIso(memberEnd),
848
1419
  durationMs: Math.max(0, memberEnd - memberStart),
849
- argsJson: telemetryArgsJson(member.parsed),
850
- result: r,
1420
+ argsJson: telemetryArgsJson(plan?.args ?? member.parsed),
1421
+ result: r.result,
851
1422
  batchIndex: index,
852
1423
  batchSize: batch.length,
853
1424
  });
854
- return r;
1425
+ return r.result;
855
1426
  }
856
1427
  catch (e) {
857
1428
  const memberEnd = Date.now();
@@ -885,7 +1456,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
885
1456
  }
886
1457
  for (let i = 0; i < batch.length; i++) {
887
1458
  const member = batch[i];
888
- commitToolResult(member.call?.function?.name ?? "(unknown)", member.parsed, member.call, results[i], memberDurations[i]);
1459
+ await commitToolResult(member.call?.function?.name ?? "(unknown)", memberPlans.get(i)?.args ?? member.parsed, member.call, results[i], memberDurations[i]);
889
1460
  }
890
1461
  if (repHardStop) {
891
1462
  const stopBase = msg.content ?? "";
@@ -902,6 +1473,19 @@ export async function runLoopWithChat(chatFn, history, opts) {
902
1473
  }
903
1474
  }
904
1475
  }
1476
+ catch (e) {
1477
+ // Cancel during a live goal pauses (preserves) it with a notice — never
1478
+ // clears — so a later /goal resume can continue. Failed POSTs skip this
1479
+ // entirely: the caller rolls back per the existing splice contract and
1480
+ // the goal stays active and carries on.
1481
+ if (isCancelError(e) || signal?.aborted) {
1482
+ const cancelledGoal = readLiveGoal();
1483
+ if (cancelledGoal !== null && cancelledGoal.active) {
1484
+ pauseLiveGoal(cancelledGoal.objective, "(cancelled)");
1485
+ }
1486
+ }
1487
+ throw e;
1488
+ }
905
1489
  finally {
906
1490
  finishStats();
907
1491
  }