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