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.
- package/CHANGELOG.md +62 -0
- package/README.md +220 -224
- package/dist/App.js +922 -341
- package/dist/adapters.js +127 -14
- package/dist/agent/goal-evaluator.js +3 -0
- package/dist/agent/loop.js +211 -430
- package/dist/agent/tool-pipeline.js +398 -0
- package/dist/agent/turn-events.js +12 -0
- package/dist/cli.js +57 -8
- package/dist/compact.js +72 -8
- package/dist/config.js +19 -0
- package/dist/context-manager.js +6 -2
- package/dist/extensions.js +6 -0
- package/dist/file-diffs.js +108 -0
- package/dist/kilo.js +1 -1
- package/dist/local-discovery.js +2 -2
- package/dist/media.js +276 -0
- package/dist/overflow.js +140 -0
- package/dist/policy.js +8 -0
- package/dist/scheduler.js +38 -9
- package/dist/session-revert.js +125 -0
- package/dist/sessions.js +101 -0
- package/dist/snapshots.js +69 -0
- package/dist/system.js +2 -89
- package/dist/telemetry.js +26 -1
- package/dist/todos.js +241 -0
- package/dist/tools/filesystem.js +102 -22
- package/dist/tools/registry.js +184 -45
- package/dist/tools/ripgrep.js +7 -6
- package/dist/tools/search.js +172 -17
- package/dist/tools/shared.js +6 -0
- package/dist/tools.js +7 -39
- package/dist/ui/diff-panel.js +5 -5
- package/dist/ui/diff-view.js +16 -7
- package/dist/ui/diff.js +73 -51
- package/dist/ui/errors.js +20 -6
- package/dist/ui/input.js +24 -20
- package/dist/ui/live-tail.js +36 -1
- package/dist/ui/markdown.js +9 -4
- package/dist/ui/modals.js +6 -4
- package/dist/ui/paint-scheduler.js +120 -0
- package/dist/ui/palette.js +4 -2
- package/dist/ui/pickers.js +4 -1
- package/dist/ui/side-by-side.js +88 -27
- package/dist/ui/status-bar.js +63 -8
- package/dist/ui/stream-store.js +7 -0
- package/dist/ui/theme.js +23 -1
- package/dist/ui/todo-panel.js +5 -2
- package/dist/ui/tool-inspector.js +33 -4
- package/dist/ui/transcript.js +9 -6
- package/dist/web/events.js +93 -0
- package/dist/web/runtime.js +790 -0
- package/dist/web/server.js +570 -0
- package/dist/web/ui/app.js +1925 -0
- package/dist/web/ui/index.html +135 -0
- package/dist/web/ui/styles.css +515 -0
- package/dist/zen.js +115 -4
- package/documentation/cli.md +5 -5
- package/documentation/configuration.md +11 -6
- package/documentation/development.md +4 -3
- package/documentation/extensions.md +1 -1
- package/documentation/goals.md +1 -1
- package/documentation/index.md +4 -4
- package/documentation/providers.md +2 -3
- package/documentation/skills.md +3 -3
- package/documentation/tools.md +8 -3
- package/documentation/troubleshooting.md +1 -1
- package/examples/extensions/01-audit-gate.js +2 -2
- package/examples/extensions/02-notes-tool.js +2 -2
- package/examples/extensions/03-custom-command.js +2 -2
- package/package.json +3 -2
package/dist/agent/loop.js
CHANGED
|
@@ -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
|
|
27
|
-
import {
|
|
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,
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
export
|
|
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
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
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 +
|
|
209
|
-
// args) return repairs-oriented results WITHOUT executing;
|
|
210
|
-
// propagate as LoopCancelledError (never a result, never
|
|
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
|
-
//
|
|
221
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
1033
|
-
|
|
1034
|
-
const
|
|
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 (
|
|
1038
|
-
return false;
|
|
1039
|
-
const finalResult =
|
|
1040
|
-
const 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
|
-
|
|
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:
|
|
938
|
+
toolCallId: receipt.toolCallId,
|
|
1113
939
|
name,
|
|
1114
940
|
startedAt: telemetryIso(toolAt),
|
|
1115
941
|
endedAt: telemetryIso(toolAt),
|
|
1116
942
|
durationMs: 0,
|
|
1117
|
-
argsJson: telemetryArgsJson(
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
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
|
-
|
|
1170
|
-
|
|
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
|
-
|
|
1174
|
-
|
|
1009
|
+
args: parsed,
|
|
1010
|
+
result: commit.result,
|
|
1011
|
+
isError: commit.isError,
|
|
1175
1012
|
durationMs: Math.max(0, toolEnd - toolStart),
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1214
|
-
|
|
1028
|
+
const commit = await commitToolResult(name, parsed, call, guarded, 0);
|
|
1029
|
+
reportSerialReceipt({
|
|
1215
1030
|
toolCallId: call?.id ?? "",
|
|
1216
1031
|
name,
|
|
1217
|
-
|
|
1218
|
-
|
|
1032
|
+
args: parsed,
|
|
1033
|
+
result: commit.result,
|
|
1034
|
+
isError: commit.isError,
|
|
1219
1035
|
durationMs: 0,
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
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
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
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
|
-
//
|
|
1372
|
-
//
|
|
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
|
-
|
|
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(
|
|
1421
|
-
result:
|
|
1200
|
+
argsJson: telemetryArgsJson(outcome.args),
|
|
1201
|
+
result: outcome.result,
|
|
1422
1202
|
batchIndex: index,
|
|
1423
1203
|
batchSize: batch.length,
|
|
1424
1204
|
});
|
|
1425
|
-
return
|
|
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
|
}
|