atom-agent 1.3.0 → 1.5.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 (71) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +220 -224
  3. package/dist/App.js +922 -341
  4. package/dist/adapters.js +127 -14
  5. package/dist/agent/goal-evaluator.js +3 -0
  6. package/dist/agent/loop.js +211 -430
  7. package/dist/agent/tool-pipeline.js +398 -0
  8. package/dist/agent/turn-events.js +12 -0
  9. package/dist/cli.js +57 -8
  10. package/dist/compact.js +72 -8
  11. package/dist/config.js +19 -0
  12. package/dist/context-manager.js +6 -2
  13. package/dist/extensions.js +6 -0
  14. package/dist/file-diffs.js +108 -0
  15. package/dist/kilo.js +1 -1
  16. package/dist/local-discovery.js +2 -2
  17. package/dist/media.js +276 -0
  18. package/dist/overflow.js +140 -0
  19. package/dist/policy.js +8 -0
  20. package/dist/scheduler.js +38 -9
  21. package/dist/session-revert.js +125 -0
  22. package/dist/sessions.js +101 -0
  23. package/dist/snapshots.js +69 -0
  24. package/dist/system.js +2 -89
  25. package/dist/telemetry.js +26 -1
  26. package/dist/todos.js +241 -0
  27. package/dist/tools/filesystem.js +102 -22
  28. package/dist/tools/registry.js +184 -45
  29. package/dist/tools/ripgrep.js +7 -6
  30. package/dist/tools/search.js +172 -17
  31. package/dist/tools/shared.js +6 -0
  32. package/dist/tools.js +7 -39
  33. package/dist/ui/diff-panel.js +5 -5
  34. package/dist/ui/diff-view.js +16 -7
  35. package/dist/ui/diff.js +73 -51
  36. package/dist/ui/errors.js +20 -6
  37. package/dist/ui/input.js +24 -20
  38. package/dist/ui/live-tail.js +36 -1
  39. package/dist/ui/markdown.js +9 -4
  40. package/dist/ui/modals.js +6 -4
  41. package/dist/ui/paint-scheduler.js +120 -0
  42. package/dist/ui/palette.js +4 -2
  43. package/dist/ui/pickers.js +4 -1
  44. package/dist/ui/side-by-side.js +88 -27
  45. package/dist/ui/status-bar.js +63 -8
  46. package/dist/ui/stream-store.js +7 -0
  47. package/dist/ui/theme.js +23 -1
  48. package/dist/ui/todo-panel.js +5 -2
  49. package/dist/ui/tool-inspector.js +33 -4
  50. package/dist/ui/transcript.js +9 -6
  51. package/dist/web/events.js +93 -0
  52. package/dist/web/runtime.js +790 -0
  53. package/dist/web/server.js +570 -0
  54. package/dist/web/ui/app.js +1925 -0
  55. package/dist/web/ui/index.html +135 -0
  56. package/dist/web/ui/styles.css +515 -0
  57. package/dist/zen.js +115 -4
  58. package/documentation/cli.md +5 -5
  59. package/documentation/configuration.md +11 -6
  60. package/documentation/development.md +4 -3
  61. package/documentation/extensions.md +1 -1
  62. package/documentation/goals.md +1 -1
  63. package/documentation/index.md +4 -4
  64. package/documentation/providers.md +2 -3
  65. package/documentation/skills.md +3 -3
  66. package/documentation/tools.md +8 -3
  67. package/documentation/troubleshooting.md +1 -1
  68. package/examples/extensions/01-audit-gate.js +2 -2
  69. package/examples/extensions/02-notes-tool.js +2 -2
  70. package/examples/extensions/03-custom-command.js +2 -2
  71. package/package.json +3 -2
@@ -22,42 +22,19 @@
22
22
  // zen.ts; runAgenticLoopForProvider wraps this loop from there).
23
23
  import { loadAtomConfig } from "../config.js";
24
24
  import { historyChars } from "../context-manager.js";
25
- import { planBatches } from "../scheduler.js";
26
- import { describeToolCall, executeTool, invalidCall, needsApproval, toolNames, validateAskQuestionArgs, validateToolArgs, } from "../tools.js";
27
- import { afterToolInterceptors, applyAfterInterceptors, applyBeforeInterceptors, beforeToolInterceptors, blockedToolResult, } from "../tools/intercept.js";
25
+ import { captureSchedulerSnapshot, planBatches } from "../scheduler.js";
26
+ import { describeToolCall, executeTool, invalidCall } from "../tools.js";
27
+ import { applyCommitPatches, invalidJsonArgsResult, isCancelError, LoopCancelledError, parseToolArguments, planToolCall, runPlannedToolCall, runSerialToolPipeline, throwIfCancelled, } from "./tool-pipeline.js";
28
28
  import { getReadCacheStats } from "../tools/read-cache.js";
