atom-agent 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -2
- package/README.md +17 -16
- package/dist/App.js +1010 -77
- package/dist/adapters.js +108 -8
- package/dist/agent/gates.js +14 -1
- package/dist/agent/loop-guard.js +182 -0
- package/dist/agent/loop.js +781 -329
- package/dist/agent/normalize.js +151 -0
- package/dist/cli.js +16 -2
- package/dist/compact.js +128 -2
- package/dist/env-block.js +43 -5
- package/dist/scheduler.js +101 -21
- package/dist/sessions.js +524 -0
- package/dist/system.js +89 -12
- package/dist/telemetry-dashboard.js +19 -1
- package/dist/telemetry.js +55 -0
- package/dist/tools/dir-cache.js +214 -0
- package/dist/tools/filesystem.js +43 -3
- package/dist/tools/read-cache.js +160 -0
- package/dist/tools/registry.js +80 -0
- package/dist/tools/ripgrep.js +256 -0
- package/dist/tools/search.js +147 -80
- package/dist/tools/shared.js +39 -0
- package/dist/tools/shell.js +26 -5
- package/dist/tools/todo.js +1 -1
- package/dist/tools/web.js +6 -6
- package/dist/tools.js +3 -0
- package/dist/ui/diff-panel.js +55 -0
- package/dist/ui/diff-view.js +117 -0
- package/dist/ui/diff.js +422 -0
- package/dist/ui/highlight.js +120 -0
- 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/modals.js +22 -5
- package/dist/ui/palette.js +12 -2
- package/dist/ui/side-by-side.js +144 -0
- package/dist/ui/status-bar.js +20 -4
- package/dist/ui/status-host.js +22 -0
- package/dist/ui/stream-store.js +48 -0
- package/dist/ui/theme.js +6 -0
- package/dist/ui/todo-panel.js +10 -2
- package/dist/ui/tool-inspector.js +7 -1
- package/dist/ui/transcript.js +105 -39
- package/dist/zen.js +97 -20
- package/package.json +1 -1
package/dist/agent/loop.js
CHANGED
|
@@ -9,18 +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
|
-
// Dependency direction: agent/loop -> {tools,
|
|
17
|
-
// context-manager, agent/gates, agent/
|
|
19
|
+
// Dependency direction: agent/loop -> {tools, tools/read-cache,
|
|
20
|
+
// scheduler, config, context-manager, agent/gates, agent/loop-guard,
|
|
21
|
+
// agent/normalize, agent/types} and NOT zen (transports stay in
|
|
18
22
|
// zen.ts; runAgenticLoopForProvider wraps this loop from there).
|
|
19
23
|
import { loadAtomConfig } from "../config.js";
|
|
20
|
-
import { createContextManager, historyCharBudget, historyMessageBudget, truncateHistoryWithCaps, } from "../context-manager.js";
|
|
24
|
+
import { createContextManager, historyCharBudget, historyChars, historyMessageBudget, truncateHistoryWithCaps, } from "../context-manager.js";
|
|
21
25
|
import { planBatches } from "../scheduler.js";
|
|
22
|
-
import { describeToolCall, executeTool, invalidCall,
|
|
26
|
+
import { describeToolCall, executeTool, invalidCall, needsApproval, toolNames, validateAskQuestionArgs, validateToolArgs, } from "../tools.js";
|
|
27
|
+
import { getReadCacheStats } from "../tools/read-cache.js";
|
|
23
28
|
import { bashExitCode, evaluateTurnEnd, isCodePath, isVerificationCommand, openTodoNeedles, } from "./gates.js";
|
|
29
|
+
import { errorStreakFollowUp, ErrorStreakTracker, repetitionFollowUp, RepetitionGuard, repetitionStopNotice, } from "./loop-guard.js";
|
|
30
|
+
import { normalizeChatResult, normalizeToolResult, toolSignature } from "./normalize.js";
|
|
24
31
|
// Whole-turn cancellation: thrown when the user cancels (Ctrl+C) mid-loop.
|
|
25
32
|
// The App catches it, rolls the partial turn back (same splice contract as
|
|
26
33
|
// POST failure), renders one dim `(cancelled)` line, and returns to a clean
|
|
@@ -49,9 +56,10 @@ export function throwIfCancelled(signal) {
|
|
|
49
56
|
if (signal?.aborted)
|
|
50
57
|
throw new LoopCancelledError();
|
|
51
58
|
}
|
|
52
|
-
// Tool-round budget for one agentic turn (env → atom.json →
|
|
53
|
-
//
|
|
54
|
-
//
|
|
59
|
+
// Tool-round budget for one agentic turn (env → atom.json → unlimited).
|
|
60
|
+
// No default cap: the turn runs until the model ends it, a gate stops it,
|
|
61
|
+
// or the user cancels. An explicit `opts.maxSteps` (or env/file value) still
|
|
62
|
+
// caps the turn (tests inject it).
|
|
55
63
|
export function toolStepBudget() {
|
|
56
64
|
const raw = process.env.ATOM_MAX_TOOL_STEPS;
|
|
57
65
|
if (raw !== undefined) {
|
|
@@ -62,7 +70,7 @@ export function toolStepBudget() {
|
|
|
62
70
|
return Math.min(Math.max(Math.floor(n), 5), 100);
|
|
63
71
|
}
|
|
64
72
|
}
|
|
65
|
-
return loadAtomConfig().config.maxToolSteps ??
|
|
73
|
+
return loadAtomConfig().config.maxToolSteps ?? Number.POSITIVE_INFINITY;
|
|
66
74
|
}
|
|
67
75
|
// Legacy trim entry: byte-identical contract (legacy env/config/default caps
|
|
68
76
|
// + live todo pinning, same notice, same in-place splice). New code should
|
|
@@ -71,6 +79,113 @@ export function toolStepBudget() {
|
|
|
71
79
|
export function truncateHistory(history, notify, reserve) {
|
|
72
80
|
return truncateHistoryWithCaps(history, { maxMessages: historyMessageBudget(), maxChars: historyCharBudget() }, { notify, reserve, todoNeedles: openTodoNeedles() });
|
|
73
81
|
}
|
|
82
|
+
// Empty-response recovery (live-proven on free-tier gateways: a 200-OK
|
|
83
|
+
// stream can carry only queue comments and reasoning with zero answer text
|
|
84
|
+
// and zero tool calls, which the transport reports as an `Empty reply`
|
|
85
|
+
// error). A failed POST normally aborts the turn — EXCEPT this one: ending
|
|
86
|
+
// the turn on model silence with no fallback makes flaky backends fatal, so
|
|
87
|
+
// the loop spends a bounded number of extra POSTs asking the model to repair
|
|
88
|
+
// (same assistant+user follow-up shape as the turn-end gates, so pairing
|
|
89
|
+
// stays valid). When the budget is spent the original error throws, exactly
|
|
90
|
+
// as before — the caller rolls back and the user sees it.
|
|
91
|
+
export const MAX_EMPTY_ROUNDS = 2;
|
|
92
|
+
export function isEmptyReplyError(e) {
|
|
93
|
+
return e instanceof Error && e.message.startsWith("Empty reply");
|
|
94
|
+
}
|
|
95
|
+
export function emptyResponseFollowUp(attempt) {
|
|
96
|
+
return (`(empty response: attempt ${attempt} returned no text and no tool calls — ` +
|
|
97
|
+
`the turn cannot end on silence. Continue with tool calls toward the goal, ` +
|
|
98
|
+
`or answer in text. If there is genuinely nothing to do, end by saying so explicitly.)`);
|
|
99
|
+
}
|
|
100
|
+
// Per-tool outer timeout (ms): undefined → default 60s (enabled); explicit
|
|
101
|
+
// <=0/NaN → disabled (direct await, zero overhead). Clamped 1s–120s when
|
|
102
|
+
// enabled so a stuck executor can never hang the turn past the bash ceiling.
|
|
103
|
+
export const DEFAULT_TOOL_TIMEOUT_MS = 60_000;
|
|
104
|
+
export function resolveToolTimeoutMs(raw) {
|
|
105
|
+
if (raw === undefined)
|
|
106
|
+
return DEFAULT_TOOL_TIMEOUT_MS;
|
|
107
|
+
if (typeof raw !== "number" || !Number.isFinite(raw))
|
|
108
|
+
return DEFAULT_TOOL_TIMEOUT_MS;
|
|
109
|
+
if (raw <= 0)
|
|
110
|
+
return null;
|
|
111
|
+
return Math.min(Math.max(Math.floor(raw), 1000), 120_000);
|
|
112
|
+
}
|
|
113
|
+
// Total tool-call budget per turn (no default cap; explicit opts value only,
|
|
114
|
+
// min 1). Previously defaulted to 200 as a parallel-batch explosion guard.
|
|
115
|
+
// Deprecated alias kept for import compatibility; the loop no longer uses it.
|
|
116
|
+
export const DEFAULT_MAX_TOTAL_TOOL_CALLS = Number.POSITIVE_INFINITY;
|
|
117
|
+
export function resolveMaxTotalToolCalls(raw) {
|
|
118
|
+
if (typeof raw !== "number" || !Number.isFinite(raw))
|
|
119
|
+
return Number.POSITIVE_INFINITY;
|
|
120
|
+
return Math.max(1, Math.floor(raw));
|
|
121
|
+
}
|
|
122
|
+
// Race one execution against the outer timeout. Timeout resolves to an
|
|
123
|
+
// `Error:` result (the model adapts); the underlying promise is left to
|
|
124
|
+
// settle — executors own their own cleanup.
|
|
125
|
+
//
|
|
126
|
+
// Cancellation is DELIBERATELY not raced here: the pinned contract is that
|
|
127
|
+
// an in-flight tool runs to completion and its result IS recorded, with the
|
|
128
|
+
// cancel stopping the turn before the next batch/POST (see the
|
|
129
|
+
// throwIfCancelled checks between batches and before each POST). Racing
|
|
130
|
+
// abort against the execution would drop the in-flight result and break
|
|
131
|
+
// assistant/tool pairing guarantees the tests pin. A hung tool + cancel
|
|
132
|
+
// therefore waits for the timeout (≤60s), commits the timeout error, then
|
|
133
|
+
// the next boundary check throws LoopCancelledError.
|
|
134
|
+
export async function executeWithTimeout(execute, name, parsed, timeoutMs, signal) {
|
|
135
|
+
// No new executions after a cancel: refuse to start when already aborted.
|
|
136
|
+
if (signal?.aborted)
|
|
137
|
+
throw new LoopCancelledError();
|
|
138
|
+
if (timeoutMs === null) {
|
|
139
|
+
return execute(name, parsed);
|
|
140
|
+
}
|
|
141
|
+
let timer = null;
|
|
142
|
+
try {
|
|
143
|
+
const execP = execute(name, parsed);
|
|
144
|
+
const timeoutP = new Promise((_resolve, reject) => {
|
|
145
|
+
timer = setTimeout(() => {
|
|
146
|
+
const err = new Error(`timeout after ${timeoutMs}ms`);
|
|
147
|
+
err.code = "ToolTimeout";
|
|
148
|
+
reject(err);
|
|
149
|
+
}, timeoutMs);
|
|
150
|
+
});
|
|
151
|
+
try {
|
|
152
|
+
return await Promise.race([execP, timeoutP]);
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
if (isCancelError(e))
|
|
156
|
+
throw e;
|
|
157
|
+
if (e?.code === "ToolTimeout" || e?.message?.startsWith("timeout after ")) {
|
|
158
|
+
return `Error: ${name} timed out after ${timeoutMs}ms — retry with a narrower scope or smaller input.`;
|
|
159
|
+
}
|
|
160
|
+
throw e;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
if (timer)
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Permission-gate resolution shared by the serial path and the parallel
|
|
169
|
+
// pre-pass: returns the hook's decision, or null when no approval applies
|
|
170
|
+
// (no hook, or a tool that never needs it). A "no" (or a throwing hook)
|
|
171
|
+
// denies without executing; cancellation always propagates.
|
|
172
|
+
async function resolveApproval(name, parsed, opts) {
|
|
173
|
+
if (!opts?.approve || !needsApproval(name))
|
|
174
|
+
return null;
|
|
175
|
+
try {
|
|
176
|
+
const decision = await opts.approve(name, parsed);
|
|
177
|
+
// Abort that lands as a resolved denial still cancels the whole turn.
|
|
178
|
+
throwIfCancelled(opts?.signal);
|
|
179
|
+
return decision;
|
|
180
|
+
}
|
|
181
|
+
catch (e) {
|
|
182
|
+
// Whole-turn cancellation must propagate (Ctrl+C cancels the turn,
|
|
183
|
+
// not just deny one call). Anything else is a denial.
|
|
184
|
+
if (isCancelError(e) || opts?.signal?.aborted)
|
|
185
|
+
throw new LoopCancelledError();
|
|
186
|
+
return "no";
|
|
187
|
+
}
|
|
188
|
+
}
|
|
74
189
|
// Execute one parsed tool call through validation + permission +
|
|
75
190
|
// ask_question gates. Model mistakes (unknown name, invalid args) return
|
|
76
191
|
// repairs-oriented results WITHOUT executing; cancellations propagate as
|
|
@@ -78,10 +193,11 @@ export function truncateHistory(history, notify, reserve) {
|
|
|
78
193
|
// returns a result string fed back to the model:
|
|
79
194
|
// - ask_question never needs approval; without an askUser hook it resolves
|
|
80
195
|
// to "Error: ask_question has no UI hook".
|
|
81
|
-
// - write/edit/bash consult the approve hook when one is provided
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
196
|
+
// - write/edit/bash consult the approve hook when one is provided (or a
|
|
197
|
+
// pre-resolved batch decision); a "no" resolves to
|
|
198
|
+
// "Error: denied by user: <tool>" (final, no retry/rollback). Without a
|
|
199
|
+
// hook every tool executes immediately.
|
|
200
|
+
async function runOneTool(call, parsed, opts, execute, preDecision) {
|
|
85
201
|
const name = call?.function?.name ?? "(unknown)";
|
|
86
202
|
// Unknown tool: model mistake — list actual names, never execute.
|
|
87
203
|
if (!toolNames().includes(name)) {
|
|
@@ -100,33 +216,24 @@ async function runOneTool(call, parsed, opts, execute) {
|
|
|
100
216
|
// the next POST (no new POSTs, pairing stays valid until rollback).
|
|
101
217
|
return runAskQuestion(parsed, opts?.askUser, opts?.signal);
|
|
102
218
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
decision = await opts.approve(name, parsed);
|
|
107
|
-
}
|
|
108
|
-
catch (e) {
|
|
109
|
-
// Whole-turn cancellation must propagate (Ctrl+C cancels the turn,
|
|
110
|
-
// not just deny one call). Anything else is a denial.
|
|
111
|
-
if (isCancelError(e) || opts?.signal?.aborted)
|
|
112
|
-
throw new LoopCancelledError();
|
|
113
|
-
decision = "no";
|
|
114
|
-
}
|
|
115
|
-
// Abort that lands as a resolved denial still cancels the whole turn.
|
|
116
|
-
throwIfCancelled(opts?.signal);
|
|
117
|
-
if (decision === "no") {
|
|
118
|
-
return `Error: denied by user: ${name}`;
|
|
119
|
-
}
|
|
120
|
-
// "once" runs this call; "always" runs it too (the caller caches the
|
|
121
|
-
// always-allowed set session-wide so later calls skip the prompt).
|
|
219
|
+
const decision = preDecision ?? (await resolveApproval(name, parsed, opts));
|
|
220
|
+
if (decision === "no") {
|
|
221
|
+
return `Error: denied by user: ${name}`;
|
|
122
222
|
}
|
|
223
|
+
// "once"/"always" run this call (the caller caches the always-allowed set
|
|
224
|
+
// session-wide so later calls skip the prompt).
|
|
123
225
|
// No new executions after a cancel: stop after the current tool finishes.
|
|
124
226
|
// The current tool (if already running) is awaited to completion and its
|
|
125
227
|
// result IS recorded — the loop then stops before the next tool/POST, so
|
|
126
228
|
// assistant/tool pairing stays valid until the caller rolls back.
|
|
127
229
|
throwIfCancelled(opts?.signal);
|
|
230
|
+
const timeoutMs = resolveToolTimeoutMs(opts?.toolTimeoutMs);
|
|
231
|
+
const doNormalize = opts?.normalizeResults !== false;
|
|
128
232
|
try {
|
|
129
|
-
|
|
233
|
+
const raw = await executeWithTimeout(execute, name, parsed, timeoutMs, opts?.signal);
|
|
234
|
+
if (doNormalize)
|
|
235
|
+
return normalizeToolResult(raw);
|
|
236
|
+
return typeof raw === "string" ? raw : normalizeToolResult(raw);
|
|
130
237
|
}
|
|
131
238
|
catch (e) {
|
|
132
239
|
if (isCancelError(e) || opts?.signal?.aborted)
|
|
@@ -134,6 +241,35 @@ async function runOneTool(call, parsed, opts, execute) {
|
|
|
134
241
|
throw e;
|
|
135
242
|
}
|
|
136
243
|
}
|
|
244
|
+
// After-tool-call result hook (issue 06): the single rewrite seam between
|
|
245
|
+
// execution and commit. Absent hook (or null/undefined/void/non-object
|
|
246
|
+
// return) → the original result, byte-identical. A string return replaces
|
|
247
|
+
// content (error flag kept); an object may replace content, override
|
|
248
|
+
// isError, or veto the commit. Hook failures degrade to the original — the
|
|
249
|
+
// turn never breaks. Telemetry keeps the pre-hook execution result; only the
|
|
250
|
+
// committed history/transcript/activity sees the rewrite.
|
|
251
|
+
async function applyToolResultHook(hook, name, parsed, result, isError) {
|
|
252
|
+
if (!hook)
|
|
253
|
+
return { content: result, isError, veto: false };
|
|
254
|
+
try {
|
|
255
|
+
const decision = await hook({ name, args: parsed, result, isError });
|
|
256
|
+
if (decision === null || decision === undefined)
|
|
257
|
+
return { content: result, isError, veto: false };
|
|
258
|
+
if (typeof decision === "string")
|
|
259
|
+
return { content: decision, isError, veto: false };
|
|
260
|
+
if (typeof decision === "object") {
|
|
261
|
+
if (decision.veto === true)
|
|
262
|
+
return { content: result, isError, veto: true };
|
|
263
|
+
const content = typeof decision.content === "string" ? decision.content : result;
|
|
264
|
+
const nextIsError = typeof decision.isError === "boolean" ? decision.isError : isError;
|
|
265
|
+
return { content, isError: nextIsError, veto: false };
|
|
266
|
+
}
|
|
267
|
+
return { content: result, isError, veto: false };
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return { content: result, isError, veto: false };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
137
273
|
async function runAskQuestion(parsed, askUser, signal) {
|
|
138
274
|
const invalid = validateAskQuestionArgs(parsed);
|
|
139
275
|
if (invalid)
|
|
@@ -226,9 +362,12 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
226
362
|
let verifiedAfterWrite = false;
|
|
227
363
|
let needsVerification = false;
|
|
228
364
|
let unverifiedPaths = [];
|
|
229
|
-
// Verification-gate nag cycles spent (bounds the continue loop
|
|
230
|
-
//
|
|
365
|
+
// Verification-gate nag cycles spent (bounds the continue loop via
|
|
366
|
+
// MAX_VERIFY_ROUNDS — a model that never verifies still terminates).
|
|
231
367
|
let verifyRounds = 0;
|
|
368
|
+
// Todo-guard cycles spent (bounds guard continues via MAX_TODO_ROUNDS —
|
|
369
|
+
// a model that never resolves open todos still terminates).
|
|
370
|
+
let todoRounds = 0;
|
|
232
371
|
// At most one truncation notice per turn; silence when nothing dropped.
|
|
233
372
|
let truncationNoticed = false;
|
|
234
373
|
// Window-aware trimmer when the caller knows the model (App passes it);
|
|
@@ -237,363 +376,676 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
237
376
|
const contextManager = opts?.context
|
|
238
377
|
? createContextManager({ model: opts.context.model, toolsChars: opts.context.toolsChars })
|
|
239
378
|
: null;
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
379
|
+
// ---- Hardened-loop state (additive; explicit caps still honored —
|
|
380
|
+
// see AgenticOpts docs) ----
|
|
381
|
+
const maxTotalToolCalls = resolveMaxTotalToolCalls(opts?.maxTotalToolCalls);
|
|
382
|
+
const repGuard = new RepetitionGuard({ maxRepeatedCalls: opts?.maxRepeatedCalls });
|
|
383
|
+
const errStreak = new ErrorStreakTracker(opts?.maxConsecutiveErrors);
|
|
384
|
+
const turnStartMs = Date.now();
|
|
385
|
+
let startChars = 0;
|
|
386
|
+
try {
|
|
387
|
+
startChars = historyChars(history);
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
startChars = 0;
|
|
391
|
+
}
|
|
392
|
+
let cacheHitsStart = 0;
|
|
393
|
+
try {
|
|
394
|
+
cacheHitsStart = getReadCacheStats().hits;
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
cacheHitsStart = 0;
|
|
398
|
+
}
|
|
399
|
+
let modelCalls = 0;
|
|
400
|
+
let toolCalls = 0;
|
|
401
|
+
let failures = 0;
|
|
402
|
+
let droppedTurnsTotal = 0;
|
|
403
|
+
// Empty-response repairs spent (bounded by MAX_EMPTY_ROUNDS — a model
|
|
404
|
+
// that only answers silence still terminates).
|
|
405
|
+
let emptyRounds = 0;
|
|
406
|
+
let bottleneck = null;
|
|
407
|
+
const noteBottleneck = (name, durationMs) => {
|
|
408
|
+
if (!Number.isFinite(durationMs) || durationMs < 0)
|
|
409
|
+
return;
|
|
410
|
+
if (!bottleneck || durationMs > bottleneck.durationMs) {
|
|
411
|
+
bottleneck = { name, durationMs: Math.floor(durationMs) };
|
|
251
412
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const trimmed = contextManager
|
|
255
|
-
? contextManager.trimForSend(history, truncationNoticed
|
|
256
|
-
? undefined
|
|
257
|
-
: (notice) => {
|
|
258
|
-
try {
|
|
259
|
-
opts?.onWarning?.(notice);
|
|
260
|
-
}
|
|
261
|
-
catch {
|
|
262
|
-
// ignore observer errors
|
|
263
|
-
}
|
|
264
|
-
}, undefined, openTodoNeedles())
|
|
265
|
-
: truncateHistory(history, truncationNoticed
|
|
266
|
-
? undefined
|
|
267
|
-
: (notice) => {
|
|
268
|
-
try {
|
|
269
|
-
opts?.onWarning?.(notice);
|
|
270
|
-
}
|
|
271
|
-
catch {
|
|
272
|
-
// ignore observer errors
|
|
273
|
-
}
|
|
274
|
-
});
|
|
275
|
-
if (trimmed.droppedTurns > 0)
|
|
276
|
-
truncationNoticed = true;
|
|
277
|
-
let msg;
|
|
278
|
-
const modelStart = Date.now();
|
|
413
|
+
};
|
|
414
|
+
const finishStats = () => {
|
|
279
415
|
try {
|
|
280
|
-
|
|
281
|
-
onToken: opts?.onToken,
|
|
282
|
-
onPhase: opts?.onPhase,
|
|
283
|
-
onToolDelta: opts?.onToolDelta,
|
|
284
|
-
onWarning: opts?.onWarning,
|
|
285
|
-
onThinking: opts?.onThinking,
|
|
286
|
-
sleep: opts?.sleep,
|
|
287
|
-
reasoningEffort: opts?.reasoningEffort,
|
|
288
|
-
signal,
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
catch (e) {
|
|
292
|
-
// A failed POST still records its model call (with the error) so the
|
|
293
|
-
// trace shows what was attempted — the caller still rolls back.
|
|
294
|
-
const modelEnd = Date.now();
|
|
295
|
-
reportModelCall({
|
|
296
|
-
step,
|
|
297
|
-
startedAt: telemetryIso(modelStart),
|
|
298
|
-
endedAt: telemetryIso(modelEnd),
|
|
299
|
-
durationMs: Math.max(0, modelEnd - modelStart),
|
|
300
|
-
usageReported: false,
|
|
301
|
-
toolCallCount: 0,
|
|
302
|
-
finishReason: "error",
|
|
303
|
-
error: e instanceof Error ? e.message : String(e),
|
|
304
|
-
});
|
|
305
|
-
if (isCancelError(e) || signal?.aborted)
|
|
306
|
-
throw new LoopCancelledError();
|
|
307
|
-
throw e;
|
|
308
|
-
}
|
|
309
|
-
throwIfCancelled(signal);
|
|
310
|
-
if (msg.usage !== undefined) {
|
|
311
|
-
// Spend accounting: EVERY POST that reports usage forwards it, and the
|
|
312
|
-
// caller accumulates each report as billed spend — tool-round POSTs,
|
|
313
|
-
// summary POSTs, and successful retries each count once. Attempts that
|
|
314
|
-
// fail (HTTP/network/truncation) report no usage, so there is nothing
|
|
315
|
-
// to dedupe: each attempt that reached the provider and reported counts
|
|
316
|
-
// exactly once. Usage is never synthesized or estimated here.
|
|
416
|
+
let endChars = startChars;
|
|
317
417
|
try {
|
|
318
|
-
|
|
418
|
+
endChars = historyChars(history);
|
|
319
419
|
}
|
|
320
420
|
catch {
|
|
321
|
-
|
|
421
|
+
endChars = startChars;
|
|
322
422
|
}
|
|
323
|
-
|
|
324
|
-
if (msg.reasoning !== undefined) {
|
|
423
|
+
let cacheHits = 0;
|
|
325
424
|
try {
|
|
326
|
-
|
|
425
|
+
cacheHits = Math.max(0, getReadCacheStats().hits - cacheHitsStart);
|
|
327
426
|
}
|
|
328
427
|
catch {
|
|
329
|
-
|
|
428
|
+
cacheHits = 0;
|
|
330
429
|
}
|
|
430
|
+
const stats = {
|
|
431
|
+
steps: modelCalls,
|
|
432
|
+
modelCalls,
|
|
433
|
+
toolCalls,
|
|
434
|
+
failures,
|
|
435
|
+
repetitionHits: repGuard.hitCount,
|
|
436
|
+
cacheHits,
|
|
437
|
+
truncationNotices: droppedTurnsTotal,
|
|
438
|
+
durationMs: Math.max(0, Date.now() - turnStartMs),
|
|
439
|
+
bottleneck,
|
|
440
|
+
contextGrowthChars: endChars - startChars,
|
|
441
|
+
};
|
|
442
|
+
opts?.onLoopStats?.(stats);
|
|
331
443
|
}
|
|
332
|
-
{
|
|
333
|
-
//
|
|
334
|
-
// actually carried it (usageReported) — never synthesized here.
|
|
335
|
-
const modelEnd = Date.now();
|
|
336
|
-
const callsCount = (msg.tool_calls ?? []).length;
|
|
337
|
-
reportModelCall({
|
|
338
|
-
step,
|
|
339
|
-
startedAt: telemetryIso(modelStart),
|
|
340
|
-
endedAt: telemetryIso(modelEnd),
|
|
341
|
-
durationMs: Math.max(0, modelEnd - modelStart),
|
|
342
|
-
usage: msg.usage,
|
|
343
|
-
usageReported: msg.usage !== undefined,
|
|
344
|
-
reasoningLabel: msg.reasoning,
|
|
345
|
-
toolCallCount: callsCount,
|
|
346
|
-
finishReason: callsCount === 0 ? "final" : "tool_calls",
|
|
347
|
-
});
|
|
444
|
+
catch {
|
|
445
|
+
// observer errors never break the turn
|
|
348
446
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
//
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
filesWritten,
|
|
358
|
-
verifiedAfterWrite,
|
|
359
|
-
needsVerification,
|
|
360
|
-
unverifiedPaths: [...unverifiedPaths],
|
|
361
|
-
verifyRounds,
|
|
362
|
-
});
|
|
363
|
-
if (outcome.kind === "continue") {
|
|
364
|
-
// Verification-gate continues are bounded per turn (alongside the
|
|
365
|
-
// step budget) so a model that never verifies still terminates.
|
|
366
|
-
if (outcome.via === "verification")
|
|
367
|
-
verifyRounds += 1;
|
|
368
|
-
history.push({ role: "assistant", content: outcome.assistantText });
|
|
369
|
-
history.push({ role: "user", content: outcome.followUp });
|
|
370
|
-
continue;
|
|
371
|
-
}
|
|
372
|
-
history.push({ role: "assistant", content: outcome.finalText });
|
|
447
|
+
};
|
|
448
|
+
try {
|
|
449
|
+
for (let step = 0;; step++) {
|
|
450
|
+
throwIfCancelled(signal);
|
|
451
|
+
// Steering seam: drain one pending steer message (if any) at this safe
|
|
452
|
+
// point — previous tool batches are fully committed, so assistant/tool
|
|
453
|
+
// pairing can never split. Runs before the budget trim so truncation
|
|
454
|
+
// accounts for the injected message. No-op without the hook.
|
|
373
455
|
try {
|
|
374
|
-
opts?.
|
|
456
|
+
opts?.drainSteer?.();
|
|
375
457
|
}
|
|
376
458
|
catch {
|
|
377
|
-
//
|
|
459
|
+
// observer errors never break the loop
|
|
378
460
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
461
|
+
// History budget (uniform for all providers — every POST flows through
|
|
462
|
+
// here): trim oldest user-turns first before each send.
|
|
463
|
+
const trimmed = contextManager
|
|
464
|
+
? contextManager.trimForSend(history, truncationNoticed
|
|
465
|
+
? undefined
|
|
466
|
+
: (notice) => {
|
|
467
|
+
try {
|
|
468
|
+
opts?.onWarning?.(notice);
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
// ignore observer errors
|
|
472
|
+
}
|
|
473
|
+
}, undefined, openTodoNeedles())
|
|
474
|
+
: truncateHistory(history, truncationNoticed
|
|
475
|
+
? undefined
|
|
476
|
+
: (notice) => {
|
|
477
|
+
try {
|
|
478
|
+
opts?.onWarning?.(notice);
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
// ignore observer errors
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
if (trimmed.droppedTurns > 0) {
|
|
485
|
+
truncationNoticed = true;
|
|
486
|
+
droppedTurnsTotal += trimmed.droppedTurns;
|
|
487
|
+
}
|
|
488
|
+
let msg;
|
|
489
|
+
const modelStart = Date.now();
|
|
490
|
+
try {
|
|
491
|
+
msg = await chatFn(history, {
|
|
492
|
+
onToken: opts?.onToken,
|
|
493
|
+
onPhase: opts?.onPhase,
|
|
494
|
+
onToolDelta: opts?.onToolDelta,
|
|
495
|
+
onWarning: opts?.onWarning,
|
|
496
|
+
onThinking: opts?.onThinking,
|
|
497
|
+
sleep: opts?.sleep,
|
|
498
|
+
reasoningEffort: opts?.reasoningEffort,
|
|
499
|
+
signal,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
catch (e) {
|
|
503
|
+
// A failed POST still records its model call (with the error) so the
|
|
504
|
+
// trace shows what was attempted — the caller still rolls back.
|
|
505
|
+
const modelEnd = Date.now();
|
|
506
|
+
reportModelCall({
|
|
507
|
+
step,
|
|
508
|
+
startedAt: telemetryIso(modelStart),
|
|
509
|
+
endedAt: telemetryIso(modelEnd),
|
|
510
|
+
durationMs: Math.max(0, modelEnd - modelStart),
|
|
511
|
+
usageReported: false,
|
|
512
|
+
toolCallCount: 0,
|
|
513
|
+
finishReason: "error",
|
|
514
|
+
error: e instanceof Error ? e.message : String(e),
|
|
515
|
+
});
|
|
516
|
+
if (isCancelError(e) || signal?.aborted)
|
|
517
|
+
throw new LoopCancelledError();
|
|
518
|
+
// Empty-response recovery: a silent POST spends repair budget instead
|
|
519
|
+
// of aborting the turn (see MAX_EMPTY_ROUNDS). Cancellation above still
|
|
520
|
+
// wins; every other failure throws exactly as before.
|
|
521
|
+
if (isEmptyReplyError(e) && emptyRounds < MAX_EMPTY_ROUNDS) {
|
|
522
|
+
emptyRounds += 1;
|
|
523
|
+
failures += 1;
|
|
524
|
+
history.push({ role: "assistant", content: "" });
|
|
525
|
+
history.push({ role: "user", content: emptyResponseFollowUp(emptyRounds) });
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
throw e;
|
|
529
|
+
}
|
|
530
|
+
throwIfCancelled(signal);
|
|
531
|
+
// Defensive normalization (malformed custom chatFn responses never crash
|
|
532
|
+
// the commit path): dropped calls surface via onWarning, pairing stays
|
|
533
|
+
// valid because only validated calls reach the batch planner.
|
|
385
534
|
try {
|
|
386
|
-
|
|
535
|
+
const norm = normalizeChatResult(msg);
|
|
536
|
+
if (norm.warnings.length > 0) {
|
|
537
|
+
for (const w of norm.warnings) {
|
|
538
|
+
try {
|
|
539
|
+
opts?.onWarning?.(w);
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
// ignore observer errors
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
msg = norm.result;
|
|
387
547
|
}
|
|
388
548
|
catch {
|
|
389
|
-
//
|
|
549
|
+
// normalization never breaks the turn; the raw message stands
|
|
390
550
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
if (isCodePath(p)) {
|
|
405
|
-
needsVerification = true;
|
|
406
|
-
if (p.length > 0 && !unverifiedPaths.includes(p))
|
|
407
|
-
unverifiedPaths.push(p);
|
|
551
|
+
modelCalls += 1;
|
|
552
|
+
if (msg.usage !== undefined) {
|
|
553
|
+
// Spend accounting: EVERY POST that reports usage forwards it, and the
|
|
554
|
+
// caller accumulates each report as billed spend — tool-round POSTs,
|
|
555
|
+
// summary POSTs, and successful retries each count once. Attempts that
|
|
556
|
+
// fail (HTTP/network/truncation) report no usage, so there is nothing
|
|
557
|
+
// to dedupe: each attempt that reached the provider and reported counts
|
|
558
|
+
// exactly once. Usage is never synthesized or estimated here.
|
|
559
|
+
try {
|
|
560
|
+
opts?.onUsage?.(msg.usage);
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
// ignore
|
|
408
564
|
}
|
|
409
565
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
// clears everything the gate tracks.
|
|
417
|
-
verifiedAfterWrite = true;
|
|
418
|
-
needsVerification = false;
|
|
419
|
-
unverifiedPaths = [];
|
|
420
|
-
}
|
|
421
|
-
else {
|
|
422
|
-
// A FAILED check is evidence of failure, not of verification:
|
|
423
|
-
// the gate stays armed so the model fixes forward instead of
|
|
424
|
-
// finishing on red output.
|
|
425
|
-
verifiedAfterWrite = false;
|
|
426
|
-
}
|
|
566
|
+
if (msg.reasoning !== undefined) {
|
|
567
|
+
try {
|
|
568
|
+
opts?.onReasoning?.(msg.reasoning);
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
// ignore
|
|
427
572
|
}
|
|
428
573
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
574
|
+
{
|
|
575
|
+
// Completed model call: usage is forwarded only when the response
|
|
576
|
+
// actually carried it (usageReported) — never synthesized here.
|
|
577
|
+
const modelEnd = Date.now();
|
|
578
|
+
const callsCount = (msg.tool_calls ?? []).length;
|
|
579
|
+
reportModelCall({
|
|
580
|
+
step,
|
|
581
|
+
startedAt: telemetryIso(modelStart),
|
|
582
|
+
endedAt: telemetryIso(modelEnd),
|
|
583
|
+
durationMs: Math.max(0, modelEnd - modelStart),
|
|
584
|
+
usage: msg.usage,
|
|
585
|
+
usageReported: msg.usage !== undefined,
|
|
586
|
+
reasoningLabel: msg.reasoning,
|
|
587
|
+
toolCallCount: callsCount,
|
|
588
|
+
finishReason: callsCount === 0 ? "final" : "tool_calls",
|
|
589
|
+
});
|
|
432
590
|
}
|
|
433
|
-
|
|
434
|
-
|
|
591
|
+
const calls = msg.tool_calls ?? [];
|
|
592
|
+
if (calls.length === 0) {
|
|
593
|
+
// Turn-continuation seam (ticket 03): the todo guard and verification
|
|
594
|
+
// gate run as entries in TURN_END_GATES — one chain, one commit point.
|
|
595
|
+
// Behavior is byte-identical to the two inline blocks this replaced.
|
|
596
|
+
const outcome = evaluateTurnEnd(msg.content ?? "", {
|
|
597
|
+
step,
|
|
598
|
+
maxSteps,
|
|
599
|
+
filesWritten,
|
|
600
|
+
verifiedAfterWrite,
|
|
601
|
+
needsVerification,
|
|
602
|
+
unverifiedPaths: [...unverifiedPaths],
|
|
603
|
+
verifyRounds,
|
|
604
|
+
todoRounds,
|
|
605
|
+
});
|
|
606
|
+
if (outcome.kind === "continue") {
|
|
607
|
+
// Guard continues are bounded per turn so a model that never
|
|
608
|
+
// verifies or never resolves todos still terminates.
|
|
609
|
+
if (outcome.via === "verification")
|
|
610
|
+
verifyRounds += 1;
|
|
611
|
+
else if (outcome.via === "todoCompletionGate")
|
|
612
|
+
todoRounds += 1;
|
|
613
|
+
history.push({ role: "assistant", content: outcome.assistantText });
|
|
614
|
+
history.push({ role: "user", content: outcome.followUp });
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
// Error-streak recovery (additive, after the pinned gates): ending on
|
|
618
|
+
// sustained unaddressed `Error:` results is almost always premature.
|
|
619
|
+
// Single errors still end normally (the model may be reporting a
|
|
620
|
+
// blocker); a streak holds final text for one fix-forward attempt,
|
|
621
|
+
// bounded to 2 holds per turn.
|
|
622
|
+
if (errStreak.shouldHoldFinal(2)) {
|
|
623
|
+
const streak = errStreak.current;
|
|
624
|
+
history.push({ role: "assistant", content: outcome.finalText });
|
|
625
|
+
history.push({ role: "user", content: errorStreakFollowUp(streak) });
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
history.push({ role: "assistant", content: outcome.finalText });
|
|
629
|
+
try {
|
|
630
|
+
opts?.onPhase?.("done");
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
// ignore
|
|
634
|
+
}
|
|
635
|
+
return outcome.finalText;
|
|
435
636
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
throwIfCancelled(signal);
|
|
441
|
-
if (batch.length === 1) {
|
|
442
|
-
// Serial path: byte-identical to the pre-05 loop body.
|
|
443
|
-
const call = batch[0].call;
|
|
444
|
-
const name = call?.function?.name ?? "(unknown)";
|
|
637
|
+
if (step >= maxSteps) {
|
|
638
|
+
const base = msg.content ?? "";
|
|
639
|
+
const notice = `${base}${base ? "\n" : ""}(stopped: too many tool steps) (limit is ${maxSteps}; raise with ATOM_MAX_TOOL_STEPS=<n>)`;
|
|
640
|
+
history.push({ role: "assistant", content: notice });
|
|
445
641
|
try {
|
|
446
|
-
opts?.onPhase?.("
|
|
642
|
+
opts?.onPhase?.("done");
|
|
447
643
|
}
|
|
448
644
|
catch {
|
|
449
645
|
// ignore
|
|
450
646
|
}
|
|
451
|
-
|
|
452
|
-
|
|
647
|
+
return notice;
|
|
648
|
+
}
|
|
649
|
+
// Total tool-call budget (explicit `opts.maxTotalToolCalls` only;
|
|
650
|
+
// uncapped by default): counts every tool_call the model emits and stops
|
|
651
|
+
// the turn when the explicit budget is exceeded.
|
|
652
|
+
if (toolCalls + calls.length > maxTotalToolCalls) {
|
|
653
|
+
const base = msg.content ?? "";
|
|
654
|
+
const notice = `${base}${base ? "\n" : ""}(stopped: too many tool calls) (limit is ${maxTotalToolCalls} per turn)`;
|
|
655
|
+
history.push({ role: "assistant", content: notice });
|
|
453
656
|
try {
|
|
454
|
-
|
|
455
|
-
const v = JSON.parse(typeof raw === "string" ? raw : "{}");
|
|
456
|
-
parsed = typeof v === "object" && v !== null ? v : {};
|
|
657
|
+
opts?.onPhase?.("done");
|
|
457
658
|
}
|
|
458
659
|
catch {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
660
|
+
// ignore
|
|
661
|
+
}
|
|
662
|
+
return notice;
|
|
663
|
+
}
|
|
664
|
+
history.push({ role: "assistant", content: msg.content ?? null, tool_calls: calls });
|
|
665
|
+
// Commit helper shared by the serial and parallel paths: Task 7
|
|
666
|
+
// bookkeeping + one ordered transcript entry per call. Only successful
|
|
667
|
+
// executions count — denials, validation errors, and unknown tools (all
|
|
668
|
+
// `Error:` results) never ran, so they neither arm nor clear the gate.
|
|
669
|
+
const commitToolResult = async (name, parsed, call, result, durationMs) => {
|
|
670
|
+
const baseIsError = typeof result === "string" && result.startsWith("Error");
|
|
671
|
+
const hooked = await applyToolResultHook(opts?.onToolResult, name, parsed, result, baseIsError);
|
|
672
|
+
// Veto: skip the commit entirely — no counters, no gates, no history,
|
|
673
|
+
// no activity. The turn continues; pairing risk is the hook author's.
|
|
674
|
+
if (hooked.veto)
|
|
675
|
+
return false;
|
|
676
|
+
const finalResult = hooked.content;
|
|
677
|
+
const isError = hooked.isError;
|
|
678
|
+
toolCalls += 1;
|
|
679
|
+
if (isError)
|
|
680
|
+
failures += 1;
|
|
681
|
+
errStreak.noteResult(isError);
|
|
682
|
+
if (typeof durationMs === "number")
|
|
683
|
+
noteBottleneck(name, durationMs);
|
|
684
|
+
if (!isError && (name === "write" || name === "edit")) {
|
|
685
|
+
filesWritten = true;
|
|
686
|
+
verifiedAfterWrite = false;
|
|
687
|
+
const p = typeof parsed["path"] === "string" ? parsed["path"] : "";
|
|
688
|
+
if (isCodePath(p)) {
|
|
689
|
+
needsVerification = true;
|
|
690
|
+
if (p.length > 0 && !unverifiedPaths.includes(p))
|
|
691
|
+
unverifiedPaths.push(p);
|
|
464
692
|
}
|
|
465
|
-
|
|
466
|
-
|
|
693
|
+
}
|
|
694
|
+
else if (!isError && name === "bash") {
|
|
695
|
+
const command = parsed["command"];
|
|
696
|
+
if (typeof command === "string" && isVerificationCommand(command) && filesWritten) {
|
|
697
|
+
const exit = bashExitCode(finalResult);
|
|
698
|
+
if (exit === null || exit === 0) {
|
|
699
|
+
// Passing check (or a legacy runner that reports no envelope):
|
|
700
|
+
// clears everything the gate tracks.
|
|
701
|
+
verifiedAfterWrite = true;
|
|
702
|
+
needsVerification = false;
|
|
703
|
+
unverifiedPaths = [];
|
|
704
|
+
}
|
|
705
|
+
else {
|
|
706
|
+
// A FAILED check is evidence of failure, not of verification:
|
|
707
|
+
// the gate stays armed so the model fixes forward instead of
|
|
708
|
+
// finishing on red output.
|
|
709
|
+
verifiedAfterWrite = false;
|
|
710
|
+
}
|
|
467
711
|
}
|
|
468
|
-
const toolEnd = Date.now();
|
|
469
|
-
reportToolCall({
|
|
470
|
-
step,
|
|
471
|
-
toolCallId: call?.id ?? "",
|
|
472
|
-
name,
|
|
473
|
-
startedAt: telemetryIso(toolStart),
|
|
474
|
-
endedAt: telemetryIso(toolEnd),
|
|
475
|
-
durationMs: Math.max(0, toolEnd - toolStart),
|
|
476
|
-
argsJson: telemetryArgsJson(call?.function?.arguments ?? "{}"),
|
|
477
|
-
result,
|
|
478
|
-
batchIndex: 0,
|
|
479
|
-
batchSize: 1,
|
|
480
|
-
});
|
|
481
|
-
continue;
|
|
482
712
|
}
|
|
483
|
-
|
|
713
|
+
history.push({ role: "tool", tool_call_id: call?.id ?? "", content: finalResult });
|
|
484
714
|
try {
|
|
485
|
-
|
|
715
|
+
opts?.onToolActivity?.(describeToolCall(name, parsed), finalResult, isError);
|
|
486
716
|
}
|
|
487
|
-
catch
|
|
488
|
-
//
|
|
489
|
-
// cause) so the trace shows what was in flight — then the turn
|
|
490
|
-
// aborts exactly as before.
|
|
491
|
-
const toolEnd = Date.now();
|
|
492
|
-
const cancelled = isCancelError(e) || signal?.aborted;
|
|
493
|
-
reportToolCall({
|
|
494
|
-
step,
|
|
495
|
-
toolCallId: call?.id ?? "",
|
|
496
|
-
name,
|
|
497
|
-
startedAt: telemetryIso(toolStart),
|
|
498
|
-
endedAt: telemetryIso(toolEnd),
|
|
499
|
-
durationMs: Math.max(0, toolEnd - toolStart),
|
|
500
|
-
argsJson: telemetryArgsJson(parsed),
|
|
501
|
-
result: e instanceof Error ? e.message : String(e),
|
|
502
|
-
cancelled: cancelled ? true : undefined,
|
|
503
|
-
threw: cancelled ? undefined : true,
|
|
504
|
-
batchIndex: 0,
|
|
505
|
-
batchSize: 1,
|
|
506
|
-
});
|
|
507
|
-
if (cancelled)
|
|
508
|
-
throw new LoopCancelledError();
|
|
509
|
-
throw e;
|
|
717
|
+
catch {
|
|
718
|
+
// ignore observer errors
|
|
510
719
|
}
|
|
511
|
-
|
|
512
|
-
|
|
720
|
+
return true;
|
|
721
|
+
};
|
|
722
|
+
// Length-truncated response (the output limit cut the tool arguments
|
|
723
|
+
// off): NOTHING executes — every carried call commits a repair-oriented
|
|
724
|
+
// error result and the turn continues to the next model round. The
|
|
725
|
+
// results are `Error:` strings, so failure/error-streak accounting flows
|
|
726
|
+
// through commitToolResult exactly like any other tool error, and the
|
|
727
|
+
// step/total-call budgets above keep bounding runaway retries.
|
|
728
|
+
// Truncated-without-calls never reaches here (handled by the turn-end
|
|
729
|
+
// gates above, exactly as before).
|
|
730
|
+
if (msg.truncated === true) {
|
|
731
|
+
for (let i = 0; i < calls.length; i++) {
|
|
732
|
+
const call = calls[i];
|
|
733
|
+
const name = call?.function?.name ?? "(unknown)";
|
|
734
|
+
const result = `Error: truncated response: the output limit cut off the arguments for tool "${name}" — ` +
|
|
735
|
+
`nothing was executed. Re-issue the call with complete arguments ` +
|
|
736
|
+
`(narrow the scope or split into smaller calls if it keeps truncating).`;
|
|
737
|
+
const toolAt = Date.now();
|
|
513
738
|
reportToolCall({
|
|
514
739
|
step,
|
|
515
740
|
toolCallId: call?.id ?? "",
|
|
516
741
|
name,
|
|
517
|
-
startedAt: telemetryIso(
|
|
518
|
-
endedAt: telemetryIso(
|
|
519
|
-
durationMs:
|
|
520
|
-
argsJson: telemetryArgsJson(
|
|
742
|
+
startedAt: telemetryIso(toolAt),
|
|
743
|
+
endedAt: telemetryIso(toolAt),
|
|
744
|
+
durationMs: 0,
|
|
745
|
+
argsJson: telemetryArgsJson(call?.function?.arguments ?? "{}"),
|
|
521
746
|
result,
|
|
522
|
-
batchIndex:
|
|
523
|
-
batchSize:
|
|
747
|
+
batchIndex: i,
|
|
748
|
+
batchSize: calls.length,
|
|
524
749
|
});
|
|
750
|
+
// Truncated arguments are often not valid JSON (cut mid-string) —
|
|
751
|
+
// fall back to {} for bookkeeping (an error result never arms the
|
|
752
|
+
// verification gate, so this only shapes the activity label).
|
|
753
|
+
let parsed;
|
|
754
|
+
try {
|
|
755
|
+
const raw = call?.function?.arguments ?? "{}";
|
|
756
|
+
const v = JSON.parse(typeof raw === "string" ? raw : "{}");
|
|
757
|
+
parsed = typeof v === "object" && v !== null ? v : {};
|
|
758
|
+
}
|
|
759
|
+
catch {
|
|
760
|
+
parsed = {};
|
|
761
|
+
}
|
|
762
|
+
await commitToolResult(name, parsed, call, result);
|
|
525
763
|
}
|
|
526
|
-
commitToolResult(name, parsed, call, result);
|
|
527
764
|
continue;
|
|
528
765
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
// Each member is timed individually (concurrent wall-clock per call,
|
|
546
|
-
// not the whole batch attributed to each) and reported in call order
|
|
547
|
-
// below. A throw still aborts the turn exactly like the serial path.
|
|
548
|
-
results = await Promise.all(batch.map(async (member, index) => {
|
|
549
|
-
const memberStart = Date.now();
|
|
766
|
+
for (const batch of planBatches(calls)) {
|
|
767
|
+
// No new executions after a cancel: the current tool (if any) already
|
|
768
|
+
// finished; stop before starting the next batch.
|
|
769
|
+
throwIfCancelled(signal);
|
|
770
|
+
if (batch.length === 1) {
|
|
771
|
+
// Serial path: byte-identical to the pre-05 loop body.
|
|
772
|
+
const call = batch[0].call;
|
|
773
|
+
const name = call?.function?.name ?? "(unknown)";
|
|
774
|
+
try {
|
|
775
|
+
opts?.onPhase?.("tool", name);
|
|
776
|
+
}
|
|
777
|
+
catch {
|
|
778
|
+
// ignore
|
|
779
|
+
}
|
|
780
|
+
const toolStart = Date.now();
|
|
781
|
+
let parsed;
|
|
550
782
|
try {
|
|
551
|
-
const
|
|
552
|
-
const
|
|
783
|
+
const raw = call?.function?.arguments ?? "{}";
|
|
784
|
+
const v = JSON.parse(typeof raw === "string" ? raw : "{}");
|
|
785
|
+
parsed = typeof v === "object" && v !== null ? v : {};
|
|
786
|
+
}
|
|
787
|
+
catch {
|
|
788
|
+
parsed = {};
|
|
789
|
+
const result = `Error: invalid call: invalid JSON arguments for tool "${name}" (arguments must be valid JSON). Fix the arguments and retry.`;
|
|
790
|
+
// Invalid JSON never executes — route through the commit funnel so
|
|
791
|
+
// the result hook still sees every committed result. Bookkeeping
|
|
792
|
+
// (counters, error streak, bottleneck, history, activity) is
|
|
793
|
+
// identical to the inline block this replaced; only the committed
|
|
794
|
+
// content may differ when a hook rewrites it.
|
|
795
|
+
repGuard.note(toolSignature(name, parsed), name);
|
|
796
|
+
const toolEnd = Date.now();
|
|
797
|
+
reportToolCall({
|
|
798
|
+
step,
|
|
799
|
+
toolCallId: call?.id ?? "",
|
|
800
|
+
name,
|
|
801
|
+
startedAt: telemetryIso(toolStart),
|
|
802
|
+
endedAt: telemetryIso(toolEnd),
|
|
803
|
+
durationMs: Math.max(0, toolEnd - toolStart),
|
|
804
|
+
argsJson: telemetryArgsJson(call?.function?.arguments ?? "{}"),
|
|
805
|
+
result,
|
|
806
|
+
batchIndex: 0,
|
|
807
|
+
batchSize: 1,
|
|
808
|
+
});
|
|
809
|
+
await commitToolResult(name, parsed, call, result, Math.max(0, toolEnd - toolStart));
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
let result;
|
|
813
|
+
// Repetition guard (opt-in via maxRepeatedCalls; unset = track-only):
|
|
814
|
+
// a repeated signature skips execution and yields a guidance error;
|
|
815
|
+
// exhausted nudges stop hard.
|
|
816
|
+
const repSig = toolSignature(name, parsed);
|
|
817
|
+
const repNote = repGuard.note(repSig, name);
|
|
818
|
+
if (repNote.intervened) {
|
|
819
|
+
const toolEndRep = Date.now();
|
|
820
|
+
if (repGuard.consumeNudge()) {
|
|
821
|
+
const guarded = `Error: invalid call: ${repetitionFollowUp(repSig, repNote.consecutive)} Fix the approach and retry.`;
|
|
822
|
+
reportToolCall({
|
|
823
|
+
step,
|
|
824
|
+
toolCallId: call?.id ?? "",
|
|
825
|
+
name,
|
|
826
|
+
startedAt: telemetryIso(toolStart),
|
|
827
|
+
endedAt: telemetryIso(toolEndRep),
|
|
828
|
+
durationMs: 0,
|
|
829
|
+
argsJson: telemetryArgsJson(parsed),
|
|
830
|
+
result: guarded,
|
|
831
|
+
batchIndex: 0,
|
|
832
|
+
batchSize: 1,
|
|
833
|
+
});
|
|
834
|
+
await commitToolResult(name, parsed, call, guarded, 0);
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
const guarded = `Error: invalid call: ${repetitionFollowUp(repSig, repNote.consecutive)} Fix the approach and retry.`;
|
|
553
838
|
reportToolCall({
|
|
554
839
|
step,
|
|
555
|
-
toolCallId:
|
|
556
|
-
name
|
|
557
|
-
startedAt: telemetryIso(
|
|
558
|
-
endedAt: telemetryIso(
|
|
559
|
-
durationMs:
|
|
560
|
-
argsJson: telemetryArgsJson(
|
|
561
|
-
result:
|
|
562
|
-
batchIndex:
|
|
563
|
-
batchSize:
|
|
840
|
+
toolCallId: call?.id ?? "",
|
|
841
|
+
name,
|
|
842
|
+
startedAt: telemetryIso(toolStart),
|
|
843
|
+
endedAt: telemetryIso(toolEndRep),
|
|
844
|
+
durationMs: 0,
|
|
845
|
+
argsJson: telemetryArgsJson(parsed),
|
|
846
|
+
result: guarded,
|
|
847
|
+
batchIndex: 0,
|
|
848
|
+
batchSize: 1,
|
|
564
849
|
});
|
|
565
|
-
|
|
850
|
+
await commitToolResult(name, parsed, call, guarded, 0);
|
|
851
|
+
const stopBase = msg.content ?? "";
|
|
852
|
+
const stopNotice = `${stopBase}${stopBase ? "\n" : ""}${repetitionStopNotice(repSig, repNote.consecutive)}`;
|
|
853
|
+
history.push({ role: "assistant", content: stopNotice });
|
|
854
|
+
try {
|
|
855
|
+
opts?.onPhase?.("done");
|
|
856
|
+
}
|
|
857
|
+
catch {
|
|
858
|
+
// ignore
|
|
859
|
+
}
|
|
860
|
+
return stopNotice;
|
|
861
|
+
}
|
|
862
|
+
try {
|
|
863
|
+
result = await runOneTool(call, parsed, opts, execute);
|
|
566
864
|
}
|
|
567
865
|
catch (e) {
|
|
568
|
-
|
|
866
|
+
// A cancelled/throwing tool still records its attempt (with the
|
|
867
|
+
// cause) so the trace shows what was in flight — then the turn
|
|
868
|
+
// aborts exactly as before.
|
|
869
|
+
const toolEnd = Date.now();
|
|
569
870
|
const cancelled = isCancelError(e) || signal?.aborted;
|
|
871
|
+
if (!cancelled) {
|
|
872
|
+
failures += 1;
|
|
873
|
+
noteBottleneck(name, Math.max(0, toolEnd - toolStart));
|
|
874
|
+
}
|
|
570
875
|
reportToolCall({
|
|
571
876
|
step,
|
|
572
|
-
toolCallId:
|
|
573
|
-
name
|
|
574
|
-
startedAt: telemetryIso(
|
|
575
|
-
endedAt: telemetryIso(
|
|
576
|
-
durationMs: Math.max(0,
|
|
577
|
-
argsJson: telemetryArgsJson(
|
|
877
|
+
toolCallId: call?.id ?? "",
|
|
878
|
+
name,
|
|
879
|
+
startedAt: telemetryIso(toolStart),
|
|
880
|
+
endedAt: telemetryIso(toolEnd),
|
|
881
|
+
durationMs: Math.max(0, toolEnd - toolStart),
|
|
882
|
+
argsJson: telemetryArgsJson(parsed),
|
|
578
883
|
result: e instanceof Error ? e.message : String(e),
|
|
579
884
|
cancelled: cancelled ? true : undefined,
|
|
580
885
|
threw: cancelled ? undefined : true,
|
|
581
|
-
batchIndex:
|
|
582
|
-
batchSize:
|
|
886
|
+
batchIndex: 0,
|
|
887
|
+
batchSize: 1,
|
|
583
888
|
});
|
|
889
|
+
if (cancelled)
|
|
890
|
+
throw new LoopCancelledError();
|
|
584
891
|
throw e;
|
|
585
892
|
}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
893
|
+
{
|
|
894
|
+
const toolEnd = Date.now();
|
|
895
|
+
reportToolCall({
|
|
896
|
+
step,
|
|
897
|
+
toolCallId: call?.id ?? "",
|
|
898
|
+
name,
|
|
899
|
+
startedAt: telemetryIso(toolStart),
|
|
900
|
+
endedAt: telemetryIso(toolEnd),
|
|
901
|
+
durationMs: Math.max(0, toolEnd - toolStart),
|
|
902
|
+
argsJson: telemetryArgsJson(parsed),
|
|
903
|
+
result,
|
|
904
|
+
batchIndex: 0,
|
|
905
|
+
batchSize: 1,
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
await commitToolResult(name, parsed, call, result, Math.max(0, Date.now() - toolStart));
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
// Parallel batch: every member is pre-validated parallel-safe (see
|
|
912
|
+
// planToolBatches). Approval-gated members (parallel writes) resolve
|
|
913
|
+
// their decisions serially in call order first, so prompts never run
|
|
914
|
+
// concurrently; denied members yield inline errors without executing.
|
|
915
|
+
// Phases fire upfront in call order; results commit in call order, so
|
|
916
|
+
// each call still shows separately and tool_call_ids re-pair by index.
|
|
917
|
+
// A throw (cancel or execution error) aborts the turn exactly like the
|
|
918
|
+
// serial path — the caller rolls the partial turn back.
|
|
919
|
+
for (const member of batch) {
|
|
920
|
+
try {
|
|
921
|
+
opts?.onPhase?.("tool", member.call?.function?.name ?? "(unknown)");
|
|
922
|
+
}
|
|
923
|
+
catch {
|
|
924
|
+
// ignore
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
let results;
|
|
928
|
+
const memberDurations = new Array(batch.length).fill(0);
|
|
929
|
+
// Repetition pre-notes (synchronous, in call order — deterministic):
|
|
930
|
+
// intervened members skip execution with a guidance error; exhausted
|
|
931
|
+
// nudges arm a hard stop after this batch commits (pairing stays valid).
|
|
932
|
+
const repNotes = batch.map((member) => repGuard.note(toolSignature(member.call?.function?.name ?? "(unknown)", member.parsed), member.call?.function?.name ?? "(unknown)"));
|
|
933
|
+
let repHardStop = null;
|
|
934
|
+
for (let i = 0; i < batch.length; i++) {
|
|
935
|
+
const note = repNotes[i];
|
|
936
|
+
if (note.intervened && !repGuard.consumeNudge() && !repHardStop) {
|
|
937
|
+
repHardStop = { sig: note.signature, consecutive: note.consecutive };
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
// Serial approval pre-pass (in call order, skipping repetition-guarded
|
|
941
|
+
// members exactly as the serial path would): prompts resolve before
|
|
942
|
+
// any member executes, so concurrent writes never prompt at once.
|
|
943
|
+
// Cancel between prompts aborts the batch with nothing executed.
|
|
944
|
+
const preDecisions = new Map();
|
|
945
|
+
for (let i = 0; i < batch.length; i++) {
|
|
946
|
+
throwIfCancelled(signal);
|
|
947
|
+
if (repNotes[i].intervened)
|
|
948
|
+
continue;
|
|
949
|
+
const member = batch[i];
|
|
950
|
+
const memberName = member.call?.function?.name ?? "(unknown)";
|
|
951
|
+
const decision = await resolveApproval(memberName, member.parsed, opts);
|
|
952
|
+
if (decision !== null)
|
|
953
|
+
preDecisions.set(i, decision);
|
|
954
|
+
}
|
|
955
|
+
try {
|
|
956
|
+
// Each member is timed individually (concurrent wall-clock per call,
|
|
957
|
+
// not the whole batch attributed to each) and reported in call order
|
|
958
|
+
// below. A throw still aborts the turn exactly like the serial path.
|
|
959
|
+
results = await Promise.all(batch.map(async (member, index) => {
|
|
960
|
+
const memberStart = Date.now();
|
|
961
|
+
const note = repNotes[index];
|
|
962
|
+
const memberName = member.call?.function?.name ?? "(unknown)";
|
|
963
|
+
if (note.intervened) {
|
|
964
|
+
const guarded = `Error: invalid call: ${repetitionFollowUp(note.signature, note.consecutive)} Fix the approach and retry.`;
|
|
965
|
+
const memberEnd = Date.now();
|
|
966
|
+
memberDurations[index] = 0;
|
|
967
|
+
reportToolCall({
|
|
968
|
+
step,
|
|
969
|
+
toolCallId: member.call?.id ?? "",
|
|
970
|
+
name: memberName,
|
|
971
|
+
startedAt: telemetryIso(memberStart),
|
|
972
|
+
endedAt: telemetryIso(memberEnd),
|
|
973
|
+
durationMs: 0,
|
|
974
|
+
argsJson: telemetryArgsJson(member.parsed),
|
|
975
|
+
result: guarded,
|
|
976
|
+
batchIndex: index,
|
|
977
|
+
batchSize: batch.length,
|
|
978
|
+
});
|
|
979
|
+
return guarded;
|
|
980
|
+
}
|
|
981
|
+
try {
|
|
982
|
+
const r = await runOneTool(member.call, member.parsed, opts, execute, preDecisions.get(index) ?? null);
|
|
983
|
+
const memberEnd = Date.now();
|
|
984
|
+
memberDurations[index] = Math.max(0, memberEnd - memberStart);
|
|
985
|
+
reportToolCall({
|
|
986
|
+
step,
|
|
987
|
+
toolCallId: member.call?.id ?? "",
|
|
988
|
+
name: member.call?.function?.name ?? "(unknown)",
|
|
989
|
+
startedAt: telemetryIso(memberStart),
|
|
990
|
+
endedAt: telemetryIso(memberEnd),
|
|
991
|
+
durationMs: Math.max(0, memberEnd - memberStart),
|
|
992
|
+
argsJson: telemetryArgsJson(member.parsed),
|
|
993
|
+
result: r,
|
|
994
|
+
batchIndex: index,
|
|
995
|
+
batchSize: batch.length,
|
|
996
|
+
});
|
|
997
|
+
return r;
|
|
998
|
+
}
|
|
999
|
+
catch (e) {
|
|
1000
|
+
const memberEnd = Date.now();
|
|
1001
|
+
const cancelled = isCancelError(e) || signal?.aborted;
|
|
1002
|
+
if (!cancelled) {
|
|
1003
|
+
failures += 1;
|
|
1004
|
+
noteBottleneck(member.call?.function?.name ?? "(unknown)", Math.max(0, memberEnd - memberStart));
|
|
1005
|
+
}
|
|
1006
|
+
reportToolCall({
|
|
1007
|
+
step,
|
|
1008
|
+
toolCallId: member.call?.id ?? "",
|
|
1009
|
+
name: member.call?.function?.name ?? "(unknown)",
|
|
1010
|
+
startedAt: telemetryIso(memberStart),
|
|
1011
|
+
endedAt: telemetryIso(memberEnd),
|
|
1012
|
+
durationMs: Math.max(0, memberEnd - memberStart),
|
|
1013
|
+
argsJson: telemetryArgsJson(member.parsed),
|
|
1014
|
+
result: e instanceof Error ? e.message : String(e),
|
|
1015
|
+
cancelled: cancelled ? true : undefined,
|
|
1016
|
+
threw: cancelled ? undefined : true,
|
|
1017
|
+
batchIndex: index,
|
|
1018
|
+
batchSize: batch.length,
|
|
1019
|
+
});
|
|
1020
|
+
throw e;
|
|
1021
|
+
}
|
|
1022
|
+
}));
|
|
1023
|
+
}
|
|
1024
|
+
catch (e) {
|
|
1025
|
+
if (isCancelError(e) || signal?.aborted)
|
|
1026
|
+
throw new LoopCancelledError();
|
|
1027
|
+
throw e;
|
|
1028
|
+
}
|
|
1029
|
+
for (let i = 0; i < batch.length; i++) {
|
|
1030
|
+
const member = batch[i];
|
|
1031
|
+
await commitToolResult(member.call?.function?.name ?? "(unknown)", member.parsed, member.call, results[i], memberDurations[i]);
|
|
1032
|
+
}
|
|
1033
|
+
if (repHardStop) {
|
|
1034
|
+
const stopBase = msg.content ?? "";
|
|
1035
|
+
const stopNotice = `${stopBase}${stopBase ? "\n" : ""}${repetitionStopNotice(repHardStop.sig, repHardStop.consecutive)}`;
|
|
1036
|
+
history.push({ role: "assistant", content: stopNotice });
|
|
1037
|
+
try {
|
|
1038
|
+
opts?.onPhase?.("done");
|
|
1039
|
+
}
|
|
1040
|
+
catch {
|
|
1041
|
+
// ignore
|
|
1042
|
+
}
|
|
1043
|
+
return stopNotice;
|
|
1044
|
+
}
|
|
596
1045
|
}
|
|
597
1046
|
}
|
|
598
1047
|
}
|
|
1048
|
+
finally {
|
|
1049
|
+
finishStats();
|
|
1050
|
+
}
|
|
599
1051
|
}
|