atom-agent 1.0.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 +31 -2
- package/README.md +12 -12
- package/dist/App.js +297 -23
- package/dist/adapters.js +84 -6
- package/dist/agent/loop-guard.js +184 -0
- package/dist/agent/loop.js +609 -300
- package/dist/agent/normalize.js +144 -0
- package/dist/cli.js +1 -1
- package/dist/system.js +1 -0
- package/dist/telemetry-dashboard.js +19 -1
- package/dist/telemetry.js +55 -0
- package/dist/tools/dir-cache.js +207 -0
- package/dist/tools/filesystem.js +40 -1
- package/dist/tools/read-cache.js +160 -0
- package/dist/tools/registry.js +79 -0
- package/dist/tools/search.js +66 -60
- package/dist/tools/shell.js +19 -0
- package/dist/tools/todo.js +1 -1
- package/dist/tools.js +2 -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/highlight.js +120 -0
- package/dist/ui/live-tail.js +2 -2
- package/dist/ui/modals.js +22 -5
- package/dist/ui/palette.js +10 -2
- package/dist/ui/side-by-side.js +144 -0
- package/dist/ui/status-bar.js +20 -4
- package/dist/ui/theme.js +6 -0
- package/dist/ui/todo-panel.js +10 -2
- package/dist/ui/transcript.js +16 -4
- package/dist/zen.js +33 -9
- package/package.json +1 -1
package/dist/agent/loop.js
CHANGED
|
@@ -13,14 +13,18 @@
|
|
|
13
13
|
// serial loop did — the caller rolls the partial turn back, so
|
|
14
14
|
// assistant/tool pairing stays valid.
|
|
15
15
|
//
|
|
16
|
-
// Dependency direction: agent/loop -> {tools,
|
|
17
|
-
// context-manager, agent/gates, agent/
|
|
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
|
|
18
19
|
// zen.ts; runAgenticLoopForProvider wraps this loop from there).
|
|
19
20
|
import { loadAtomConfig } from "../config.js";
|
|
20
|
-
import { createContextManager, historyCharBudget, historyMessageBudget, truncateHistoryWithCaps, } from "../context-manager.js";
|
|
21
|
+
import { createContextManager, historyCharBudget, historyChars, historyMessageBudget, truncateHistoryWithCaps, } from "../context-manager.js";
|
|
21
22
|
import { planBatches } from "../scheduler.js";
|
|
22
23
|
import { describeToolCall, executeTool, invalidCall, MAX_TOOL_STEPS, needsApproval, toolNames, validateAskQuestionArgs, validateToolArgs, } from "../tools.js";
|
|
24
|
+
import { getReadCacheStats } from "../tools/read-cache.js";
|
|
23
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";
|
|
24
28
|
// Whole-turn cancellation: thrown when the user cancels (Ctrl+C) mid-loop.
|
|
25
29
|
// The App catches it, rolls the partial turn back (same splice contract as
|
|
26
30
|
// POST failure), renders one dim `(cancelled)` line, and returns to a clean
|
|
@@ -71,6 +75,73 @@ export function toolStepBudget() {
|
|
|
71
75
|
export function truncateHistory(history, notify, reserve) {
|
|
72
76
|
return truncateHistoryWithCaps(history, { maxMessages: historyMessageBudget(), maxChars: historyCharBudget() }, { notify, reserve, todoNeedles: openTodoNeedles() });
|
|
73
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
|
+
}
|
|
74
145
|
// Execute one parsed tool call through validation + permission +
|
|
75
146
|
// ask_question gates. Model mistakes (unknown name, invalid args) return
|
|
76
147
|
// repairs-oriented results WITHOUT executing; cancellations propagate as
|
|
@@ -125,8 +196,13 @@ async function runOneTool(call, parsed, opts, execute) {
|
|
|
125
196
|
// result IS recorded — the loop then stops before the next tool/POST, so
|
|
126
197
|
// assistant/tool pairing stays valid until the caller rolls back.
|
|
127
198
|
throwIfCancelled(opts?.signal);
|
|
199
|
+
const timeoutMs = resolveToolTimeoutMs(opts?.toolTimeoutMs);
|
|
200
|
+
const doNormalize = opts?.normalizeResults !== false;
|
|
128
201
|
try {
|
|
129
|
-
|
|
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);
|
|
130
206
|
}
|
|
131
207
|
catch (e) {
|
|
132
208
|
if (isCancelError(e) || opts?.signal?.aborted)
|
|
@@ -237,363 +313,596 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
237
313
|
const contextManager = opts?.context
|
|
238
314
|
? createContextManager({ model: opts.context.model, toolsChars: opts.context.toolsChars })
|
|
239
315
|
: null;
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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) };
|
|
251
346
|
}
|
|
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();
|
|
347
|
+
};
|
|
348
|
+
const finishStats = () => {
|
|
279
349
|
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.
|
|
350
|
+
let endChars = startChars;
|
|
317
351
|
try {
|
|
318
|
-
|
|
352
|
+
endChars = historyChars(history);
|
|
319
353
|
}
|
|
320
354
|
catch {
|
|
321
|
-
|
|
355
|
+
endChars = startChars;
|
|
322
356
|
}
|
|
323
|
-
|
|
324
|
-
if (msg.reasoning !== undefined) {
|
|
357
|
+
let cacheHits = 0;
|
|
325
358
|
try {
|
|
326
|
-
|
|
359
|
+
cacheHits = Math.max(0, getReadCacheStats().hits - cacheHitsStart);
|
|
327
360
|
}
|
|
328
361
|
catch {
|
|
329
|
-
|
|
362
|
+
cacheHits = 0;
|
|
330
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);
|
|
331
377
|
}
|
|
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
|
-
});
|
|
378
|
+
catch {
|
|
379
|
+
// observer errors never break the turn
|
|
348
380
|
}
|
|
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 });
|
|
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.
|
|
373
389
|
try {
|
|
374
|
-
opts?.
|
|
390
|
+
opts?.drainSteer?.();
|
|
375
391
|
}
|
|
376
392
|
catch {
|
|
377
|
-
//
|
|
393
|
+
// observer errors never break the loop
|
|
378
394
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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;
|
|
387
421
|
}
|
|
388
|
-
|
|
389
|
-
|
|
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
|
+
});
|
|
390
435
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
}
|
|
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;
|
|
409
453
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
// finishing on red output.
|
|
425
|
-
verifiedAfterWrite = false;
|
|
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
|
+
}
|
|
426
468
|
}
|
|
427
469
|
}
|
|
428
|
-
|
|
429
|
-
history.push({ role: "tool", tool_call_id: call?.id ?? "", content: result });
|
|
430
|
-
try {
|
|
431
|
-
opts?.onToolActivity?.(describeToolCall(name, parsed), result, isError);
|
|
470
|
+
msg = norm.result;
|
|
432
471
|
}
|
|
433
472
|
catch {
|
|
434
|
-
//
|
|
473
|
+
// normalization never breaks the turn; the raw message stands
|
|
435
474
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
//
|
|
443
|
-
|
|
444
|
-
const name = call?.function?.name ?? "(unknown)";
|
|
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.
|
|
445
483
|
try {
|
|
446
|
-
opts?.
|
|
484
|
+
opts?.onUsage?.(msg.usage);
|
|
447
485
|
}
|
|
448
486
|
catch {
|
|
449
487
|
// ignore
|
|
450
488
|
}
|
|
451
|
-
|
|
452
|
-
|
|
489
|
+
}
|
|
490
|
+
if (msg.reasoning !== undefined) {
|
|
453
491
|
try {
|
|
454
|
-
|
|
455
|
-
const v = JSON.parse(typeof raw === "string" ? raw : "{}");
|
|
456
|
-
parsed = typeof v === "object" && v !== null ? v : {};
|
|
492
|
+
opts?.onReasoning?.(msg.reasoning);
|
|
457
493
|
}
|
|
458
494
|
catch {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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) });
|
|
481
547
|
continue;
|
|
482
548
|
}
|
|
483
|
-
|
|
549
|
+
history.push({ role: "assistant", content: outcome.finalText });
|
|
484
550
|
try {
|
|
485
|
-
|
|
551
|
+
opts?.onPhase?.("done");
|
|
486
552
|
}
|
|
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;
|
|
553
|
+
catch {
|
|
554
|
+
// ignore
|
|
510
555
|
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
durationMs: Math.max(0, toolEnd - toolStart),
|
|
520
|
-
argsJson: telemetryArgsJson(parsed),
|
|
521
|
-
result,
|
|
522
|
-
batchIndex: 0,
|
|
523
|
-
batchSize: 1,
|
|
524
|
-
});
|
|
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");
|
|
525
564
|
}
|
|
526
|
-
|
|
527
|
-
|
|
565
|
+
catch {
|
|
566
|
+
// ignore
|
|
567
|
+
}
|
|
568
|
+
return notice;
|
|
528
569
|
}
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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 });
|
|
536
577
|
try {
|
|
537
|
-
opts?.onPhase?.("
|
|
578
|
+
opts?.onPhase?.("done");
|
|
538
579
|
}
|
|
539
580
|
catch {
|
|
540
581
|
// ignore
|
|
541
582
|
}
|
|
583
|
+
return notice;
|
|
542
584
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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;
|
|
550
651
|
try {
|
|
551
|
-
const
|
|
552
|
-
const
|
|
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();
|
|
553
672
|
reportToolCall({
|
|
554
673
|
step,
|
|
555
|
-
toolCallId:
|
|
556
|
-
name
|
|
557
|
-
startedAt: telemetryIso(
|
|
558
|
-
endedAt: telemetryIso(
|
|
559
|
-
durationMs: Math.max(0,
|
|
560
|
-
argsJson: telemetryArgsJson(
|
|
561
|
-
result
|
|
562
|
-
batchIndex:
|
|
563
|
-
batchSize:
|
|
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,
|
|
564
683
|
});
|
|
565
|
-
|
|
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);
|
|
566
738
|
}
|
|
567
739
|
catch (e) {
|
|
568
|
-
|
|
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();
|
|
569
744
|
const cancelled = isCancelError(e) || signal?.aborted;
|
|
745
|
+
if (!cancelled) {
|
|
746
|
+
failures += 1;
|
|
747
|
+
noteBottleneck(name, Math.max(0, toolEnd - toolStart));
|
|
748
|
+
}
|
|
570
749
|
reportToolCall({
|
|
571
750
|
step,
|
|
572
|
-
toolCallId:
|
|
573
|
-
name
|
|
574
|
-
startedAt: telemetryIso(
|
|
575
|
-
endedAt: telemetryIso(
|
|
576
|
-
durationMs: Math.max(0,
|
|
577
|
-
argsJson: telemetryArgsJson(
|
|
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),
|
|
578
757
|
result: e instanceof Error ? e.message : String(e),
|
|
579
758
|
cancelled: cancelled ? true : undefined,
|
|
580
759
|
threw: cancelled ? undefined : true,
|
|
581
|
-
batchIndex:
|
|
582
|
-
batchSize:
|
|
760
|
+
batchIndex: 0,
|
|
761
|
+
batchSize: 1,
|
|
583
762
|
});
|
|
763
|
+
if (cancelled)
|
|
764
|
+
throw new LoopCancelledError();
|
|
584
765
|
throw e;
|
|
585
766
|
}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
+
}
|
|
596
902
|
}
|
|
597
903
|
}
|
|
598
904
|
}
|
|
905
|
+
finally {
|
|
906
|
+
finishStats();
|
|
907
|
+
}
|
|
599
908
|
}
|