29
29
  import { emptyGoalProgress, GOAL_STALL_REPEATS, goalFollowUp, goalPausedNotice, goalReportAck, goalReportOutsideError, goalReportRejectedNotice, goalStallNudge, goalStallReached, goalVerdictNotice, noteGoalProgress, recentTurnsForJudge, resetGoalStall, sameGoalDisposition, updateGoalDisposition, validateUpdateGoalArgs, } from "../goal.js";
30
30
  import { bashExitCode, evaluateTurnEnd, isCodePath, isVerificationCommand, todoCompletionGate, verificationGate, } from "./gates.js";
31
31
  import { errorStreakFollowUp, ErrorStreakTracker, repetitionFollowUp, RepetitionGuard, repetitionStopNotice, } from "./loop-guard.js";
32
- import { normalizeChatResult, normalizeToolResult, toolSignature } from "./normalize.js";
33
- // Whole-turn cancellation: thrown when the user cancels (Ctrl+C) mid-loop.
34
- // The App catches it, rolls the partial turn back (same splice contract as
35
- // POST failure), renders one dim `(cancelled)` line, and returns to a clean
36
- // input state. Never retried, never a tool result.
37
- export class LoopCancelledError extends Error {
38
- constructor() {
39
- super("(cancelled)");
40
- this.name = "LoopCancelledError";
41
- }
42
- }
43
- export function isCancelError(e) {
44
- if (e instanceof LoopCancelledError)
45
- return true;
46
- if (e instanceof Error && e.name === "LoopCancelledError")
47
- return true;
48
- // fetch abort surfaces as DOMException AbortError (or Error with that name
49
- // in mocks). Treat any AbortError as a cancellation, never a retry.
50
- if (e instanceof Error && e.name === "AbortError")
51
- return true;
52
- if (typeof DOMException !== "undefined" && e instanceof DOMException && e.name === "AbortError") {
53
- return true;
54
- }
55
- return false;
56
- }
57
- export function throwIfCancelled(signal) {
58
- if (signal?.aborted)
59
- throw new LoopCancelledError();
60
- }
32
+ import { normalizeChatResult, toolSignature } from "./normalize.js";
33
+ import { emitTurnEvent } from "./turn-events.js";
34
+ // Compat re-exports: the cancel primitives and the tool-timeout helpers now
35
+ // live in the pipeline module (./tool-pipeline.js); loop.js re-exports them
36
+ // so existing `from "./agent/loop.js"` importers (via zen) keep working.
37
+ export { DEFAULT_TOOL_TIMEOUT_MS, executeWithTimeout, isCancelError, LoopCancelledError, resolveToolTimeoutMs, throwIfCancelled, } from "./tool-pipeline.js";
61
38
  // Tool-round budget for one agentic turn (env → atom.json → unlimited).
62
39
  // No default cap: the turn runs until the model ends it, a gate stops it,
63
40
  // or the user cancels. An explicit `opts.maxSteps` (or env/file value) still
@@ -92,19 +69,6 @@ export function emptyResponseFollowUp(attempt) {
92
69
  `the turn cannot end on silence. Continue with tool calls toward the goal, ` +
93
70
  `or answer in text. If there is genuinely nothing to do, end by saying so explicitly.)`);
94
71
  }
95
- // Per-tool outer timeout (ms): undefined → default 60s (enabled); explicit
96
- // <=0/NaN → disabled (direct await, zero overhead). Clamped 1s–120s when
97
- // enabled so a stuck executor can never hang the turn past the bash ceiling.
98
- export const DEFAULT_TOOL_TIMEOUT_MS = 60_000;
99
- export function resolveToolTimeoutMs(raw) {
100
- if (raw === undefined)
101
- return DEFAULT_TOOL_TIMEOUT_MS;
102
- if (typeof raw !== "number" || !Number.isFinite(raw))
103
- return DEFAULT_TOOL_TIMEOUT_MS;
104
- if (raw <= 0)
105
- return null;
106
- return Math.min(Math.max(Math.floor(raw), 1000), 120_000);
107
- }
108
72
  // Total tool-call budget per turn (no default cap; explicit opts value only,
109
73
  // min 1). Previously defaulted to 200 as a parallel-batch explosion guard.
110
74
  // Deprecated alias kept for import compatibility; the loop no longer uses it.
@@ -114,100 +78,16 @@ export function resolveMaxTotalToolCalls(raw) {
114
78
  return Number.POSITIVE_INFINITY;
115
79
  return Math.max(1, Math.floor(raw));
116
80
  }
