atom-agent 0.3.0 → 1.1.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 +82 -0
- package/README.md +83 -32
- package/dist/App.js +2178 -318
- package/dist/adapters.js +146 -15
- package/dist/agent/gates.js +153 -0
- package/dist/agent/loop-guard.js +184 -0
- package/dist/agent/loop.js +908 -0
- package/dist/agent/normalize.js +144 -0
- package/dist/agent/types.js +1 -0
- package/dist/auth.js +2 -1
- package/dist/cli.js +68 -6
- package/dist/compact.js +6 -48
- package/dist/config.js +171 -0
- package/dist/context-manager.js +564 -0
- package/dist/kilo.js +343 -0
- package/dist/local-discovery.js +308 -0
- package/dist/policy.js +286 -0
- package/dist/prompt-cache.js +99 -0
- package/dist/providers.js +183 -2
- package/dist/rollback.js +21 -0
- package/dist/scheduler.js +247 -0
- package/dist/session.js +35 -3
- package/dist/skills.js +214 -43
- package/dist/snapshots.js +57 -2
- package/dist/system.js +8 -1
- package/dist/telemetry-dashboard.js +589 -0
- package/dist/telemetry-server.js +301 -0
- package/dist/telemetry.js +1056 -0
- package/dist/tools/dir-cache.js +207 -0
- package/dist/tools/filesystem.js +149 -0
- package/dist/tools/fingerprints.js +33 -0
- package/dist/tools/overflow.js +76 -0
- package/dist/tools/read-cache.js +160 -0
- package/dist/tools/registry.js +802 -0
- package/dist/tools/search.js +242 -0
- package/dist/tools/shared.js +31 -0
- package/dist/tools/shell.js +273 -0
- package/dist/tools/todo.js +191 -0
- package/dist/tools/web.js +454 -0
- package/dist/tools.js +17 -1863
- package/dist/ui/activity.js +51 -0
- package/dist/ui/diff-panel.js +55 -0
- package/dist/ui/diff-view.js +112 -0
- package/dist/ui/diff.js +422 -0
- package/dist/ui/errors.js +129 -0
- package/dist/ui/highlight.js +120 -0
- package/dist/ui/input-model.js +115 -0
- package/dist/ui/input.js +40 -0
- package/dist/ui/live-tail.js +15 -0
- package/dist/ui/markdown.js +525 -0
- package/dist/ui/modals.js +47 -0
- package/dist/ui/palette.js +70 -0
- package/dist/ui/pickers.js +32 -0
- package/dist/ui/side-by-side.js +144 -0
- package/dist/ui/status-bar.js +75 -0
- package/dist/ui/theme.js +128 -0
- package/dist/ui/todo-panel.js +30 -0
- package/dist/ui/tool-inspector.js +59 -0
- package/dist/ui/transcript.js +128 -0
- package/dist/zen.js +145 -666
- package/package.json +1 -1
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
// Shared agentic-loop core: the SINGLE loop implementation backing both
|
|
2
|
+
// runAgenticLoop and runAgenticLoopForProvider (same tool/rollback contract).
|
|
3
|
+
// Moved verbatim from src/zen.ts; zen.ts re-exports runLoopWithChat so
|
|
4
|
+
// existing importers keep working untouched.
|
|
5
|
+
//
|
|
6
|
+
// Sequencing: each assistant message's tool_calls block is partitioned by
|
|
7
|
+
// planBatches — a batch of parallel-safe calls runs concurrently and its
|
|
8
|
+
// results commit in call order (re-paired by index, one transcript entry per
|
|
9
|
+
// call); everything else executes strictly serially in program order. A
|
|
10
|
+
// failure in one call NEVER skips the remaining commits of its block when
|
|
11
|
+
// the results are values; malformed calls yield their error result inline.
|
|
12
|
+
// A thrown execution error (or cancel) aborts the turn exactly as the old
|
|
13
|
+
// serial loop did — the caller rolls the partial turn back, so
|
|
14
|
+
// assistant/tool pairing stays valid.
|
|
15
|
+
//
|
|
16
|
+
// Dependency direction: agent/loop -> {tools, tools/read-cache,
|
|
17
|
+
// scheduler, config, context-manager, agent/gates, agent/loop-guard,
|
|
18
|
+
// agent/normalize, agent/types} and NOT zen (transports stay in
|
|
19
|
+
// zen.ts; runAgenticLoopForProvider wraps this loop from there).
|
|
20
|
+
import { loadAtomConfig } from "../config.js";
|
|
21
|
+
import { createContextManager, historyCharBudget, historyChars, historyMessageBudget, truncateHistoryWithCaps, } from "../context-manager.js";
|
|
22
|
+
import { planBatches } from "../scheduler.js";
|
|
23
|
+
import { describeToolCall, executeTool, invalidCall, MAX_TOOL_STEPS, needsApproval, toolNames, validateAskQuestionArgs, validateToolArgs, } from "../tools.js";
|
|
24
|
+
import { getReadCacheStats } from "../tools/read-cache.js";
|
|
25
|
+
import { bashExitCode, evaluateTurnEnd, isCodePath, isVerificationCommand, openTodoNeedles, } from "./gates.js";
|
|
26
|
+
import { errorStreakFollowUp, ErrorStreakTracker, repetitionFollowUp, RepetitionGuard, repetitionStopNotice, } from "./loop-guard.js";
|
|
27
|
+
import { normalizeChatResult, normalizeToolResult, toolSignature } from "./normalize.js";
|
|
28
|
+
// Whole-turn cancellation: thrown when the user cancels (Ctrl+C) mid-loop.
|
|
29
|
+
// The App catches it, rolls the partial turn back (same splice contract as
|
|
30
|
+
// POST failure), renders one dim `(cancelled)` line, and returns to a clean
|
|
31
|
+
// input state. Never retried, never a tool result.
|
|
32
|
+
export class LoopCancelledError extends Error {
|
|
33
|
+
constructor() {
|
|
34
|
+
super("(cancelled)");
|
|
35
|
+
this.name = "LoopCancelledError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function isCancelError(e) {
|
|
39
|
+
if (e instanceof LoopCancelledError)
|
|
40
|
+
return true;
|
|
41
|
+
if (e instanceof Error && e.name === "LoopCancelledError")
|
|
42
|
+
return true;
|
|
43
|
+
// fetch abort surfaces as DOMException AbortError (or Error with that name
|
|
44
|
+
// in mocks). Treat any AbortError as a cancellation, never a retry.
|
|
45
|
+
if (e instanceof Error && e.name === "AbortError")
|
|
46
|
+
return true;
|
|
47
|
+
if (typeof DOMException !== "undefined" && e instanceof DOMException && e.name === "AbortError") {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
export function throwIfCancelled(signal) {
|
|
53
|
+
if (signal?.aborted)
|
|
54
|
+
throw new LoopCancelledError();
|
|
55
|
+
}
|
|
56
|
+
// Tool-round budget for one agentic turn (env → atom.json → 30).
|
|
57
|
+
// A real explore → implement → verify task needs 15–30 tool rounds, so the
|
|
58
|
+
// default is 30; an explicit `opts.maxSteps` still wins (tests inject it).
|
|
59
|
+
export function toolStepBudget() {
|
|
60
|
+
const raw = process.env.ATOM_MAX_TOOL_STEPS;
|
|
61
|
+
if (raw !== undefined) {
|
|
62
|
+
const text = raw.trim();
|
|
63
|
+
if (/^\d+$/.test(text)) {
|
|
64
|
+
const n = Number(text);
|
|
65
|
+
if (Number.isFinite(n))
|
|
66
|
+
return Math.min(Math.max(Math.floor(n), 5), 100);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return loadAtomConfig().config.maxToolSteps ?? MAX_TOOL_STEPS;
|
|
70
|
+
}
|
|
71
|
+
// Legacy trim entry: byte-identical contract (legacy env/config/default caps
|
|
72
|
+
// + live todo pinning, same notice, same in-place splice). New code should
|
|
73
|
+
// use a ContextManager (derived, window-aware caps); the loop core does when
|
|
74
|
+
// it knows the model (see AgenticOpts.context).
|
|
75
|
+
export function truncateHistory(history, notify, reserve) {
|
|
76
|
+
return truncateHistoryWithCaps(history, { maxMessages: historyMessageBudget(), maxChars: historyCharBudget() }, { notify, reserve, todoNeedles: openTodoNeedles() });
|
|
77
|
+
}
|
|
78
|
+
// Per-tool outer timeout (ms): undefined → default 60s (enabled); explicit
|
|
79
|
+
// <=0/NaN → disabled (direct await, zero overhead). Clamped 1s–120s when
|
|
80
|
+
// enabled so a stuck executor can never hang the turn past the bash ceiling.
|
|
81
|
+
export const DEFAULT_TOOL_TIMEOUT_MS = 60_000;
|
|
82
|
+
export function resolveToolTimeoutMs(raw) {
|
|
83
|
+
if (raw === undefined)
|
|
84
|
+
return DEFAULT_TOOL_TIMEOUT_MS;
|
|
85
|
+
if (typeof raw !== "number" || !Number.isFinite(raw))
|
|
86
|
+
return DEFAULT_TOOL_TIMEOUT_MS;
|
|
87
|
+
if (raw <= 0)
|
|
88
|
+
return null;
|
|
89
|
+
return Math.min(Math.max(Math.floor(raw), 1000), 120_000);
|
|
90
|
+
}
|
|
91
|
+
// Total tool-call budget per turn (default 200, min 1). Existing suites peak
|
|
92
|
+
// near 30 calls/turn, so the default only caps parallel-batch explosions.
|
|
93
|
+
export const DEFAULT_MAX_TOTAL_TOOL_CALLS = 200;
|
|
94
|
+
export function resolveMaxTotalToolCalls(raw) {
|
|
95
|
+
if (typeof raw !== "number" || !Number.isFinite(raw))
|
|
96
|
+
return DEFAULT_MAX_TOTAL_TOOL_CALLS;
|
|
97
|
+
return Math.max(1, Math.floor(raw));
|
|
98
|
+
}
|
|
99
|
+
// Race one execution against the outer timeout. Timeout resolves to an
|
|
100
|
+
// `Error:` result (the model adapts); the underlying promise is left to
|
|
101
|
+
// settle — executors own their own cleanup.
|
|
102
|
+
//
|
|
103
|
+
// Cancellation is DELIBERATELY not raced here: the pinned contract is that
|
|
104
|
+
// an in-flight tool runs to completion and its result IS recorded, with the
|
|
105
|
+
// cancel stopping the turn before the next batch/POST (see the
|
|
106
|
+
// throwIfCancelled checks between batches and before each POST). Racing
|
|
107
|
+
// abort against the execution would drop the in-flight result and break
|
|
108
|
+
// assistant/tool pairing guarantees the tests pin. A hung tool + cancel
|
|
109
|
+
// therefore waits for the timeout (≤60s), commits the timeout error, then
|
|
110
|
+
// the next boundary check throws LoopCancelledError.
|
|
111
|
+
export async function executeWithTimeout(execute, name, parsed, timeoutMs, signal) {
|
|
112
|
+
// No new executions after a cancel: refuse to start when already aborted.
|
|
113
|
+
if (signal?.aborted)
|
|
114
|
+
throw new LoopCancelledError();
|
|
115
|
+
if (timeoutMs === null) {
|
|
116
|
+
return execute(name, parsed);
|
|
117
|
+
}
|
|
118
|
+
let timer = null;
|
|
119
|
+
try {
|
|
120
|
+
const execP = execute(name, parsed);
|
|
121
|
+
const timeoutP = new Promise((_resolve, reject) => {
|
|
122
|
+
timer = setTimeout(() => {
|
|
123
|
+
const err = new Error(`timeout after ${timeoutMs}ms`);
|
|
124
|
+
err.code = "ToolTimeout";
|
|
125
|
+
reject(err);
|
|
126
|
+
}, timeoutMs);
|
|
127
|
+
});
|
|
128
|
+
try {
|
|
129
|
+
return await Promise.race([execP, timeoutP]);
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
if (isCancelError(e))
|
|
133
|
+
throw e;
|
|
134
|
+
if (e?.code === "ToolTimeout" || e?.message?.startsWith("timeout after ")) {
|
|
135
|
+
return `Error: ${name} timed out after ${timeoutMs}ms — retry with a narrower scope or smaller input.`;
|
|
136
|
+
}
|
|
137
|
+
throw e;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
if (timer)
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Execute one parsed tool call through validation + permission +
|
|
146
|
+
// ask_question gates. Model mistakes (unknown name, invalid args) return
|
|
147
|
+
// repairs-oriented results WITHOUT executing; cancellations propagate as
|
|
148
|
+
// LoopCancelledError (never a result, never retried). Everything else
|
|
149
|
+
// returns a result string fed back to the model:
|
|
150
|
+
// - ask_question never needs approval; without an askUser hook it resolves
|
|
151
|
+
// to "Error: ask_question has no UI hook".
|
|
152
|
+
// - write/edit/bash consult the approve hook when one is provided; a "no"
|
|
153
|
+
// resolves to "Error: denied by user: <tool>" (final, no retry/rollback).
|
|
154
|
+
// Without a hook every tool executes immediately.
|
|
155
|
+
async function runOneTool(call, parsed, opts, execute) {
|
|
156
|
+
const name = call?.function?.name ?? "(unknown)";
|
|
157
|
+
// Unknown tool: model mistake — list actual names, never execute.
|
|
158
|
+
if (!toolNames().includes(name)) {
|
|
159
|
+
return `Error: unknown tool "${name}". Available: ${toolNames().join(", ")}`;
|
|
160
|
+
}
|
|
161
|
+
// Argument validation BEFORE approval/execution: model mistake, never runs.
|
|
162
|
+
const detail = validateToolArgs(name, parsed);
|
|
163
|
+
if (detail) {
|
|
164
|
+
return invalidCall(detail);
|
|
165
|
+
}
|
|
166
|
+
if (name === "ask_question") {
|
|
167
|
+
throwIfCancelled(opts?.signal);
|
|
168
|
+
// If the signal aborts during the modal, runAskQuestion rejects with
|
|
169
|
+
// LoopCancelledError (no result). If it resolves just as the signal
|
|
170
|
+
// aborts, return the result — the loop records it, then stops before
|
|
171
|
+
// the next POST (no new POSTs, pairing stays valid until rollback).
|
|
172
|
+
return runAskQuestion(parsed, opts?.askUser, opts?.signal);
|
|
173
|
+
}
|
|
174
|
+
if (opts?.approve && needsApproval(name)) {
|
|
175
|
+
let decision;
|
|
176
|
+
try {
|
|
177
|
+
decision = await opts.approve(name, parsed);
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
// Whole-turn cancellation must propagate (Ctrl+C cancels the turn,
|
|
181
|
+
// not just deny one call). Anything else is a denial.
|
|
182
|
+
if (isCancelError(e) || opts?.signal?.aborted)
|
|
183
|
+
throw new LoopCancelledError();
|
|
184
|
+
decision = "no";
|
|
185
|
+
}
|
|
186
|
+
// Abort that lands as a resolved denial still cancels the whole turn.
|
|
187
|
+
throwIfCancelled(opts?.signal);
|
|
188
|
+
if (decision === "no") {
|
|
189
|
+
return `Error: denied by user: ${name}`;
|
|
190
|
+
}
|
|
191
|
+
// "once" runs this call; "always" runs it too (the caller caches the
|
|
192
|
+
// always-allowed set session-wide so later calls skip the prompt).
|
|
193
|
+
}
|
|
194
|
+
// No new executions after a cancel: stop after the current tool finishes.
|
|
195
|
+
// The current tool (if already running) is awaited to completion and its
|
|
196
|
+
// result IS recorded — the loop then stops before the next tool/POST, so
|
|
197
|
+
// assistant/tool pairing stays valid until the caller rolls back.
|
|
198
|
+
throwIfCancelled(opts?.signal);
|
|
199
|
+
const timeoutMs = resolveToolTimeoutMs(opts?.toolTimeoutMs);
|
|
200
|
+
const doNormalize = opts?.normalizeResults !== false;
|
|
201
|
+
try {
|
|
202
|
+
const raw = await executeWithTimeout(execute, name, parsed, timeoutMs, opts?.signal);
|
|
203
|
+
if (doNormalize)
|
|
204
|
+
return normalizeToolResult(raw);
|
|
205
|
+
return typeof raw === "string" ? raw : normalizeToolResult(raw);
|
|
206
|
+
}
|
|
207
|
+
catch (e) {
|
|
208
|
+
if (isCancelError(e) || opts?.signal?.aborted)
|
|
209
|
+
throw new LoopCancelledError();
|
|
210
|
+
throw e;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async function runAskQuestion(parsed, askUser, signal) {
|
|
214
|
+
const invalid = validateAskQuestionArgs(parsed);
|
|
215
|
+
if (invalid)
|
|
216
|
+
return invalid;
|
|
217
|
+
if (!askUser)
|
|
218
|
+
return "Error: ask_question has no UI hook";
|
|
219
|
+
const q = parsed;
|
|
220
|
+
const allowCustom = q.allowCustom === true;
|
|
221
|
+
try {
|
|
222
|
+
const answer = await askUser(q.question, q.options, allowCustom);
|
|
223
|
+
if (typeof answer === "string" && answer.startsWith("Error:"))
|
|
224
|
+
return answer;
|
|
225
|
+
return JSON.stringify({ answer });
|
|
226
|
+
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
// Whole-turn cancellation (Ctrl+C) propagates — it is NOT the Esc
|
|
229
|
+
// question-cancel result below.
|
|
230
|
+
if (isCancelError(e) || signal?.aborted)
|
|
231
|
+
throw new LoopCancelledError();
|
|
232
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
233
|
+
if (/cancel/i.test(msg))
|
|
234
|
+
return "Error: question cancelled by user";
|
|
235
|
+
return `Error: ${msg}`;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// Shared agentic-loop core: the SINGLE loop implementation backing both
|
|
239
|
+
// runAgenticLoop and runAgenticLoopForProvider (same tool/rollback contract).
|
|
240
|
+
// Sequencing: each assistant message's tool_calls block is partitioned by
|
|
241
|
+
// planBatches — a batch of parallel-safe calls runs concurrently and its
|
|
242
|
+
// results commit in call order (re-paired by index, one transcript entry per
|
|
243
|
+
// call); everything else executes strictly serially in program order. A
|
|
244
|
+
// failure in one call NEVER skips the remaining commits of its block when
|
|
245
|
+
// the results are values (each result pairs with its tool_call_id in
|
|
246
|
+
// order); malformed calls (bad JSON, unknown name, failed validation) yield
|
|
247
|
+
// their error result inline and the block continues. Validation/unknown/
|
|
248
|
+
// denial/cancel are never retried — only transient transport failures retry
|
|
249
|
+
// (inside chatCompletion). A thrown execution error (or cancel) aborts the
|
|
250
|
+
// turn exactly as the old serial loop did — the caller rolls the partial
|
|
251
|
+
// turn back, so assistant/tool pairing stays valid.
|
|
252
|
+
export async function runLoopWithChat(chatFn, history, opts) {
|
|
253
|
+
const execute = opts?.execute ?? executeTool;
|
|
254
|
+
const maxSteps = opts?.maxSteps ?? toolStepBudget();
|
|
255
|
+
const signal = opts?.signal ?? null;
|
|
256
|
+
// Local observability sink (see src/telemetry.ts): optional, observer-only.
|
|
257
|
+
// Every hook call below is guarded, so telemetry can never break the turn;
|
|
258
|
+
// absent → a few Date.now() reads per call, negligible and identical.
|
|
259
|
+
const telemetry = opts?.telemetry;
|
|
260
|
+
const telemetryIso = (ms) => {
|
|
261
|
+
try {
|
|
262
|
+
return new Date(ms).toISOString();
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return new Date().toISOString();
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
const telemetryArgsJson = (value) => {
|
|
269
|
+
try {
|
|
270
|
+
const s = JSON.stringify(value ?? {});
|
|
271
|
+
return typeof s === "string" ? s : "{}";
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return "{}";
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
const reportModelCall = (info) => {
|
|
278
|
+
try {
|
|
279
|
+
telemetry?.onModelCall?.(info);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// observer errors never break the loop
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
const reportToolCall = (info) => {
|
|
286
|
+
try {
|
|
287
|
+
telemetry?.onToolCall?.(info);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
// observer errors never break the loop
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
// Explicit verification state (no transcript parsing — the gate reads
|
|
294
|
+
// these, never model prose):
|
|
295
|
+
// - filesWritten: any write/edit executed (legacy compat signal).
|
|
296
|
+
// - needsVerification: a CODE-path write/edit is still awaiting a passing
|
|
297
|
+
// check (docs/configs never arm it — no false positives).
|
|
298
|
+
// - unverifiedPaths: which code paths (insertion order, unique) for messages.
|
|
299
|
+
// - verifiedAfterWrite: an exit-0 verification command ran (clears the rest).
|
|
300
|
+
// Only evidence AFTER the last write counts, so each new write resets.
|
|
301
|
+
let filesWritten = false;
|
|
302
|
+
let verifiedAfterWrite = false;
|
|
303
|
+
let needsVerification = false;
|
|
304
|
+
let unverifiedPaths = [];
|
|
305
|
+
// Verification-gate nag cycles spent (bounds the continue loop alongside
|
|
306
|
+
// the step budget — a model that never verifies still terminates).
|
|
307
|
+
let verifyRounds = 0;
|
|
308
|
+
// At most one truncation notice per turn; silence when nothing dropped.
|
|
309
|
+
let truncationNoticed = false;
|
|
310
|
+
// Window-aware trimmer when the caller knows the model (App passes it);
|
|
311
|
+
// created once per turn — ceiling sources resolve once, history is
|
|
312
|
+
// re-measured every step. Absent → the legacy fixed caps below.
|
|
313
|
+
const contextManager = opts?.context
|
|
314
|
+
? createContextManager({ model: opts.context.model, toolsChars: opts.context.toolsChars })
|
|
315
|
+
: null;
|
|
316
|
+
// ---- Hardened-loop state (additive; defaults preserve the pinned
|
|
317
|
+
// maxSteps contract — see AgenticOpts docs) ----
|
|
318
|
+
const maxTotalToolCalls = resolveMaxTotalToolCalls(opts?.maxTotalToolCalls);
|
|
319
|
+
const repGuard = new RepetitionGuard({ maxRepeatedCalls: opts?.maxRepeatedCalls });
|
|
320
|
+
const errStreak = new ErrorStreakTracker(opts?.maxConsecutiveErrors);
|
|
321
|
+
const turnStartMs = Date.now();
|
|
322
|
+
let startChars = 0;
|
|
323
|
+
try {
|
|
324
|
+
startChars = historyChars(history);
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
startChars = 0;
|
|
328
|
+
}
|
|
329
|
+
let cacheHitsStart = 0;
|
|
330
|
+
try {
|
|
331
|
+
cacheHitsStart = getReadCacheStats().hits;
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
cacheHitsStart = 0;
|
|
335
|
+
}
|
|
336
|
+
let modelCalls = 0;
|
|
337
|
+
let toolCalls = 0;
|
|
338
|
+
let failures = 0;
|
|
339
|
+
let droppedTurnsTotal = 0;
|
|
340
|
+
let bottleneck = null;
|
|
341
|
+
const noteBottleneck = (name, durationMs) => {
|
|
342
|
+
if (!Number.isFinite(durationMs) || durationMs < 0)
|
|
343
|
+
return;
|
|
344
|
+
if (!bottleneck || durationMs > bottleneck.durationMs) {
|
|
345
|
+
bottleneck = { name, durationMs: Math.floor(durationMs) };
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
const finishStats = () => {
|
|
349
|
+
try {
|
|
350
|
+
let endChars = startChars;
|
|
351
|
+
try {
|
|
352
|
+
endChars = historyChars(history);
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
endChars = startChars;
|
|
356
|
+
}
|
|
357
|
+
let cacheHits = 0;
|
|
358
|
+
try {
|
|
359
|
+
cacheHits = Math.max(0, getReadCacheStats().hits - cacheHitsStart);
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
cacheHits = 0;
|
|
363
|
+
}
|
|
364
|
+
const stats = {
|
|
365
|
+
steps: modelCalls,
|
|
366
|
+
modelCalls,
|
|
367
|
+
toolCalls,
|
|
368
|
+
failures,
|
|
369
|
+
repetitionHits: repGuard.hitCount,
|
|
370
|
+
cacheHits,
|
|
371
|
+
truncationNotices: droppedTurnsTotal,
|
|
372
|
+
durationMs: Math.max(0, Date.now() - turnStartMs),
|
|
373
|
+
bottleneck,
|
|
374
|
+
contextGrowthChars: endChars - startChars,
|
|
375
|
+
};
|
|
376
|
+
opts?.onLoopStats?.(stats);
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
// observer errors never break the turn
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
try {
|
|
383
|
+
for (let step = 0;; step++) {
|
|
384
|
+
throwIfCancelled(signal);
|
|
385
|
+
// Steering seam: drain one pending steer message (if any) at this safe
|
|
386
|
+
// point — previous tool batches are fully committed, so assistant/tool
|
|
387
|
+
// pairing can never split. Runs before the budget trim so truncation
|
|
388
|
+
// accounts for the injected message. No-op without the hook.
|
|
389
|
+
try {
|
|
390
|
+
opts?.drainSteer?.();
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
// observer errors never break the loop
|
|
394
|
+
}
|
|
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
|
+
let msg;
|
|
423
|
+
const modelStart = Date.now();
|
|
424
|
+
try {
|
|
425
|
+
msg = await chatFn(history, {
|
|
426
|
+
onToken: opts?.onToken,
|
|
427
|
+
onPhase: opts?.onPhase,
|
|
428
|
+
onToolDelta: opts?.onToolDelta,
|
|
429
|
+
onWarning: opts?.onWarning,
|
|
430
|
+
onThinking: opts?.onThinking,
|
|
431
|
+
sleep: opts?.sleep,
|
|
432
|
+
reasoningEffort: opts?.reasoningEffort,
|
|
433
|
+
signal,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
catch (e) {
|
|
437
|
+
// A failed POST still records its model call (with the error) so the
|
|
438
|
+
// trace shows what was attempted — the caller still rolls back.
|
|
439
|
+
const modelEnd = Date.now();
|
|
440
|
+
reportModelCall({
|
|
441
|
+
step,
|
|
442
|
+
startedAt: telemetryIso(modelStart),
|
|
443
|
+
endedAt: telemetryIso(modelEnd),
|
|
444
|
+
durationMs: Math.max(0, modelEnd - modelStart),
|
|
445
|
+
usageReported: false,
|
|
446
|
+
toolCallCount: 0,
|
|
447
|
+
finishReason: "error",
|
|
448
|
+
error: e instanceof Error ? e.message : String(e),
|
|
449
|
+
});
|
|
450
|
+
if (isCancelError(e) || signal?.aborted)
|
|
451
|
+
throw new LoopCancelledError();
|
|
452
|
+
throw e;
|
|
453
|
+
}
|
|
454
|
+
throwIfCancelled(signal);
|
|
455
|
+
// Defensive normalization (malformed custom chatFn responses never crash
|
|
456
|
+
// the commit path): dropped calls surface via onWarning, pairing stays
|
|
457
|
+
// valid because only validated calls reach the batch planner.
|
|
458
|
+
try {
|
|
459
|
+
const norm = normalizeChatResult(msg);
|
|
460
|
+
if (norm.warnings.length > 0) {
|
|
461
|
+
for (const w of norm.warnings) {
|
|
462
|
+
try {
|
|
463
|
+
opts?.onWarning?.(w);
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
// ignore observer errors
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
msg = norm.result;
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
// normalization never breaks the turn; the raw message stands
|
|
474
|
+
}
|
|
475
|
+
modelCalls += 1;
|
|
476
|
+
if (msg.usage !== undefined) {
|
|
477
|
+
// Spend accounting: EVERY POST that reports usage forwards it, and the
|
|
478
|
+
// caller accumulates each report as billed spend — tool-round POSTs,
|
|
479
|
+
// summary POSTs, and successful retries each count once. Attempts that
|
|
480
|
+
// fail (HTTP/network/truncation) report no usage, so there is nothing
|
|
481
|
+
// to dedupe: each attempt that reached the provider and reported counts
|
|
482
|
+
// exactly once. Usage is never synthesized or estimated here.
|
|
483
|
+
try {
|
|
484
|
+
opts?.onUsage?.(msg.usage);
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
// ignore
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
if (msg.reasoning !== undefined) {
|
|
491
|
+
try {
|
|
492
|
+
opts?.onReasoning?.(msg.reasoning);
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
// ignore
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
{
|
|
499
|
+
// Completed model call: usage is forwarded only when the response
|
|
500
|
+
// actually carried it (usageReported) — never synthesized here.
|
|
501
|
+
const modelEnd = Date.now();
|
|
502
|
+
const callsCount = (msg.tool_calls ?? []).length;
|
|
503
|
+
reportModelCall({
|
|
504
|
+
step,
|
|
505
|
+
startedAt: telemetryIso(modelStart),
|
|
506
|
+
endedAt: telemetryIso(modelEnd),
|
|
507
|
+
durationMs: Math.max(0, modelEnd - modelStart),
|
|
508
|
+
usage: msg.usage,
|
|
509
|
+
usageReported: msg.usage !== undefined,
|
|
510
|
+
reasoningLabel: msg.reasoning,
|
|
511
|
+
toolCallCount: callsCount,
|
|
512
|
+
finishReason: callsCount === 0 ? "final" : "tool_calls",
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
const calls = msg.tool_calls ?? [];
|
|
516
|
+
if (calls.length === 0) {
|
|
517
|
+
// Turn-continuation seam (ticket 03): the todo guard and verification
|
|
518
|
+
// gate run as entries in TURN_END_GATES — one chain, one commit point.
|
|
519
|
+
// Behavior is byte-identical to the two inline blocks this replaced.
|
|
520
|
+
const outcome = evaluateTurnEnd(msg.content ?? "", {
|
|
521
|
+
step,
|
|
522
|
+
maxSteps,
|
|
523
|
+
filesWritten,
|
|
524
|
+
verifiedAfterWrite,
|
|
525
|
+
needsVerification,
|
|
526
|
+
unverifiedPaths: [...unverifiedPaths],
|
|
527
|
+
verifyRounds,
|
|
528
|
+
});
|
|
529
|
+
if (outcome.kind === "continue") {
|
|
530
|
+
// Verification-gate continues are bounded per turn (alongside the
|
|
531
|
+
// step budget) so a model that never verifies still terminates.
|
|
532
|
+
if (outcome.via === "verification")
|
|
533
|
+
verifyRounds += 1;
|
|
534
|
+
history.push({ role: "assistant", content: outcome.assistantText });
|
|
535
|
+
history.push({ role: "user", content: outcome.followUp });
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
// Error-streak recovery (additive, after the pinned gates): ending on
|
|
539
|
+
// sustained unaddressed `Error:` results is almost always premature.
|
|
540
|
+
// Single errors still end normally (the model may be reporting a
|
|
541
|
+
// blocker); a streak holds final text for one fix-forward attempt,
|
|
542
|
+
// bounded to 2 holds per turn.
|
|
543
|
+
if (errStreak.shouldHoldFinal(2)) {
|
|
544
|
+
const streak = errStreak.current;
|
|
545
|
+
history.push({ role: "assistant", content: outcome.finalText });
|
|
546
|
+
history.push({ role: "user", content: errorStreakFollowUp(streak) });
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
history.push({ role: "assistant", content: outcome.finalText });
|
|
550
|
+
try {
|
|
551
|
+
opts?.onPhase?.("done");
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
// ignore
|
|
555
|
+
}
|
|
556
|
+
return outcome.finalText;
|
|
557
|
+
}
|
|
558
|
+
if (step >= maxSteps) {
|
|
559
|
+
const base = msg.content ?? "";
|
|
560
|
+
const notice = `${base}${base ? "\n" : ""}(stopped: too many tool steps) (limit is ${maxSteps}; raise with ATOM_MAX_TOOL_STEPS=<n>)`;
|
|
561
|
+
history.push({ role: "assistant", content: notice });
|
|
562
|
+
try {
|
|
563
|
+
opts?.onPhase?.("done");
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
// ignore
|
|
567
|
+
}
|
|
568
|
+
return notice;
|
|
569
|
+
}
|
|
570
|
+
// Total tool-call budget (parallel-batch explosion guard): counts every
|
|
571
|
+
// tool_call the model emits, mirroring the maxSteps stop contract. The
|
|
572
|
+
// default (200) never binds the pinned suites (~30 calls/turn).
|
|
573
|
+
if (toolCalls + calls.length > maxTotalToolCalls) {
|
|
574
|
+
const base = msg.content ?? "";
|
|
575
|
+
const notice = `${base}${base ? "\n" : ""}(stopped: too many tool calls) (limit is ${maxTotalToolCalls} per turn)`;
|
|
576
|
+
history.push({ role: "assistant", content: notice });
|
|
577
|
+
try {
|
|
578
|
+
opts?.onPhase?.("done");
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
// ignore
|
|
582
|
+
}
|
|
583
|
+
return notice;
|
|
584
|
+
}
|
|
585
|
+
history.push({ role: "assistant", content: msg.content ?? null, tool_calls: calls });
|
|
586
|
+
// Commit helper shared by the serial and parallel paths: Task 7
|
|
587
|
+
// bookkeeping + one ordered transcript entry per call. Only successful
|
|
588
|
+
// executions count — denials, validation errors, and unknown tools (all
|
|
589
|
+
// `Error:` results) never ran, so they neither arm nor clear the gate.
|
|
590
|
+
const commitToolResult = (name, parsed, call, result, durationMs) => {
|
|
591
|
+
const isError = typeof result === "string" && result.startsWith("Error");
|
|
592
|
+
toolCalls += 1;
|
|
593
|
+
if (isError)
|
|
594
|
+
failures += 1;
|
|
595
|
+
errStreak.noteResult(isError);
|
|
596
|
+
if (typeof durationMs === "number")
|
|
597
|
+
noteBottleneck(name, durationMs);
|
|
598
|
+
if (!isError && (name === "write" || name === "edit")) {
|
|
599
|
+
filesWritten = true;
|
|
600
|
+
verifiedAfterWrite = false;
|
|
601
|
+
const p = typeof parsed["path"] === "string" ? parsed["path"] : "";
|
|
602
|
+
if (isCodePath(p)) {
|
|
603
|
+
needsVerification = true;
|
|
604
|
+
if (p.length > 0 && !unverifiedPaths.includes(p))
|
|
605
|
+
unverifiedPaths.push(p);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
else if (!isError && name === "bash") {
|
|
609
|
+
const command = parsed["command"];
|
|
610
|
+
if (typeof command === "string" && isVerificationCommand(command) && filesWritten) {
|
|
611
|
+
const exit = bashExitCode(result);
|
|
612
|
+
if (exit === null || exit === 0) {
|
|
613
|
+
// Passing check (or a legacy runner that reports no envelope):
|
|
614
|
+
// clears everything the gate tracks.
|
|
615
|
+
verifiedAfterWrite = true;
|
|
616
|
+
needsVerification = false;
|
|
617
|
+
unverifiedPaths = [];
|
|
618
|
+
}
|
|
619
|
+
else {
|
|
620
|
+
// A FAILED check is evidence of failure, not of verification:
|
|
621
|
+
// the gate stays armed so the model fixes forward instead of
|
|
622
|
+
// finishing on red output.
|
|
623
|
+
verifiedAfterWrite = false;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
history.push({ role: "tool", tool_call_id: call?.id ?? "", content: result });
|
|
628
|
+
try {
|
|
629
|
+
opts?.onToolActivity?.(describeToolCall(name, parsed), result, isError);
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
// ignore observer errors
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
for (const batch of planBatches(calls)) {
|
|
636
|
+
// No new executions after a cancel: the current tool (if any) already
|
|
637
|
+
// finished; stop before starting the next batch.
|
|
638
|
+
throwIfCancelled(signal);
|
|
639
|
+
if (batch.length === 1) {
|
|
640
|
+
// Serial path: byte-identical to the pre-05 loop body.
|
|
641
|
+
const call = batch[0].call;
|
|
642
|
+
const name = call?.function?.name ?? "(unknown)";
|
|
643
|
+
try {
|
|
644
|
+
opts?.onPhase?.("tool", name);
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
// ignore
|
|
648
|
+
}
|
|
649
|
+
const toolStart = Date.now();
|
|
650
|
+
let parsed;
|
|
651
|
+
try {
|
|
652
|
+
const raw = call?.function?.arguments ?? "{}";
|
|
653
|
+
const v = JSON.parse(typeof raw === "string" ? raw : "{}");
|
|
654
|
+
parsed = typeof v === "object" && v !== null ? v : {};
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
parsed = {};
|
|
658
|
+
const result = `Error: invalid call: invalid JSON arguments for tool "${name}" (arguments must be valid JSON). Fix the arguments and retry.`;
|
|
659
|
+
toolCalls += 1;
|
|
660
|
+
failures += 1;
|
|
661
|
+
errStreak.noteResult(true);
|
|
662
|
+
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
|
+
const toolEnd = Date.now();
|
|
672
|
+
reportToolCall({
|
|
673
|
+
step,
|
|
674
|
+
toolCallId: call?.id ?? "",
|
|
675
|
+
name,
|
|
676
|
+
startedAt: telemetryIso(toolStart),
|
|
677
|
+
endedAt: telemetryIso(toolEnd),
|
|
678
|
+
durationMs: Math.max(0, toolEnd - toolStart),
|
|
679
|
+
argsJson: telemetryArgsJson(call?.function?.arguments ?? "{}"),
|
|
680
|
+
result,
|
|
681
|
+
batchIndex: 0,
|
|
682
|
+
batchSize: 1,
|
|
683
|
+
});
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
let result;
|
|
687
|
+
// Repetition guard (opt-in via maxRepeatedCalls; unset = track-only
|
|
688
|
+
// so the pinned maxSteps contract holds): a repeated signature skips
|
|
689
|
+
// execution and yields a guidance error; exhausted nudges stop hard.
|
|
690
|
+
const repSig = toolSignature(name, parsed);
|
|
691
|
+
const repNote = repGuard.note(repSig, name);
|
|
692
|
+
if (repNote.intervened) {
|
|
693
|
+
const toolEndRep = Date.now();
|
|
694
|
+
if (repGuard.consumeNudge()) {
|
|
695
|
+
const guarded = `Error: invalid call: ${repetitionFollowUp(repSig, repNote.consecutive)} Fix the approach and retry.`;
|
|
696
|
+
reportToolCall({
|
|
697
|
+
step,
|
|
698
|
+
toolCallId: call?.id ?? "",
|
|
699
|
+
name,
|
|
700
|
+
startedAt: telemetryIso(toolStart),
|
|
701
|
+
endedAt: telemetryIso(toolEndRep),
|
|
702
|
+
durationMs: 0,
|
|
703
|
+
argsJson: telemetryArgsJson(parsed),
|
|
704
|
+
result: guarded,
|
|
705
|
+
batchIndex: 0,
|
|
706
|
+
batchSize: 1,
|
|
707
|
+
});
|
|
708
|
+
commitToolResult(name, parsed, call, guarded, 0);
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
const guarded = `Error: invalid call: ${repetitionFollowUp(repSig, repNote.consecutive)} Fix the approach and retry.`;
|
|
712
|
+
reportToolCall({
|
|
713
|
+
step,
|
|
714
|
+
toolCallId: call?.id ?? "",
|
|
715
|
+
name,
|
|
716
|
+
startedAt: telemetryIso(toolStart),
|
|
717
|
+
endedAt: telemetryIso(toolEndRep),
|
|
718
|
+
durationMs: 0,
|
|
719
|
+
argsJson: telemetryArgsJson(parsed),
|
|
720
|
+
result: guarded,
|
|
721
|
+
batchIndex: 0,
|
|
722
|
+
batchSize: 1,
|
|
723
|
+
});
|
|
724
|
+
commitToolResult(name, parsed, call, guarded, 0);
|
|
725
|
+
const stopBase = msg.content ?? "";
|
|
726
|
+
const stopNotice = `${stopBase}${stopBase ? "\n" : ""}${repetitionStopNotice(repSig, repNote.consecutive)}`;
|
|
727
|
+
history.push({ role: "assistant", content: stopNotice });
|
|
728
|
+
try {
|
|
729
|
+
opts?.onPhase?.("done");
|
|
730
|
+
}
|
|
731
|
+
catch {
|
|
732
|
+
// ignore
|
|
733
|
+
}
|
|
734
|
+
return stopNotice;
|
|
735
|
+
}
|
|
736
|
+
try {
|
|
737
|
+
result = await runOneTool(call, parsed, opts, execute);
|
|
738
|
+
}
|
|
739
|
+
catch (e) {
|
|
740
|
+
// A cancelled/throwing tool still records its attempt (with the
|
|
741
|
+
// cause) so the trace shows what was in flight — then the turn
|
|
742
|
+
// aborts exactly as before.
|
|
743
|
+
const toolEnd = Date.now();
|
|
744
|
+
const cancelled = isCancelError(e) || signal?.aborted;
|
|
745
|
+
if (!cancelled) {
|
|
746
|
+
failures += 1;
|
|
747
|
+
noteBottleneck(name, Math.max(0, toolEnd - toolStart));
|
|
748
|
+
}
|
|
749
|
+
reportToolCall({
|
|
750
|
+
step,
|
|
751
|
+
toolCallId: call?.id ?? "",
|
|
752
|
+
name,
|
|
753
|
+
startedAt: telemetryIso(toolStart),
|
|
754
|
+
endedAt: telemetryIso(toolEnd),
|
|
755
|
+
durationMs: Math.max(0, toolEnd - toolStart),
|
|
756
|
+
argsJson: telemetryArgsJson(parsed),
|
|
757
|
+
result: e instanceof Error ? e.message : String(e),
|
|
758
|
+
cancelled: cancelled ? true : undefined,
|
|
759
|
+
threw: cancelled ? undefined : true,
|
|
760
|
+
batchIndex: 0,
|
|
761
|
+
batchSize: 1,
|
|
762
|
+
});
|
|
763
|
+
if (cancelled)
|
|
764
|
+
throw new LoopCancelledError();
|
|
765
|
+
throw e;
|
|
766
|
+
}
|
|
767
|
+
{
|
|
768
|
+
const toolEnd = Date.now();
|
|
769
|
+
reportToolCall({
|
|
770
|
+
step,
|
|
771
|
+
toolCallId: call?.id ?? "",
|
|
772
|
+
name,
|
|
773
|
+
startedAt: telemetryIso(toolStart),
|
|
774
|
+
endedAt: telemetryIso(toolEnd),
|
|
775
|
+
durationMs: Math.max(0, toolEnd - toolStart),
|
|
776
|
+
argsJson: telemetryArgsJson(parsed),
|
|
777
|
+
result,
|
|
778
|
+
batchIndex: 0,
|
|
779
|
+
batchSize: 1,
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
commitToolResult(name, parsed, call, result, Math.max(0, Date.now() - toolStart));
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
// Parallel batch: every member is pre-validated parallel-safe (see
|
|
786
|
+
// planToolBatches), so runOneTool neither prompts nor blocks here.
|
|
787
|
+
// Phases fire upfront in call order; results commit in call order, so
|
|
788
|
+
// each call still shows separately and tool_call_ids re-pair by index.
|
|
789
|
+
// A throw (cancel or execution error) aborts the turn exactly like the
|
|
790
|
+
// serial path — the caller rolls the partial turn back.
|
|
791
|
+
for (const member of batch) {
|
|
792
|
+
try {
|
|
793
|
+
opts?.onPhase?.("tool", member.call?.function?.name ?? "(unknown)");
|
|
794
|
+
}
|
|
795
|
+
catch {
|
|
796
|
+
// ignore
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
let results;
|
|
800
|
+
const memberDurations = new Array(batch.length).fill(0);
|
|
801
|
+
// Repetition pre-notes (synchronous, in call order — deterministic):
|
|
802
|
+
// intervened members skip execution with a guidance error; exhausted
|
|
803
|
+
// nudges arm a hard stop after this batch commits (pairing stays valid).
|
|
804
|
+
const repNotes = batch.map((member) => repGuard.note(toolSignature(member.call?.function?.name ?? "(unknown)", member.parsed), member.call?.function?.name ?? "(unknown)"));
|
|
805
|
+
let repHardStop = null;
|
|
806
|
+
for (let i = 0; i < batch.length; i++) {
|
|
807
|
+
const note = repNotes[i];
|
|
808
|
+
if (note.intervened && !repGuard.consumeNudge() && !repHardStop) {
|
|
809
|
+
repHardStop = { sig: note.signature, consecutive: note.consecutive };
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
try {
|
|
813
|
+
// Each member is timed individually (concurrent wall-clock per call,
|
|
814
|
+
// not the whole batch attributed to each) and reported in call order
|
|
815
|
+
// below. A throw still aborts the turn exactly like the serial path.
|
|
816
|
+
results = await Promise.all(batch.map(async (member, index) => {
|
|
817
|
+
const memberStart = Date.now();
|
|
818
|
+
const note = repNotes[index];
|
|
819
|
+
const memberName = member.call?.function?.name ?? "(unknown)";
|
|
820
|
+
if (note.intervened) {
|
|
821
|
+
const guarded = `Error: invalid call: ${repetitionFollowUp(note.signature, note.consecutive)} Fix the approach and retry.`;
|
|
822
|
+
const memberEnd = Date.now();
|
|
823
|
+
memberDurations[index] = 0;
|
|
824
|
+
reportToolCall({
|
|
825
|
+
step,
|
|
826
|
+
toolCallId: member.call?.id ?? "",
|
|
827
|
+
name: memberName,
|
|
828
|
+
startedAt: telemetryIso(memberStart),
|
|
829
|
+
endedAt: telemetryIso(memberEnd),
|
|
830
|
+
durationMs: 0,
|
|
831
|
+
argsJson: telemetryArgsJson(member.parsed),
|
|
832
|
+
result: guarded,
|
|
833
|
+
batchIndex: index,
|
|
834
|
+
batchSize: batch.length,
|
|
835
|
+
});
|
|
836
|
+
return guarded;
|
|
837
|
+
}
|
|
838
|
+
try {
|
|
839
|
+
const r = await runOneTool(member.call, member.parsed, opts, execute);
|
|
840
|
+
const memberEnd = Date.now();
|
|
841
|
+
memberDurations[index] = Math.max(0, memberEnd - memberStart);
|
|
842
|
+
reportToolCall({
|
|
843
|
+
step,
|
|
844
|
+
toolCallId: member.call?.id ?? "",
|
|
845
|
+
name: member.call?.function?.name ?? "(unknown)",
|
|
846
|
+
startedAt: telemetryIso(memberStart),
|
|
847
|
+
endedAt: telemetryIso(memberEnd),
|
|
848
|
+
durationMs: Math.max(0, memberEnd - memberStart),
|
|
849
|
+
argsJson: telemetryArgsJson(member.parsed),
|
|
850
|
+
result: r,
|
|
851
|
+
batchIndex: index,
|
|
852
|
+
batchSize: batch.length,
|
|
853
|
+
});
|
|
854
|
+
return r;
|
|
855
|
+
}
|
|
856
|
+
catch (e) {
|
|
857
|
+
const memberEnd = Date.now();
|
|
858
|
+
const cancelled = isCancelError(e) || signal?.aborted;
|
|
859
|
+
if (!cancelled) {
|
|
860
|
+
failures += 1;
|
|
861
|
+
noteBottleneck(member.call?.function?.name ?? "(unknown)", Math.max(0, memberEnd - memberStart));
|
|
862
|
+
}
|
|
863
|
+
reportToolCall({
|
|
864
|
+
step,
|
|
865
|
+
toolCallId: member.call?.id ?? "",
|
|
866
|
+
name: member.call?.function?.name ?? "(unknown)",
|
|
867
|
+
startedAt: telemetryIso(memberStart),
|
|
868
|
+
endedAt: telemetryIso(memberEnd),
|
|
869
|
+
durationMs: Math.max(0, memberEnd - memberStart),
|
|
870
|
+
argsJson: telemetryArgsJson(member.parsed),
|
|
871
|
+
result: e instanceof Error ? e.message : String(e),
|
|
872
|
+
cancelled: cancelled ? true : undefined,
|
|
873
|
+
threw: cancelled ? undefined : true,
|
|
874
|
+
batchIndex: index,
|
|
875
|
+
batchSize: batch.length,
|
|
876
|
+
});
|
|
877
|
+
throw e;
|
|
878
|
+
}
|
|
879
|
+
}));
|
|
880
|
+
}
|
|
881
|
+
catch (e) {
|
|
882
|
+
if (isCancelError(e) || signal?.aborted)
|
|
883
|
+
throw new LoopCancelledError();
|
|
884
|
+
throw e;
|
|
885
|
+
}
|
|
886
|
+
for (let i = 0; i < batch.length; i++) {
|
|
887
|
+
const member = batch[i];
|
|
888
|
+
commitToolResult(member.call?.function?.name ?? "(unknown)", member.parsed, member.call, results[i], memberDurations[i]);
|
|
889
|
+
}
|
|
890
|
+
if (repHardStop) {
|
|
891
|
+
const stopBase = msg.content ?? "";
|
|
892
|
+
const stopNotice = `${stopBase}${stopBase ? "\n" : ""}${repetitionStopNotice(repHardStop.sig, repHardStop.consecutive)}`;
|
|
893
|
+
history.push({ role: "assistant", content: stopNotice });
|
|
894
|
+
try {
|
|
895
|
+
opts?.onPhase?.("done");
|
|
896
|
+
}
|
|
897
|
+
catch {
|
|
898
|
+
// ignore
|
|
899
|
+
}
|
|
900
|
+
return stopNotice;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
finally {
|
|
906
|
+
finishStats();
|
|
907
|
+
}
|
|
908
|
+
}
|