117
- // Race one execution against the outer timeout. Timeout resolves to an
118
- // `Error:` result (the model adapts); the underlying promise is left to
119
- // settle executors own their own cleanup.
120
- //
121
- // Cancellation is DELIBERATELY not raced here: the pinned contract is that
122
- // an in-flight tool runs to completion and its result IS recorded, with the
123
- // cancel stopping the turn before the next batch/POST (see the
124
- // throwIfCancelled checks between batches and before each POST). Racing
125
- // abort against the execution would drop the in-flight result and break
126
- // assistant/tool pairing guarantees the tests pin. A hung tool + cancel
127
- // therefore waits for the timeout (≤60s), commits the timeout error, then
128
- // the next boundary check throws LoopCancelledError.
129
- export async function executeWithTimeout(execute, name, parsed, timeoutMs, signal) {
130
- // No new executions after a cancel: refuse to start when already aborted.
131
- if (signal?.aborted)
132
- throw new LoopCancelledError();
133
- if (timeoutMs === null) {
134
- return execute(name, parsed);
135
- }
136
- let timer = null;
137
- try {
138
- const execP = execute(name, parsed);
139
- const timeoutP = new Promise((_resolve, reject) => {
140
- timer = setTimeout(() => {
141
- const err = new Error(`timeout after ${timeoutMs}ms`);
142
- err.code = "ToolTimeout";
143
- reject(err);
144
- }, timeoutMs);
145
- });
146
- try {
147
- return await Promise.race([execP, timeoutP]);
148
- }
149
- catch (e) {
150
- if (isCancelError(e))
151
- throw e;
152
- if (e?.code === "ToolTimeout" || e?.message?.startsWith("timeout after ")) {
153
- return `Error: ${name} timed out after ${timeoutMs}ms — retry with a narrower scope or smaller input.`;
154
- }
155
- throw e;
156
- }
157
- }
158
- finally {
159
- if (timer)
160
- clearTimeout(timer);
161
- }
162
- }
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
- }
81
+ // Per-call pipeline stages (hook validate approve execute
82
+ // post-hook) live in ./tool-pipeline.js the serial driver calls
83
+ // runSerialToolPipeline, the parallel pre-pass plans each member via
84
+ // planToolCall and members execute via runPlannedToolCall (the same
85
+ // plan/run pair, so the paths share every stage by construction).
207
86
  // 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).
87
+ // permission + the registry-intercepted stage. Model mistakes (unknown
88
+ // name, invalid args) return repairs-oriented results WITHOUT executing;
89
+ // cancellations propagate as LoopCancelledError (never a result, never
90
+ // retried).
211
91
  // Everything else returns the result string plus the effective (post-
212
92
  // rewrite) args the commit funnel must use for gates, labels, and pairing:
213
93
  // - Hook-vs-approval ordering: pre-hooks run BEFORE validation and
@@ -217,143 +97,12 @@ async function runAfterIntercept(name, parsed, result, isError) {
217
97
  // re-validate before execution, so a hook can never smuggle unvalidated
218
98
  // args into an executor.
219
99
  // - 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.
100
+ // The registry roster (toolNames/isInterceptedTool) is the only name
101
+ // authority the loop holds no per-tool branch.
222
102
  // - ask_question never needs approval; without an askUser hook it resolves
223
103
  // to "Error: ask_question has no UI hook".
224
104
  // - update_goal never needs approval either; without the per-turn recorder
225
105
  // (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) {
231
- const name = call?.function?.name ?? "(unknown)";
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 };
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)";
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.
254
- const detail = validateToolArgs(name, parsed);
255
- if (detail) {
256
- return { result: invalidCall(detail), args: parsed };
257
- }
258
- if (name === "ask_question") {
259
- throwIfCancelled(opts?.signal);
260
- // If the signal aborts during the modal, runAskQuestion rejects with
261
- // LoopCancelledError (no result). If it resolves just as the signal
262
- // aborts, return the result — the loop records it, then stops before
263
- // the next POST (no new POSTs, pairing stays valid until rollback).
264
- return { result: await runAskQuestion(parsed, opts?.askUser, opts?.signal), args: parsed };
265
- }
266
- if (name === "update_goal") {
267
- throwIfCancelled(opts?.signal);
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 };
281
- }
282
- // "once"/"always" run this call (the caller caches the always-allowed set
283
- // session-wide so later calls skip the prompt).
284
- // No new executions after a cancel: stop after the current tool finishes.
285
- // The current tool (if already running) is awaited to completion and its
286
- // result IS recorded — the loop then stops before the next tool/POST, so
287
- // assistant/tool pairing stays valid until the caller rolls back.
288
- throwIfCancelled(opts?.signal);
289
- const timeoutMs = resolveToolTimeoutMs(opts?.toolTimeoutMs);
290
- const doNormalize = opts?.normalizeResults !== false;
291
- try {
292
- const raw = await executeWithTimeout(execute, name, parsed, timeoutMs, opts?.signal);
293
- if (doNormalize)
294
- return { result: normalizeToolResult(raw), args: parsed };
295
- return { result: typeof raw === "string" ? raw : normalizeToolResult(raw), args: parsed };
296
- }
297
- catch (e) {
298
- if (isCancelError(e) || opts?.signal?.aborted)
299
- throw new LoopCancelledError();
300
- throw e;
301
- }
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
- }
332
- async function runAskQuestion(parsed, askUser, signal) {
333
- const invalid = validateAskQuestionArgs(parsed);
334
- if (invalid)
335
- return invalid;
336
- if (!askUser)
337
- return "Error: ask_question has no UI hook";
338
- const q = parsed;
339
- const allowCustom = q.allowCustom === true;
340
- try {
341
- const answer = await askUser(q.question, q.options, allowCustom);
342
- if (typeof answer === "string" && answer.startsWith("Error:"))
343
- return answer;
344
- return JSON.stringify({ answer });
345
- }
346
- catch (e) {
347
- // Whole-turn cancellation (Ctrl+C) propagates — it is NOT the Esc
348
- // question-cancel result below.
349
- if (isCancelError(e) || signal?.aborted)
350
- throw new LoopCancelledError();
351
- const msg = e instanceof Error ? e.message : String(e);
352
- if (/cancel/i.test(msg))
353
- return "Error: question cancelled by user";
354
- return `Error: ${msg}`;
355
- }
356
- }
357
106
  // Shared agentic-loop core: the SINGLE loop implementation backing both
358
107
  // runAgenticLoop and runAgenticLoopForProvider (same tool/rollback contract).
359
108
  // Sequencing: each assistant message's tool_calls block is partitioned by
@@ -376,6 +125,11 @@ export async function runLoopWithChat(chatFn, history, opts) {
376
125
  // Every hook call below is guarded, so telemetry can never break the turn;
377
126
  // absent → a few Date.now() reads per call, negligible and identical.
378
127
  const telemetry = opts?.telemetry;
128
+ // Ordered turn-event sink (see src/agent/turn-events.ts): additive-only
129
+ // observer beside the callbacks below. Every emission goes through
130
+ // emitTurnEvent (guarded, never throws); absent → no reporting, and the
131
+ // pass-through wrappers below collapse to the original callbacks.
132
+ const sink = opts?.turnEvents;
379
133
  const telemetryIso = (ms) => {
380
134
  try {
381
135
  return new Date(ms).toISOString();
@@ -409,6 +163,12 @@ export async function runLoopWithChat(chatFn, history, opts) {
409
163
  // observer errors never break the loop
410
164
  }
411
165
  };
166
+ // TurnEvents phase mirror: reports the same phase + detail the loop just
167
+ // sent to onPhase. Always called AFTER the callback block, so callback side
168
+ // effects keep their exact order; guarded, so the sink never breaks the turn.
169
+ const reportPhase = (phase, detail) => {
170
+ emitTurnEvent(sink, (s) => s.onPhase?.(phase, detail));
171
+ };
412
172
  // Explicit verification state (no transcript parsing — the gate reads
413
173
  // these, never model prose):
414
174
  // - filesWritten: any write/edit executed (legacy compat signal).
@@ -599,12 +359,37 @@ export async function runLoopWithChat(chatFn, history, opts) {
599
359
  let msg;
600
360
  const modelStart = Date.now();
601
361
  try {
362
+ // TurnEvents pass-through wrappers: each original callback runs first,
363
+ // exactly as before (a throwing callback still propagates to chatFn),
364
+ // then the same fact is reported to the sink (guarded, never throws).
365
+ // onToolDelta/onWarning have no sink kinds and pass through untouched.
366
+ // When the sink is absent the wrapper still calls the original — and a
367
+ // field with neither callback nor sink stays undefined, so chatFn sees
368
+ // the same shape it always did.
369
+ const sinkToken = sink?.onToken !== undefined;
370
+ const sinkPhase = sink?.onPhase !== undefined;
371
+ const sinkThinking = sink?.onThinking !== undefined;
602
372
  msg = await chatFn(history, {
603
- onToken: opts?.onToken,
604
- onPhase: opts?.onPhase,
373
+ onToken: opts?.onToken !== undefined || sinkToken
374
+ ? (text) => {
375
+ opts?.onToken?.(text);
376
+ emitTurnEvent(sink, (s) => s.onToken?.(text));
377
+ }
378
+ : undefined,
379
+ onPhase: opts?.onPhase !== undefined || sinkPhase
380
+ ? (phase, detail) => {
381
+ opts?.onPhase?.(phase, detail);
382
+ emitTurnEvent(sink, (s) => s.onPhase?.(phase, detail));
383
+ }
384
+ : undefined,
605
385
  onToolDelta: opts?.onToolDelta,
606
386
  onWarning: opts?.onWarning,
607
- onThinking: opts?.onThinking,
387
+ onThinking: opts?.onThinking !== undefined || sinkThinking
388
+ ? (thinking) => {
389
+ opts?.onThinking?.(thinking);
390
+ emitTurnEvent(sink, (s) => s.onThinking?.(thinking));
391
+ }
392
+ : undefined,
608
393
  sleep: opts?.sleep,
609
394
  reasoningEffort: opts?.reasoningEffort,
610
395
  signal,
@@ -796,6 +581,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
796
581
  catch {
797
582
  // ignore
798
583
  }
584
+ reportPhase("done");
799
585
  return outcome.finalText;
800
586
  }
801
587
  if (judgeError !== null || verdict === null) {
@@ -810,6 +596,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
810
596
  catch {
811
597
  // ignore
812
598
  }
599
+ reportPhase("done");
813
600
  return outcome.finalText;
814
601
  }
815
602
  disposition = verdict;
@@ -864,6 +651,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
864
651
  catch {
865
652
  // ignore
866
653
  }
654
+ reportPhase("done");
867
655
  return outcome.finalText;
868
656
  }
869
657
  // A guard-style continue: not a turn end, so no goal-turn
@@ -888,6 +676,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
888
676
  catch {
889
677
  // ignore
890
678
  }
679
+ reportPhase("done");
891
680
  return final;
892
681
  }
893
682
  if (disposition !== null &&
@@ -937,6 +726,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
937
726
  catch {
938
727
  // ignore
939
728
  }
729
+ reportPhase("done");
940
730
  return outcome.finalText;
941
731
  }
942
732
  noteGoalTurn();
@@ -967,6 +757,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
967
757
  catch {
968
758
  // ignore
969
759
  }
760
+ reportPhase("done");
970
761
  return outcome.finalText;
971
762
  }
972
763
  if (step >= maxSteps) {
@@ -991,6 +782,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
991
782
  catch {
992
783
  // ignore
993
784
  }
785
+ reportPhase("done");
994
786
  return notice;
995
787
  }
996
788
  // Total tool-call budget (explicit `opts.maxTotalToolCalls` only;
@@ -1016,28 +808,36 @@ export async function runLoopWithChat(chatFn, history, opts) {
1016
808
  catch {
1017
809
  // ignore
1018
810
  }
811
+ reportPhase("done");
1019
812
  return notice;
1020
813
  }
1021
814
  history.push({ role: "assistant", content: msg.content ?? null, tool_calls: calls });
1022
- // Commit helper shared by the serial and parallel paths: Task 7
815
+ // Commit funnel shared by the serial and parallel paths: Task 7
1023
816
  // bookkeeping + one ordered transcript entry per call. Only successful
1024
817
  // executions count — denials, validation errors, and unknown tools (all
1025
818
  // `Error:` results) never ran, so they neither arm nor clear the gate.
819
+ //
820
+ // The funnel returns the commit receipt (see ToolCallReceipt in
821
+ // ./tool-pipeline.js): the effective args plus the final post-patch
822
+ // result. The serial driver reports telemetry from this receipt AFTER
823
+ // the commit, so telemetry, history, and activity all observe the same
824
+ // values. The parallel path keeps its pre-commit telemetry reporting
825
+ // untouched (ticket 05 owns that path) and ignores the return.
1026
826
  const commitToolResult = async (name, parsed, call, result, durationMs) => {
1027
827
  const baseIsError = typeof result === "string" && result.startsWith("Error");
1028
828
  // Extension post-hooks observe the raw commit candidate first (every
1029
829
  // committed result: executions, blocks, denials, validation errors);
1030
830
  // the caller's onToolResult hook runs last on the patched version.
1031
831
  // 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);
832
+ // and ordering are untouched; throwing patchers fail open inside the
833
+ // pipeline module.
834
+ const patched = await applyCommitPatches(name, parsed, result, baseIsError, opts?.onToolResult);
1035
835
  // Veto: skip the commit entirely — no counters, no gates, no history,
1036
836
  // 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;
837
+ if (patched.veto)
838
+ return { committed: false, result: patched.content, isError: patched.isError };
839
+ const finalResult = patched.content;
840
+ const isError = patched.isError;
1041
841
  toolCalls += 1;
1042
842
  if (isError)
1043
843
  failures += 1;
@@ -1089,7 +889,12 @@ export async function runLoopWithChat(chatFn, history, opts) {
1089
889
  catch {
1090
890
  // ignore observer errors
1091
891
  }
1092
- return true;
892
+ // TurnEvents: the commit funnel is the single commit-order point — every
893
+ // committed result reports its stable identity here, right after the
894
+ // activity callback. Vetoed results return above with neither activity
895
+ // nor sink event, exactly matching the callbacks.
896
+ emitTurnEvent(sink, (s) => s.onToolFinished?.({ toolCallId: call?.id ?? "", name, isError }));
897
+ return { committed: true, result: finalResult, isError };
1093
898
  };
1094
899
  // Length-truncated response (the output limit cut the tool arguments
1095
900
  // off): NOTHING executes — every carried call commits a repair-oriented
@@ -1107,40 +912,55 @@ export async function runLoopWithChat(chatFn, history, opts) {
1107
912
  `nothing was executed. Re-issue the call with complete arguments ` +
1108
913
  `(narrow the scope or split into smaller calls if it keeps truncating).`;
1109
914
  const toolAt = Date.now();
915
+ // Truncated arguments are often not valid JSON (cut mid-string) —
916
+ // fall back to {} for bookkeeping (an error result never arms the
917
+ // verification gate, so this only shapes the activity label).
918
+ const parsed = parseToolArguments(call?.function?.arguments) ?? {};
919
+ // TurnEvents: truncated calls never ran, so no phase fired — report
920
+ // the start here so every finished keeps a preceding started.
921
+ emitTurnEvent(sink, (s) => s.onToolStarted?.({ toolCallId: call?.id ?? "", name, index: i }));
922
+ // Commit first, then report telemetry from the receipt: one receipt
923
+ // (effective args + final post-patch result) for telemetry, history,
924
+ // and activity alike.
925
+ const commit = await commitToolResult(name, parsed, call, result);
926
+ const receipt = {
927
+ toolCallId: call?.id ?? "",
928
+ name,
929
+ args: parsed,
930
+ result: commit.result,
931
+ isError: commit.isError,
932
+ durationMs: 0,
933
+ committed: commit.committed,
934
+ decision: "truncated",
935
+ };
1110
936
  reportToolCall({
1111
937
  step,
1112
- toolCallId: call?.id ?? "",
938
+ toolCallId: receipt.toolCallId,
1113
939
  name,
1114
940
  startedAt: telemetryIso(toolAt),
1115
941
  endedAt: telemetryIso(toolAt),
1116
942
  durationMs: 0,
1117
- argsJson: telemetryArgsJson(call?.function?.arguments ?? "{}"),
1118
- result,
943
+ argsJson: telemetryArgsJson(receipt.args),
944
+ result: receipt.result,
1119
945
  batchIndex: i,
1120
946
  batchSize: calls.length,
1121
947
  });
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
948
  }
1136
949
  continue;
1137
950
  }
1138
- for (const batch of planBatches(calls)) {
951
+ // Static registry snapshot (ticket 05): captured once per tool_calls
952
+ // block at plan time and threaded through planning, so a mid-turn
953
+ // extension registration cannot reshape an already-planned batch.
954
+ const registrySnapshot = captureSchedulerSnapshot();
955
+ for (const batch of planBatches(calls, registrySnapshot)) {
1139
956
  // No new executions after a cancel: the current tool (if any) already
1140
957
  // finished; stop before starting the next batch.
1141
958
  throwIfCancelled(signal);
1142
959
  if (batch.length === 1) {
1143
- // Serial path: byte-identical to the pre-05 loop body.
960
+ // Serial path: one call through the pipeline module
961
+ // (hook → validate → approve → execute), committed through the
962
+ // shared funnel, with telemetry reported from the commit receipt —
963
+ // one receipt for telemetry, history, and activity alike.
1144
964
  const call = batch[0].call;
1145
965
  const name = call?.function?.name ?? "(unknown)";
1146
966
  try {
@@ -1149,42 +969,53 @@ export async function runLoopWithChat(chatFn, history, opts) {
1149
969
  catch {
1150
970
  // ignore
1151
971
  }
972
+ // TurnEvents: phase mirror + start of this tool transition, keyed by
973
+ // the stable tool_call_id — never the display label.
974
+ reportPhase("tool", name);
975
+ emitTurnEvent(sink, (s) => s.onToolStarted?.({ toolCallId: call?.id ?? "", name, index: calls.indexOf(call) }));
1152
976
  const toolStart = Date.now();
1153
- let parsed;
1154
- try {
1155
- const raw = call?.function?.arguments ?? "{}";
1156
- const v = JSON.parse(typeof raw === "string" ? raw : "{}");
1157
- parsed = typeof v === "object" && v !== null ? v : {};
1158
- }
1159
- catch {
1160
- parsed = {};
1161
- const result = `Error: invalid call: invalid JSON arguments for tool "${name}" (arguments must be valid JSON). Fix the arguments and retry.`;
977
+ // One serial telemetry report from a commit receipt (effective args
978
+ // + final post-patch result). Thrown executions never commit, so
979
+ // they keep their attempt-shaped report in the catch below.
980
+ const reportSerialReceipt = (receipt, toolEndMs) => {
981
+ reportToolCall({
982
+ step,
983
+ toolCallId: receipt.toolCallId,
984
+ name: receipt.name,
985
+ startedAt: telemetryIso(toolStart),
986
+ endedAt: telemetryIso(toolEndMs),
987
+ durationMs: Math.max(0, toolEndMs - toolStart),
988
+ argsJson: telemetryArgsJson(receipt.args),
989
+ result: receipt.result,
990
+ batchIndex: 0,
991
+ batchSize: 1,
992
+ });
993
+ };
994
+ const unparsed = parseToolArguments(call?.function?.arguments);
995
+ if (unparsed === null) {
1162
996
  // Invalid JSON never executes — route through the commit funnel so
1163
997
  // the result hook still sees every committed result. Bookkeeping
1164
998
  // (counters, error streak, bottleneck, history, activity) is
1165
999
  // identical to the inline block this replaced; only the committed
1166
1000
  // content may differ when a hook rewrites it.
1001
+ const parsed = {};
1002
+ const result = invalidJsonArgsResult(name);
1167
1003
  repGuard.note(toolSignature(name, parsed), name);
1168
1004
  const toolEnd = Date.now();
1169
- reportToolCall({
1170
- step,
1005
+ const commit = await commitToolResult(name, parsed, call, result, Math.max(0, toolEnd - toolStart));
1006
+ reportSerialReceipt({
1171
1007
  toolCallId: call?.id ?? "",
1172
1008
  name,
1173
- startedAt: telemetryIso(toolStart),
1174
- endedAt: telemetryIso(toolEnd),
1009
+ args: parsed,
1010
+ result: commit.result,
1011
+ isError: commit.isError,
1175
1012
  durationMs: Math.max(0, toolEnd - toolStart),
1176
- argsJson: telemetryArgsJson(call?.function?.arguments ?? "{}"),
1177
- result,
1178
- batchIndex: 0,
1179
- batchSize: 1,
1180
- });
1181
- await commitToolResult(name, parsed, call, result, Math.max(0, toolEnd - toolStart));
1013
+ committed: commit.committed,
1014
+ decision: "invalid-json",
1015
+ }, toolEnd);
1182
1016
  continue;
1183
1017
  }
1184
- let result;
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;
1018
+ const parsed = unparsed;
1188
1019
  // Repetition guard (opt-in via maxRepeatedCalls; unset = track-only):
1189
1020
  // a repeated signature skips execution and yields a guidance error;
1190
1021
  // exhausted nudges stop hard.
@@ -1192,37 +1023,21 @@ export async function runLoopWithChat(chatFn, history, opts) {
1192
1023
  const repNote = repGuard.note(repSig, name);
1193
1024
  if (repNote.intervened) {
1194
1025
  const toolEndRep = Date.now();
1195
- if (repGuard.consumeNudge()) {
1196
- const guarded = `Error: invalid call: ${repetitionFollowUp(repSig, repNote.consecutive)} Fix the approach and retry.`;
1197
- reportToolCall({
1198
- step,
1199
- toolCallId: call?.id ?? "",
1200
- name,
1201
- startedAt: telemetryIso(toolStart),
1202
- endedAt: telemetryIso(toolEndRep),
1203
- durationMs: 0,
1204
- argsJson: telemetryArgsJson(parsed),
1205
- result: guarded,
1206
- batchIndex: 0,
1207
- batchSize: 1,
1208
- });
1209
- await commitToolResult(name, parsed, call, guarded, 0);
1210
- continue;
1211
- }
1026
+ const hasNudge = repGuard.consumeNudge();
1212
1027
  const guarded = `Error: invalid call: ${repetitionFollowUp(repSig, repNote.consecutive)} Fix the approach and retry.`;
1213
- reportToolCall({
1214
- step,
1028
+ const commit = await commitToolResult(name, parsed, call, guarded, 0);
1029
+ reportSerialReceipt({
1215
1030
  toolCallId: call?.id ?? "",
1216
1031
  name,
1217
- startedAt: telemetryIso(toolStart),
1218
- endedAt: telemetryIso(toolEndRep),
1032
+ args: parsed,
1033
+ result: commit.result,
1034
+ isError: commit.isError,
1219
1035
  durationMs: 0,
1220
- argsJson: telemetryArgsJson(parsed),
1221
- result: guarded,
1222
- batchIndex: 0,
1223
- batchSize: 1,
1224
- });
1225
- await commitToolResult(name, parsed, call, guarded, 0);
1036
+ committed: commit.committed,
1037
+ decision: "repetition-guard",
1038
+ }, toolEndRep);
1039
+ if (hasNudge)
1040
+ continue;
1226
1041
  const stopBase = msg.content ?? "";
1227
1042
  const stopNotice = `${stopBase}${stopBase ? "\n" : ""}${repetitionStopNotice(repSig, repNote.consecutive)}`;
1228
1043
  history.push({ role: "assistant", content: stopNotice });
@@ -1232,12 +1047,12 @@ export async function runLoopWithChat(chatFn, history, opts) {
1232
1047
  catch {
1233
1048
  // ignore
1234
1049
  }
1050
+ reportPhase("done");
1235
1051
  return stopNotice;
1236
1052
  }
1053
+ let outcome;
1237
1054
  try {
1238
- const one = await runOneTool(call, parsed, opts, execute, undefined, recordGoalReport);
1239
- result = one.result;
1240
- effectiveArgs = one.args;
1055
+ outcome = await runSerialToolPipeline(call, parsed, opts, execute, recordGoalReport);
1241
1056
  }
1242
1057
  catch (e) {
1243
1058
  // A cancelled/throwing tool still records its attempt (with the
@@ -1267,22 +1082,18 @@ export async function runLoopWithChat(chatFn, history, opts) {
1267
1082
  throw new LoopCancelledError();
1268
1083
  throw e;
1269
1084
  }
1270
- {
1271
- const toolEnd = Date.now();
1272
- reportToolCall({
1273
- step,
1274
- toolCallId: call?.id ?? "",
1275
- name,
1276
- startedAt: telemetryIso(toolStart),
1277
- endedAt: telemetryIso(toolEnd),
1278
- durationMs: Math.max(0, toolEnd - toolStart),
1279
- argsJson: telemetryArgsJson(effectiveArgs),
1280
- result,
1281
- batchIndex: 0,
1282
- batchSize: 1,
1283
- });
1284
- }
1285
- await commitToolResult(name, effectiveArgs, call, result, Math.max(0, Date.now() - toolStart));
1085
+ const toolEnd = Date.now();
1086
+ const commit = await commitToolResult(name, outcome.args, call, outcome.result, Math.max(0, Date.now() - toolStart));
1087
+ reportSerialReceipt({
1088
+ toolCallId: call?.id ?? "",
1089
+ name,
1090
+ args: outcome.args,
1091
+ result: commit.result,
1092
+ isError: commit.isError,
1093
+ durationMs: Math.max(0, toolEnd - toolStart),
1094
+ committed: commit.committed,
1095
+ decision: outcome.decision,
1096
+ }, toolEnd);
1286
1097
  continue;
1287
1098
  }
1288
1099
  // Parallel batch: every member is pre-validated parallel-safe (see
@@ -1300,6 +1111,14 @@ export async function runLoopWithChat(chatFn, history, opts) {
1300
1111
  catch {
1301
1112
  // ignore
1302
1113
  }
1114
+ // TurnEvents: phase mirror + start per member, in call order, keyed
1115
+ // by the stable tool_call_id — never the display label.
1116
+ reportPhase("tool", member.call?.function?.name ?? "(unknown)");
1117
+ emitTurnEvent(sink, (s) => s.onToolStarted?.({
1118
+ toolCallId: member.call?.id ?? "",
1119
+ name: member.call?.function?.name ?? "(unknown)",
1120
+ index: calls.indexOf(member.call),
1121
+ }));
1303
1122
  }
1304
1123
  let results;
1305
1124
  const memberDurations = new Array(batch.length).fill(0);
@@ -1314,6 +1133,14 @@ export async function runLoopWithChat(chatFn, history, opts) {
1314
1133
  repHardStop = { sig: note.signature, consecutive: note.consecutive };
1315
1134
  }
1316
1135
  }
1136
+ // Serial planning pre-pass (in call order, skipping repetition-guarded
1137
+ // members exactly as the serial path would): each member plans through
1138
+ // the shared planToolCall runner — unknown-name gate, pre-hooks,
1139
+ // re-validation, then the approval decision — so rewrites reach the
1140
+ // approval prompt, a block skips approval entirely, and prompts still
1141
+ // resolve before any member executes (concurrent writes never prompt
1142
+ // at once). Cancel between prompts aborts the batch with nothing
1143
+ // executed.
1317
1144
  const memberPlans = new Map();
1318
1145
  const preDecisions = new Map();
1319
1146
  for (let i = 0; i < batch.length; i++) {
@@ -1322,24 +1149,10 @@ export async function runLoopWithChat(chatFn, history, opts) {
1322
1149
  continue;
1323
1150
  const member = batch[i];
1324
1151
  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);
1152
+ const planned = await planToolCall(memberName, member.parsed, opts);
1153
+ memberPlans.set(i, planned.plan);
1154
+ if (planned.preDecision !== null)
1155
+ preDecisions.set(i, planned.preDecision);
1343
1156
  }
1344
1157
  try {
1345
1158
  // Each member is timed individually (concurrent wall-clock per call,
@@ -1368,46 +1181,13 @@ export async function runLoopWithChat(chatFn, history, opts) {
1368
1181
  return guarded;
1369
1182
  }
1370
1183
  try {
1371
- // Pre-resolved members (blocked/invalid) never reach the
1372
- // executor: their inline results commit in call order below.
1184
+ // Planned members run the shared runner concurrently: inline
1185
+ // results (unknown/blocked/invalid/denied) never reach the
1186
+ // executor; executions share validate → approve → execute with
1187
+ // the serial path by construction. The pre-pass guarantees a
1188
+ // plan for every non-guarded member (guarded ones return above).
1373
1189
  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);
1190
+ const outcome = await runPlannedToolCall(member.call, plan, preDecisions.get(index) ?? null, opts, execute, recordGoalReport);
1411
1191
  const memberEnd = Date.now();
1412
1192
  memberDurations[index] = Math.max(0, memberEnd - memberStart);
1413
1193
  reportToolCall({
@@ -1417,12 +1197,12 @@ export async function runLoopWithChat(chatFn, history, opts) {
1417
1197
  startedAt: telemetryIso(memberStart),
1418
1198
  endedAt: telemetryIso(memberEnd),
1419
1199
  durationMs: Math.max(0, memberEnd - memberStart),
1420
- argsJson: telemetryArgsJson(plan?.args ?? member.parsed),
1421
- result: r.result,
1200
+ argsJson: telemetryArgsJson(outcome.args),
1201
+ result: outcome.result,
1422
1202
  batchIndex: index,
1423
1203
  batchSize: batch.length,
1424
1204
  });
1425
- return r.result;
1205
+ return outcome.result;
1426
1206
  }
1427
1207
  catch (e) {
1428
1208
  const memberEnd = Date.now();
@@ -1468,6 +1248,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
1468
1248
  catch {
1469
1249
  // ignore
1470
1250
  }
1251
+ reportPhase("done");
1471
1252
  return stopNotice;
1472
1253
  }
1473
1254
  }