atom-agent 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +75 -0
- package/README.md +13 -4
- package/atom.example.json +11 -0
- package/dist/App.js +923 -200
- package/dist/adapters.js +82 -13
- package/dist/agent/goal-evaluator.js +69 -0
- package/dist/agent/loop.js +517 -76
- package/dist/cli.js +11 -3
- package/dist/compact.js +41 -15
- package/dist/config.js +43 -7
- package/dist/context-manager.js +16 -198
- package/dist/context-windows.js +4 -2
- package/dist/env-block.js +5 -5
- package/dist/extension-commands.js +196 -0
- package/dist/extension-ui.js +153 -0
- package/dist/extensions.js +1571 -0
- package/dist/goal.js +583 -0
- package/dist/project-trust.js +96 -0
- package/dist/providers.js +6 -6
- package/dist/scheduler.js +74 -36
- package/dist/session.js +23 -5
- package/dist/sessions.js +25 -6
- package/dist/telemetry-dashboard.js +28 -0
- package/dist/telemetry.js +39 -0
- package/dist/tools/compaction-hooks.js +165 -0
- package/dist/tools/custom.js +189 -0
- package/dist/tools/intercept.js +145 -0
- package/dist/tools/overrides.js +105 -0
- package/dist/tools/provider-hooks.js +224 -0
- package/dist/tools/registry.js +246 -17
- package/dist/tools.js +44 -0
- package/dist/ui/palette.js +1 -1
- package/dist/ui/status-bar.js +80 -5
- package/dist/zen.js +305 -75
- package/documentation/architecture.md +114 -0
- package/documentation/cli.md +82 -0
- package/documentation/compaction.md +50 -0
- package/documentation/configuration.md +111 -0
- package/documentation/development.md +62 -0
- package/documentation/extensions.md +160 -0
- package/documentation/getting-started.md +63 -0
- package/documentation/goals.md +41 -0
- package/documentation/index.md +41 -0
- package/documentation/observability.md +70 -0
- package/documentation/permissions.md +66 -0
- package/documentation/providers.md +78 -0
- package/documentation/sessions.md +92 -0
- package/documentation/skills.md +57 -0
- package/documentation/tools.md +94 -0
- package/documentation/troubleshooting.md +54 -0
- package/examples/extensions/01-audit-gate.js +24 -0
- package/examples/extensions/02-notes-tool.js +32 -0
- package/examples/extensions/03-custom-command.js +32 -0
- package/package.json +6 -2
package/dist/agent/loop.js
CHANGED
|
@@ -21,11 +21,13 @@
|
|
|
21
21
|
// agent/normalize, agent/types} and NOT zen (transports stay in
|
|
22
22
|
// zen.ts; runAgenticLoopForProvider wraps this loop from there).
|
|
23
23
|
import { loadAtomConfig } from "../config.js";
|
|
24
|
-
import {
|
|
24
|
+
import { historyChars } from "../context-manager.js";
|
|
25
25
|
import { planBatches } from "../scheduler.js";
|
|
26
26
|
import { describeToolCall, executeTool, invalidCall, needsApproval, toolNames, validateAskQuestionArgs, validateToolArgs, } from "../tools.js";
|
|
27
|
+
import { afterToolInterceptors, applyAfterInterceptors, applyBeforeInterceptors, beforeToolInterceptors, blockedToolResult, } from "../tools/intercept.js";
|
|
27
28
|
import { getReadCacheStats } from "../tools/read-cache.js";
|
|
28
|
-
import {
|
|
29
|
+
import { emptyGoalProgress, GOAL_STALL_REPEATS, goalFollowUp, goalPausedNotice, goalReportAck, goalReportOutsideError, goalReportRejectedNotice, goalStallNudge, goalStallReached, goalVerdictNotice, noteGoalProgress, recentTurnsForJudge, resetGoalStall, sameGoalDisposition, updateGoalDisposition, validateUpdateGoalArgs, } from "../goal.js";
|
|
30
|
+
import { bashExitCode, evaluateTurnEnd, isCodePath, isVerificationCommand, todoCompletionGate, verificationGate, } from "./gates.js";
|
|
29
31
|
import { errorStreakFollowUp, ErrorStreakTracker, repetitionFollowUp, RepetitionGuard, repetitionStopNotice, } from "./loop-guard.js";
|
|
30
32
|
import { normalizeChatResult, normalizeToolResult, toolSignature } from "./normalize.js";
|
|
31
33
|
// Whole-turn cancellation: thrown when the user cancels (Ctrl+C) mid-loop.
|
|
@@ -72,13 +74,6 @@ export function toolStepBudget() {
|
|
|
72
74
|
}
|
|
73
75
|
return loadAtomConfig().config.maxToolSteps ?? Number.POSITIVE_INFINITY;
|
|
74
76
|
}
|
|
75
|
-
// Legacy trim entry: byte-identical contract (legacy env/config/default caps
|
|
76
|
-
// + live todo pinning, same notice, same in-place splice). New code should
|
|
77
|
-
// use a ContextManager (derived, window-aware caps); the loop core does when
|
|
78
|
-
// it knows the model (see AgenticOpts.context).
|
|
79
|
-
export function truncateHistory(history, notify, reserve) {
|
|
80
|
-
return truncateHistoryWithCaps(history, { maxMessages: historyMessageBudget(), maxChars: historyCharBudget() }, { notify, reserve, todoNeedles: openTodoNeedles() });
|
|
81
|
-
}
|
|
82
77
|
// Empty-response recovery (live-proven on free-tier gateways: a 200-OK
|
|
83
78
|
// stream can carry only queue comments and reasoning with zero answer text
|
|
84
79
|
// and zero tool calls, which the transport reports as an `Empty reply`
|
|
@@ -186,27 +181,79 @@ async function resolveApproval(name, parsed, opts) {
|
|
|
186
181
|
return "no";
|
|
187
182
|
}
|
|
188
183
|
}
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
184
|
+
// Extension tool-call interception (ticket 03): global pre/post hooks
|
|
185
|
+
// registered via ExtensionAPI.onBeforeToolCall/onAfterToolCall. Both
|
|
186
|
+
// helpers snapshot the live handler list and never throw — a throwing
|
|
187
|
+
// before handler fails closed (blocked outcome), a throwing after handler
|
|
188
|
+
// fails open (original content). Cancellation checks stay where they are;
|
|
189
|
+
// hooks are in-process policy, not executions, so they run even when the
|
|
190
|
+
// signal is armed and the existing boundaries still stop the turn.
|
|
191
|
+
async function runBeforeIntercept(name, parsed) {
|
|
192
|
+
try {
|
|
193
|
+
return await applyBeforeInterceptors(beforeToolInterceptors(), name, parsed);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return { args: parsed, blocked: blockedToolResult(name, "(unknown)", "interception failed") };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async function runAfterIntercept(name, parsed, result, isError) {
|
|
200
|
+
try {
|
|
201
|
+
return await applyAfterInterceptors(afterToolInterceptors(), { name, args: parsed, result, isError });
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return { content: result, isError };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// Execute one parsed tool call through interception + validation +
|
|
208
|
+
// permission + ask_question gates. Model mistakes (unknown name, invalid
|
|
209
|
+
// args) return repairs-oriented results WITHOUT executing; cancellations
|
|
210
|
+
// propagate as LoopCancelledError (never a result, never retried).
|
|
211
|
+
// Everything else returns the result string plus the effective (post-
|
|
212
|
+
// rewrite) args the commit funnel must use for gates, labels, and pairing:
|
|
213
|
+
// - Hook-vs-approval ordering: pre-hooks run BEFORE validation and
|
|
214
|
+
// approval. The hook sees the call pre-approval and may rewrite args
|
|
215
|
+
// before the approval prompt shows them; a block short-circuits approval
|
|
216
|
+
// entirely (no prompt for a call that never runs). Rewrites always
|
|
217
|
+
// re-validate before execution, so a hook can never smuggle unvalidated
|
|
218
|
+
// args into an executor.
|
|
219
|
+
// - Unknown names never reach hooks (model mistake — nothing would run).
|
|
220
|
+
// update_goal is the one exemption (ticket 03): a loop-intercepted tool
|
|
221
|
+
// like ask_question, resolved without an executor via onUpdateGoal.
|
|
194
222
|
// - ask_question never needs approval; without an askUser hook it resolves
|
|
195
223
|
// to "Error: ask_question has no UI hook".
|
|
224
|
+
// - update_goal never needs approval either; without the per-turn recorder
|
|
225
|
+
// (only runLoopWithChat supplies it) it resolves to the outside-turn error.
|
|
196
226
|
// - write/edit/bash consult the approve hook when one is provided (or a
|
|
197
227
|
// pre-resolved batch decision); a "no" resolves to
|
|
198
228
|
// "Error: denied by user: <tool>" (final, no retry/rollback). Without a
|
|
199
229
|
// hook every tool executes immediately.
|
|
200
|
-
async function runOneTool(call, parsed, opts, execute, preDecision) {
|
|
230
|
+
async function runOneTool(call, parsed, opts, execute, preDecision, onUpdateGoal) {
|
|
201
231
|
const name = call?.function?.name ?? "(unknown)";
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
232
|
+
// update_goal bypasses the unknown-name gate (ticket 03): a
|
|
233
|
+
// loop-intercepted tool like ask_question — the registry owns builtins
|
|
234
|
+
// only, so the loop exempts it by name here; runOneToolWithArgs below
|
|
235
|
+
// validates it and resolves it without an executor (never needs approval).
|
|
236
|
+
if (name !== "update_goal" && !toolNames().includes(name)) {
|
|
237
|
+
return { result: `Error: unknown tool "${name}". Available: ${toolNames().join(", ")}`, args: parsed };
|
|
205
238
|
}
|
|
239
|
+
const pre = await runBeforeIntercept(name, parsed);
|
|
240
|
+
if (pre.blocked !== null) {
|
|
241
|
+
return { result: pre.blocked, args: pre.args };
|
|
242
|
+
}
|
|
243
|
+
return runOneToolWithArgs(call, pre.args, opts, execute, preDecision, onUpdateGoal);
|
|
244
|
+
}
|
|
245
|
+
// Inner execution after pre-interception: validate (rewritten) args, then
|
|
246
|
+
// permission + ask_question/update_goal gates, then the executor. Shared by
|
|
247
|
+
// the serial path (via runOneTool) and the parallel batch (via the pre-pass
|
|
248
|
+
// below, which resolves pre-hooks serially so prompts never run concurrently).
|
|
249
|
+
async function runOneToolWithArgs(call, parsed, opts, execute, preDecision, onUpdateGoal) {
|
|
250
|
+
const name = call?.function?.name ?? "(unknown)";
|
|
206
251
|
// Argument validation BEFORE approval/execution: model mistake, never runs.
|
|
252
|
+
// Pre-hook rewrites arrive here already applied, so they re-validate on
|
|
253
|
+
// exactly what would execute — invalid rewrites never reach an executor.
|
|
207
254
|
const detail = validateToolArgs(name, parsed);
|
|
208
255
|
if (detail) {
|
|
209
|
-
return invalidCall(detail);
|
|
256
|
+
return { result: invalidCall(detail), args: parsed };
|
|
210
257
|
}
|
|
211
258
|
if (name === "ask_question") {
|
|
212
259
|
throwIfCancelled(opts?.signal);
|
|
@@ -214,11 +261,23 @@ async function runOneTool(call, parsed, opts, execute, preDecision) {
|
|
|
214
261
|
// LoopCancelledError (no result). If it resolves just as the signal
|
|
215
262
|
// aborts, return the result — the loop records it, then stops before
|
|
216
263
|
// the next POST (no new POSTs, pairing stays valid until rollback).
|
|
217
|
-
return runAskQuestion(parsed, opts?.askUser, opts?.signal);
|
|
264
|
+
return { result: await runAskQuestion(parsed, opts?.askUser, opts?.signal), args: parsed };
|
|
265
|
+
}
|
|
266
|
+
if (name === "update_goal") {
|
|
267
|
+
throwIfCancelled(opts?.signal);
|
|
268
|
+
// Goal-disposition report (ticket 03): ask_question-shaped — validated,
|
|
269
|
+
// never needs approval, resolved without an executor (the per-turn slot
|
|
270
|
+
// recorder owns the record; execute never sees this name, so plan-mode
|
|
271
|
+
// and approval policy are untouched). Without the recorder (never from
|
|
272
|
+
// runLoopWithChat — both call sites thread it) the call is outside any
|
|
273
|
+
// goal turn by construction.
|
|
274
|
+
if (!onUpdateGoal)
|
|
275
|
+
return { result: goalReportOutsideError(), args: parsed };
|
|
276
|
+
return { result: onUpdateGoal(parsed), args: parsed };
|
|
218
277
|
}
|
|
219
278
|
const decision = preDecision ?? (await resolveApproval(name, parsed, opts));
|
|
220
279
|
if (decision === "no") {
|
|
221
|
-
return `Error: denied by user: ${name}
|
|
280
|
+
return { result: `Error: denied by user: ${name}`, args: parsed };
|
|
222
281
|
}
|
|
223
282
|
// "once"/"always" run this call (the caller caches the always-allowed set
|
|
224
283
|
// session-wide so later calls skip the prompt).
|
|
@@ -232,8 +291,8 @@ async function runOneTool(call, parsed, opts, execute, preDecision) {
|
|
|
232
291
|
try {
|
|
233
292
|
const raw = await executeWithTimeout(execute, name, parsed, timeoutMs, opts?.signal);
|
|
234
293
|
if (doNormalize)
|
|
235
|
-
return normalizeToolResult(raw);
|
|
236
|
-
return typeof raw === "string" ? raw : normalizeToolResult(raw);
|
|
294
|
+
return { result: normalizeToolResult(raw), args: parsed };
|
|
295
|
+
return { result: typeof raw === "string" ? raw : normalizeToolResult(raw), args: parsed };
|
|
237
296
|
}
|
|
238
297
|
catch (e) {
|
|
239
298
|
if (isCancelError(e) || opts?.signal?.aborted)
|
|
@@ -368,14 +427,96 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
368
427
|
// Todo-guard cycles spent (bounds guard continues via MAX_TODO_ROUNDS —
|
|
369
428
|
// a model that never resolves open todos still terminates).
|
|
370
429
|
let todoRounds = 0;
|
|
371
|
-
//
|
|
372
|
-
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
//
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
430
|
+
// Goal engagement (ticket 02): set the first time a completed POST
|
|
431
|
+
// observes a live goal. A goal cleared mid-run still counts its final
|
|
432
|
+
// turn (the work happened); a run that never saw a goal counts nothing.
|
|
433
|
+
let goalEngaged = false;
|
|
434
|
+
// Disposition slot (ticket 03): the model-reported outcome for the CURRENT
|
|
435
|
+
// goal turn, recorded by update_goal calls and consumed at the next turn
|
|
436
|
+
// end BEFORE the auto-continue decision. Reset per run and on every
|
|
437
|
+
// consumption — a report never leaks into the following turn.
|
|
438
|
+
let pendingDisposition = null;
|
|
439
|
+
// Read the live goal without ever throwing (a failing accessor ends the
|
|
440
|
+
// turn normally — the goal simply does not continue).
|
|
441
|
+
const readLiveGoal = () => {
|
|
442
|
+
try {
|
|
443
|
+
return opts?.goal?.getGoal?.() ?? null;
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
// Pause-with-preservation via the App-owned callback (state flip plus a
|
|
450
|
+
// visible notice; the goal text and stats survive). Never throws.
|
|
451
|
+
const pauseLiveGoal = (objective, reason) => {
|
|
452
|
+
try {
|
|
453
|
+
opts?.goal?.pauseGoal(goalPausedNotice(objective, reason));
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
// observer errors never break the loop
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
// Verdict pause (ticket 03): terminal dispositions pause with their own
|
|
460
|
+
// verdict wording — no goalPausedNotice wrap, the verdict IS the notice
|
|
461
|
+
// (it already names the objective and carries the reason). Never throws.
|
|
462
|
+
const pauseLiveGoalWithNotice = (notice) => {
|
|
463
|
+
try {
|
|
464
|
+
opts?.goal?.pauseGoal(notice);
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
// observer errors never break the loop
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
// Record one update_goal report into the per-turn slot (ticket 03), in
|
|
471
|
+
// order: bad args are a model mistake (invalid-call error, nothing
|
|
472
|
+
// recorded); with no live active goal the call is outside any goal turn
|
|
473
|
+
// (structured error, zero state change); after a terminal report anything
|
|
474
|
+
// further is rejected with a notice (first report sticks); the same value
|
|
475
|
+
// twice is idempotent; otherwise the report replaces any pending
|
|
476
|
+
// non-terminal one (last wins). Pure parts live in goal.ts; this closure
|
|
477
|
+
// only owns the slot read/write. Never throws — results are strings.
|
|
478
|
+
const recordGoalReport = (parsed) => {
|
|
479
|
+
const detail = validateUpdateGoalArgs(parsed);
|
|
480
|
+
if (detail)
|
|
481
|
+
return invalidCall(detail);
|
|
482
|
+
const live = readLiveGoal();
|
|
483
|
+
if (live === null || !live.active)
|
|
484
|
+
return goalReportOutsideError();
|
|
485
|
+
const next = updateGoalDisposition(parsed);
|
|
486
|
+
if (pendingDisposition !== null && pendingDisposition.status !== "continue") {
|
|
487
|
+
return goalReportRejectedNotice(pendingDisposition);
|
|
488
|
+
}
|
|
489
|
+
if (pendingDisposition !== null && sameGoalDisposition(pendingDisposition, next)) {
|
|
490
|
+
return goalReportAck(next);
|
|
491
|
+
}
|
|
492
|
+
pendingDisposition = next;
|
|
493
|
+
return goalReportAck(next);
|
|
494
|
+
};
|
|
495
|
+
// Take the pending report and clear the slot (ticket 03 consumption reads
|
|
496
|
+
// through here so the declared return type drives the turn-end checks —
|
|
497
|
+
// the captured slot's direct-flow narrowing would otherwise collapse the
|
|
498
|
+
// read to null). A report never leaks into the following turn.
|
|
499
|
+
const takeDisposition = () => {
|
|
500
|
+
const current = pendingDisposition;
|
|
501
|
+
pendingDisposition = null;
|
|
502
|
+
return current;
|
|
503
|
+
};
|
|
504
|
+
// Novelty progress (ticket 05): per-run memory of committed-result
|
|
505
|
+
// fingerprints plus the consecutive-non-novel streak. Recorded in the
|
|
506
|
+
// commit funnel below; read at goal turn end for the stall redirect.
|
|
507
|
+
// Loop-local is sufficient: a whole goal run normally lives inside one
|
|
508
|
+
// runLoopWithChat call (continuations `continue` the loop; only
|
|
509
|
+
// terminal/cancel/budget/failure return).
|
|
510
|
+
const goalProgress = emptyGoalProgress();
|
|
511
|
+
// One goal turn taken (guarded — accounting never breaks the turn).
|
|
512
|
+
const noteGoalTurn = () => {
|
|
513
|
+
try {
|
|
514
|
+
opts?.goal?.onGoalTurn?.();
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
// observer errors never break the loop
|
|
518
|
+
}
|
|
519
|
+
};
|
|
379
520
|
// ---- Hardened-loop state (additive; explicit caps still honored —
|
|
380
521
|
// see AgenticOpts docs) ----
|
|
381
522
|
const maxTotalToolCalls = resolveMaxTotalToolCalls(opts?.maxTotalToolCalls);
|
|
@@ -399,7 +540,6 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
399
540
|
let modelCalls = 0;
|
|
400
541
|
let toolCalls = 0;
|
|
401
542
|
let failures = 0;
|
|
402
|
-
let droppedTurnsTotal = 0;
|
|
403
543
|
// Empty-response repairs spent (bounded by MAX_EMPTY_ROUNDS — a model
|
|
404
544
|
// that only answers silence still terminates).
|
|
405
545
|
let emptyRounds = 0;
|
|
@@ -434,7 +574,6 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
434
574
|
failures,
|
|
435
575
|
repetitionHits: repGuard.hitCount,
|
|
436
576
|
cacheHits,
|
|
437
|
-
truncationNotices: droppedTurnsTotal,
|
|
438
577
|
durationMs: Math.max(0, Date.now() - turnStartMs),
|
|
439
578
|
bottleneck,
|
|
440
579
|
contextGrowthChars: endChars - startChars,
|
|
@@ -450,41 +589,13 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
450
589
|
throwIfCancelled(signal);
|
|
451
590
|
// Steering seam: drain one pending steer message (if any) at this safe
|
|
452
591
|
// point — previous tool batches are fully committed, so assistant/tool
|
|
453
|
-
// pairing can never split.
|
|
454
|
-
// accounts for the injected message. No-op without the hook.
|
|
592
|
+
// pairing can never split. No-op without the hook.
|
|
455
593
|
try {
|
|
456
594
|
opts?.drainSteer?.();
|
|
457
595
|
}
|
|
458
596
|
catch {
|
|
459
597
|
// observer errors never break the loop
|
|
460
598
|
}
|
|
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
599
|
let msg;
|
|
489
600
|
const modelStart = Date.now();
|
|
490
601
|
try {
|
|
@@ -549,6 +660,17 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
549
660
|
// normalization never breaks the turn; the raw message stands
|
|
550
661
|
}
|
|
551
662
|
modelCalls += 1;
|
|
663
|
+
// Goal slice (ticket 02): one completed POST while a goal is live.
|
|
664
|
+
// Engages the run for turn accounting below; guarded, never breaks it.
|
|
665
|
+
try {
|
|
666
|
+
if (opts?.goal?.getGoal?.()?.active === true) {
|
|
667
|
+
goalEngaged = true;
|
|
668
|
+
opts.goal.onGoalRequest?.();
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
// observer errors never break the loop
|
|
673
|
+
}
|
|
552
674
|
if (msg.usage !== undefined) {
|
|
553
675
|
// Spend accounting: EVERY POST that reports usage forwards it, and the
|
|
554
676
|
// caller accumulates each report as billed spend — tool-round POSTs,
|
|
@@ -614,6 +736,167 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
614
736
|
history.push({ role: "user", content: outcome.followUp });
|
|
615
737
|
continue;
|
|
616
738
|
}
|
|
739
|
+
// Disposition protocol (ticket 03): consume this turn's update_goal
|
|
740
|
+
// report BEFORE the auto-continue decision. Terminal dispositions stop
|
|
741
|
+
// the run with a verdict — `blocked` unconditionally (even with
|
|
742
|
+
// unaddressed tool errors), `complete` only through the honesty gate
|
|
743
|
+
// below (unverified code or open todos continue instead) — pausing
|
|
744
|
+
// the goal with the verdict as its notice (never clearing, like every
|
|
745
|
+
// other loop-driven goal ending). A `continue` report's next action
|
|
746
|
+
// becomes the follow-up below (generic text when absent); no report —
|
|
747
|
+
// or a goal gone mid-turn — flows into the existing path untouched.
|
|
748
|
+
// The slot clears on every consumption, so a report never leaks into
|
|
749
|
+
// the following turn; guard `continue`s above never reach here, so a
|
|
750
|
+
// report filed mid-turn survives them until a real turn end.
|
|
751
|
+
const dispositionGoal = readLiveGoal();
|
|
752
|
+
let disposition = takeDisposition();
|
|
753
|
+
let goalNextAction = null;
|
|
754
|
+
// Evaluator fallback (ticket 04): a report-less turn with a live goal
|
|
755
|
+
// gets exactly ONE bounded judge call before continuing — but only when
|
|
756
|
+
// a runner is configured. Without one the turn continues exactly as
|
|
757
|
+
// before (existing tests pin this). A clear verdict flows through the
|
|
758
|
+
// SAME terminal/continue handling below as a model report (a `complete`
|
|
759
|
+
// still passes the honesty gate below — one code path for both); a judge error
|
|
760
|
+
// or an unclear verdict pauses (preserves) instead of looping. The
|
|
761
|
+
// judge reads history only — this path pushes nothing, so the judge
|
|
762
|
+
// performs no state mutations.
|
|
763
|
+
if (disposition === null &&
|
|
764
|
+
dispositionGoal !== null &&
|
|
765
|
+
dispositionGoal.active &&
|
|
766
|
+
opts?.goalJudge) {
|
|
767
|
+
// Cancel wins before the extra POST: never judge into a lost turn.
|
|
768
|
+
throwIfCancelled(signal);
|
|
769
|
+
let verdict = null;
|
|
770
|
+
let judgeError = null;
|
|
771
|
+
try {
|
|
772
|
+
verdict = await opts.goalJudge({
|
|
773
|
+
goal: dispositionGoal.objective,
|
|
774
|
+
turns: recentTurnsForJudge(history),
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
catch (e) {
|
|
778
|
+
// Whole-turn cancellation still propagates (it pauses via the outer
|
|
779
|
+
// catch, like every other cancel) — anything else pauses here.
|
|
780
|
+
if (isCancelError(e) || signal?.aborted)
|
|
781
|
+
throw new LoopCancelledError();
|
|
782
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
783
|
+
judgeError = msg.length > 200 ? `${msg.slice(0, 200)}…` : msg;
|
|
784
|
+
}
|
|
785
|
+
// The goal may have cleared/paused mid-judge (slash runs while busy):
|
|
786
|
+
// then the verdict is dropped and the turn ends normally — never
|
|
787
|
+
// pause or continue a goal that is gone.
|
|
788
|
+
const liveAfterJudge = readLiveGoal();
|
|
789
|
+
if (liveAfterJudge === null || !liveAfterJudge.active) {
|
|
790
|
+
if (goalEngaged)
|
|
791
|
+
noteGoalTurn();
|
|
792
|
+
history.push({ role: "assistant", content: outcome.finalText });
|
|
793
|
+
try {
|
|
794
|
+
opts?.onPhase?.("done");
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
// ignore
|
|
798
|
+
}
|
|
799
|
+
return outcome.finalText;
|
|
800
|
+
}
|
|
801
|
+
if (judgeError !== null || verdict === null) {
|
|
802
|
+
// Pause-with-preservation (never clear): the goal, todos, and
|
|
803
|
+
// history stay for /goal resume. The turn was still taken.
|
|
804
|
+
pauseLiveGoal(liveAfterJudge.objective, judgeError !== null ? `(judge failed: ${judgeError})` : `(judge unclear — no clear verdict)`);
|
|
805
|
+
noteGoalTurn();
|
|
806
|
+
history.push({ role: "assistant", content: outcome.finalText });
|
|
807
|
+
try {
|
|
808
|
+
opts?.onPhase?.("done");
|
|
809
|
+
}
|
|
810
|
+
catch {
|
|
811
|
+
// ignore
|
|
812
|
+
}
|
|
813
|
+
return outcome.finalText;
|
|
814
|
+
}
|
|
815
|
+
disposition = verdict;
|
|
816
|
+
}
|
|
817
|
+
if (disposition !== null &&
|
|
818
|
+
disposition.status !== "continue" &&
|
|
819
|
+
dispositionGoal !== null &&
|
|
820
|
+
dispositionGoal.active) {
|
|
821
|
+
// Cancel wins even mid-verdict: never stop cleanly into a lost turn.
|
|
822
|
+
throwIfCancelled(signal);
|
|
823
|
+
// Honest completion gate (ticket 06): a `complete` only lands on
|
|
824
|
+
// genuinely finished work. With unverified code pending or todos
|
|
825
|
+
// still open the goal continues instead of stopping, naming the
|
|
826
|
+
// files/items through the gates' own follow-up voice (todos first,
|
|
827
|
+
// matching TURN_END_GATES order). `blocked` skips this entirely and
|
|
828
|
+
// stops unconditionally below. Evaluator verdicts share this path —
|
|
829
|
+
// they arrive in the same `disposition` slot, so one code path gates
|
|
830
|
+
// both. Guard `continue`s above never reach here, so a false
|
|
831
|
+
// `complete` filed mid-turn is still caught when its turn ends.
|
|
832
|
+
if (disposition.status === "complete") {
|
|
833
|
+
// Voice-only probe: budgets/rounds zeroed so the builders return
|
|
834
|
+
// their `continue` follow-up whenever their state is dirty — the
|
|
835
|
+
// live budget owns spinning protection in the branch below, and the
|
|
836
|
+
// pinned gates above own the per-turn nag bounds.
|
|
837
|
+
const honestCtx = {
|
|
838
|
+
step: 0,
|
|
839
|
+
maxSteps: Number.POSITIVE_INFINITY,
|
|
840
|
+
filesWritten,
|
|
841
|
+
verifiedAfterWrite,
|
|
842
|
+
needsVerification,
|
|
843
|
+
unverifiedPaths: [...unverifiedPaths],
|
|
844
|
+
verifyRounds: 0,
|
|
845
|
+
todoRounds: 0,
|
|
846
|
+
};
|
|
847
|
+
const todoProbe = todoCompletionGate(outcome.finalText, honestCtx);
|
|
848
|
+
const verifyProbe = verificationGate(outcome.finalText, honestCtx);
|
|
849
|
+
const honestBlock = todoProbe.action === "continue"
|
|
850
|
+
? todoProbe
|
|
851
|
+
: verifyProbe.action === "continue"
|
|
852
|
+
? verifyProbe
|
|
853
|
+
: null;
|
|
854
|
+
if (honestBlock !== null) {
|
|
855
|
+
// Spent budget cannot start another turn: pause (preserve) with
|
|
856
|
+
// the budget notice instead of completing dirty or spinning.
|
|
857
|
+
if (step >= maxSteps || toolCalls >= maxTotalToolCalls) {
|
|
858
|
+
pauseLiveGoal(dispositionGoal.objective, "(budget spent)");
|
|
859
|
+
noteGoalTurn();
|
|
860
|
+
history.push({ role: "assistant", content: outcome.finalText });
|
|
861
|
+
try {
|
|
862
|
+
opts?.onPhase?.("done");
|
|
863
|
+
}
|
|
864
|
+
catch {
|
|
865
|
+
// ignore
|
|
866
|
+
}
|
|
867
|
+
return outcome.finalText;
|
|
868
|
+
}
|
|
869
|
+
// A guard-style continue: not a turn end, so no goal-turn
|
|
870
|
+
// accounting — and the false report is already consumed, so the
|
|
871
|
+
// next turn must file fresh evidence.
|
|
872
|
+
history.push({ role: "assistant", content: honestBlock.assistantText });
|
|
873
|
+
history.push({ role: "user", content: honestBlock.followUp });
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
const verdict = disposition.status === "complete"
|
|
878
|
+
? goalVerdictNotice(dispositionGoal.objective, "complete", disposition.reason, disposition.unverified)
|
|
879
|
+
: goalVerdictNotice(dispositionGoal.objective, disposition.status, disposition.reason);
|
|
880
|
+
pauseLiveGoalWithNotice(verdict);
|
|
881
|
+
noteGoalTurn();
|
|
882
|
+
const base = outcome.finalText;
|
|
883
|
+
const final = base ? `${base}\n${verdict}` : verdict;
|
|
884
|
+
history.push({ role: "assistant", content: final });
|
|
885
|
+
try {
|
|
886
|
+
opts?.onPhase?.("done");
|
|
887
|
+
}
|
|
888
|
+
catch {
|
|
889
|
+
// ignore
|
|
890
|
+
}
|
|
891
|
+
return final;
|
|
892
|
+
}
|
|
893
|
+
if (disposition !== null &&
|
|
894
|
+
disposition.status === "continue" &&
|
|
895
|
+
disposition.next !== undefined &&
|
|
896
|
+
dispositionGoal !== null &&
|
|
897
|
+
dispositionGoal.active) {
|
|
898
|
+
goalNextAction = disposition.next;
|
|
899
|
+
}
|
|
617
900
|
// Error-streak recovery (additive, after the pinned gates): ending on
|
|
618
901
|
// sustained unaddressed `Error:` results is almost always premature.
|
|
619
902
|
// Single errors still end normally (the model may be reporting a
|
|
@@ -625,6 +908,58 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
625
908
|
history.push({ role: "user", content: errorStreakFollowUp(streak) });
|
|
626
909
|
continue;
|
|
627
910
|
}
|
|
911
|
+
// Goal auto-continue (tickets 02–03): with a live goal a would-be turn
|
|
912
|
+
// end starts the next turn through the same assistant+user follow-up
|
|
913
|
+
// seam the guards use above — the ONLY continuation message, so the
|
|
914
|
+
// transcript shows each turn normally with no synthetic user input
|
|
915
|
+
// beyond this mechanism. Unconditional (no turn cap): the run ends
|
|
916
|
+
// only via pause (cancel/spent budget), clear, a terminal disposition,
|
|
917
|
+
// or a thrown failure. A `continue` report's next action becomes the
|
|
918
|
+
// follow-up text (generic follow-up when absent or unreported).
|
|
919
|
+
// Runs after the error-streak hold so sustained tool failures still
|
|
920
|
+
// get their fix-forward guidance first (a hold is not a turn end, so
|
|
921
|
+
// it counts no goal turn — and a consumed `continue` report is dropped
|
|
922
|
+
// with it, so the fix-forward guidance wins that round).
|
|
923
|
+
const liveGoal = readLiveGoal();
|
|
924
|
+
if (liveGoal !== null && liveGoal.active) {
|
|
925
|
+
goalEngaged = true;
|
|
926
|
+
// Cancel wins even mid-continuation: never start another turn lost.
|
|
927
|
+
throwIfCancelled(signal);
|
|
928
|
+
// A spent budget can never make progress — pause (preserve) with a
|
|
929
|
+
// notice instead of POSTing forever. The turn was still taken.
|
|
930
|
+
if (step >= maxSteps || toolCalls >= maxTotalToolCalls) {
|
|
931
|
+
pauseLiveGoal(liveGoal.objective, "(budget spent)");
|
|
932
|
+
noteGoalTurn();
|
|
933
|
+
history.push({ role: "assistant", content: outcome.finalText });
|
|
934
|
+
try {
|
|
935
|
+
opts?.onPhase?.("done");
|
|
936
|
+
}
|
|
937
|
+
catch {
|
|
938
|
+
// ignore
|
|
939
|
+
}
|
|
940
|
+
return outcome.finalText;
|
|
941
|
+
}
|
|
942
|
+
noteGoalTurn();
|
|
943
|
+
history.push({ role: "assistant", content: outcome.finalText });
|
|
944
|
+
// Stall redirect (ticket 05): a run of exact repeats gets the replan
|
|
945
|
+
// nudge as its follow-up instead of the generic continuation — same
|
|
946
|
+
// assistant+user commit shape as the other gates, so the redirect is
|
|
947
|
+
// visible in the transcript. The epoch resets; the goal is never
|
|
948
|
+
// paused, cleared, or ended here (existing budgets still bound a run
|
|
949
|
+
// that keeps stalling, so recurring nudges cannot spin forever).
|
|
950
|
+
if (goalStallReached(goalProgress)) {
|
|
951
|
+
resetGoalStall(goalProgress);
|
|
952
|
+
history.push({ role: "user", content: goalStallNudge(liveGoal.objective, GOAL_STALL_REPEATS) });
|
|
953
|
+
}
|
|
954
|
+
else {
|
|
955
|
+
history.push({ role: "user", content: goalNextAction ?? goalFollowUp(liveGoal.objective) });
|
|
956
|
+
}
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
// Final turn of a run that engaged a goal before it cleared: the work
|
|
960
|
+
// happened, so it still counts (no continuation — the goal is gone).
|
|
961
|
+
if (goalEngaged)
|
|
962
|
+
noteGoalTurn();
|
|
628
963
|
history.push({ role: "assistant", content: outcome.finalText });
|
|
629
964
|
try {
|
|
630
965
|
opts?.onPhase?.("done");
|
|
@@ -637,6 +972,18 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
637
972
|
if (step >= maxSteps) {
|
|
638
973
|
const base = msg.content ?? "";
|
|
639
974
|
const notice = `${base}${base ? "\n" : ""}(stopped: too many tool steps) (limit is ${maxSteps}; raise with ATOM_MAX_TOOL_STEPS=<n>)`;
|
|
975
|
+
// Spent budget during a goal run pauses (preserves) it with a notice
|
|
976
|
+
// instead of silently dropping it — the stop text stays the reply and
|
|
977
|
+
// the pause notice lands as its own line via the callback.
|
|
978
|
+
const overGoal = readLiveGoal();
|
|
979
|
+
if (overGoal !== null && overGoal.active) {
|
|
980
|
+
goalEngaged = true;
|
|
981
|
+
pauseLiveGoal(overGoal.objective, "(step budget spent)");
|
|
982
|
+
noteGoalTurn();
|
|
983
|
+
}
|
|
984
|
+
else if (goalEngaged) {
|
|
985
|
+
noteGoalTurn();
|
|
986
|
+
}
|
|
640
987
|
history.push({ role: "assistant", content: notice });
|
|
641
988
|
try {
|
|
642
989
|
opts?.onPhase?.("done");
|
|
@@ -652,6 +999,16 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
652
999
|
if (toolCalls + calls.length > maxTotalToolCalls) {
|
|
653
1000
|
const base = msg.content ?? "";
|
|
654
1001
|
const notice = `${base}${base ? "\n" : ""}(stopped: too many tool calls) (limit is ${maxTotalToolCalls} per turn)`;
|
|
1002
|
+
// Same pause-with-preservation contract as the step-budget stop above.
|
|
1003
|
+
const overGoal = readLiveGoal();
|
|
1004
|
+
if (overGoal !== null && overGoal.active) {
|
|
1005
|
+
goalEngaged = true;
|
|
1006
|
+
pauseLiveGoal(overGoal.objective, "(tool-call budget spent)");
|
|
1007
|
+
noteGoalTurn();
|
|
1008
|
+
}
|
|
1009
|
+
else if (goalEngaged) {
|
|
1010
|
+
noteGoalTurn();
|
|
1011
|
+
}
|
|
655
1012
|
history.push({ role: "assistant", content: notice });
|
|
656
1013
|
try {
|
|
657
1014
|
opts?.onPhase?.("done");
|
|
@@ -668,7 +1025,13 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
668
1025
|
// `Error:` results) never ran, so they neither arm nor clear the gate.
|
|
669
1026
|
const commitToolResult = async (name, parsed, call, result, durationMs) => {
|
|
670
1027
|
const baseIsError = typeof result === "string" && result.startsWith("Error");
|
|
671
|
-
|
|
1028
|
+
// Extension post-hooks observe the raw commit candidate first (every
|
|
1029
|
+
// committed result: executions, blocks, denials, validation errors);
|
|
1030
|
+
// the caller's onToolResult hook runs last on the patched version.
|
|
1031
|
+
// Patches apply per call in commit order, so tool_call_id re-pairing
|
|
1032
|
+
// and ordering are untouched; throwing patchers fail open above.
|
|
1033
|
+
const after = await runAfterIntercept(name, parsed, result, baseIsError);
|
|
1034
|
+
const hooked = await applyToolResultHook(opts?.onToolResult, name, parsed, after.content, after.isError);
|
|
672
1035
|
// Veto: skip the commit entirely — no counters, no gates, no history,
|
|
673
1036
|
// no activity. The turn continues; pairing risk is the hook author's.
|
|
674
1037
|
if (hooked.veto)
|
|
@@ -679,6 +1042,15 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
679
1042
|
if (isError)
|
|
680
1043
|
failures += 1;
|
|
681
1044
|
errStreak.noteResult(isError);
|
|
1045
|
+
// Novelty progress (ticket 05): successful commits fingerprint into the
|
|
1046
|
+
// per-run seen-set (Error results are owned by the error-streak
|
|
1047
|
+
// machinery and never count). Guarded — accounting never breaks the turn.
|
|
1048
|
+
try {
|
|
1049
|
+
noteGoalProgress(goalProgress, name, parsed, finalResult, isError);
|
|
1050
|
+
}
|
|
1051
|
+
catch {
|
|
1052
|
+
// observer errors never break the loop
|
|
1053
|
+
}
|
|
682
1054
|
if (typeof durationMs === "number")
|
|
683
1055
|
noteBottleneck(name, durationMs);
|
|
684
1056
|
if (!isError && (name === "write" || name === "edit")) {
|
|
@@ -810,6 +1182,9 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
810
1182
|
continue;
|
|
811
1183
|
}
|
|
812
1184
|
let result;
|
|
1185
|
+
// Effective (post-rewrite) args: what validated, approved, and ran —
|
|
1186
|
+
// telemetry and the commit below must see these, not the originals.
|
|
1187
|
+
let effectiveArgs = parsed;
|
|
813
1188
|
// Repetition guard (opt-in via maxRepeatedCalls; unset = track-only):
|
|
814
1189
|
// a repeated signature skips execution and yields a guidance error;
|
|
815
1190
|
// exhausted nudges stop hard.
|
|
@@ -860,7 +1235,9 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
860
1235
|
return stopNotice;
|
|
861
1236
|
}
|
|
862
1237
|
try {
|
|
863
|
-
|
|
1238
|
+
const one = await runOneTool(call, parsed, opts, execute, undefined, recordGoalReport);
|
|
1239
|
+
result = one.result;
|
|
1240
|
+
effectiveArgs = one.args;
|
|
864
1241
|
}
|
|
865
1242
|
catch (e) {
|
|
866
1243
|
// A cancelled/throwing tool still records its attempt (with the
|
|
@@ -899,13 +1276,13 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
899
1276
|
startedAt: telemetryIso(toolStart),
|
|
900
1277
|
endedAt: telemetryIso(toolEnd),
|
|
901
1278
|
durationMs: Math.max(0, toolEnd - toolStart),
|
|
902
|
-
argsJson: telemetryArgsJson(
|
|
1279
|
+
argsJson: telemetryArgsJson(effectiveArgs),
|
|
903
1280
|
result,
|
|
904
1281
|
batchIndex: 0,
|
|
905
1282
|
batchSize: 1,
|
|
906
1283
|
});
|
|
907
1284
|
}
|
|
908
|
-
await commitToolResult(name,
|
|
1285
|
+
await commitToolResult(name, effectiveArgs, call, result, Math.max(0, Date.now() - toolStart));
|
|
909
1286
|
continue;
|
|
910
1287
|
}
|
|
911
1288
|
// Parallel batch: every member is pre-validated parallel-safe (see
|
|
@@ -937,10 +1314,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
937
1314
|
repHardStop = { sig: note.signature, consecutive: note.consecutive };
|
|
938
1315
|
}
|
|
939
1316
|
}
|
|
940
|
-
|
|
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.
|
|
1317
|
+
const memberPlans = new Map();
|
|
944
1318
|
const preDecisions = new Map();
|
|
945
1319
|
for (let i = 0; i < batch.length; i++) {
|
|
946
1320
|
throwIfCancelled(signal);
|
|
@@ -948,7 +1322,22 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
948
1322
|
continue;
|
|
949
1323
|
const member = batch[i];
|
|
950
1324
|
const memberName = member.call?.function?.name ?? "(unknown)";
|
|
951
|
-
|
|
1325
|
+
if (!toolNames().includes(memberName)) {
|
|
1326
|
+
memberPlans.set(i, { args: member.parsed, blocked: null, invalid: null });
|
|
1327
|
+
continue;
|
|
1328
|
+
}
|
|
1329
|
+
const pre = await runBeforeIntercept(memberName, member.parsed);
|
|
1330
|
+
if (pre.blocked !== null) {
|
|
1331
|
+
memberPlans.set(i, { args: pre.args, blocked: pre.blocked, invalid: null });
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
const invalid = validateToolArgs(memberName, pre.args);
|
|
1335
|
+
if (invalid) {
|
|
1336
|
+
memberPlans.set(i, { args: pre.args, blocked: null, invalid });
|
|
1337
|
+
continue;
|
|
1338
|
+
}
|
|
1339
|
+
memberPlans.set(i, { args: pre.args, blocked: null, invalid: null });
|
|
1340
|
+
const decision = await resolveApproval(memberName, pre.args, opts);
|
|
952
1341
|
if (decision !== null)
|
|
953
1342
|
preDecisions.set(i, decision);
|
|
954
1343
|
}
|
|
@@ -979,7 +1368,46 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
979
1368
|
return guarded;
|
|
980
1369
|
}
|
|
981
1370
|
try {
|
|
982
|
-
|
|
1371
|
+
// Pre-resolved members (blocked/invalid) never reach the
|
|
1372
|
+
// executor: their inline results commit in call order below.
|
|
1373
|
+
const plan = memberPlans.get(index);
|
|
1374
|
+
if (plan?.blocked !== null && plan?.blocked !== undefined) {
|
|
1375
|
+
const blocked = plan.blocked;
|
|
1376
|
+
const memberEnd = Date.now();
|
|
1377
|
+
memberDurations[index] = Math.max(0, memberEnd - memberStart);
|
|
1378
|
+
reportToolCall({
|
|
1379
|
+
step,
|
|
1380
|
+
toolCallId: member.call?.id ?? "",
|
|
1381
|
+
name: member.call?.function?.name ?? "(unknown)",
|
|
1382
|
+
startedAt: telemetryIso(memberStart),
|
|
1383
|
+
endedAt: telemetryIso(memberEnd),
|
|
1384
|
+
durationMs: Math.max(0, memberEnd - memberStart),
|
|
1385
|
+
argsJson: telemetryArgsJson(plan.args),
|
|
1386
|
+
result: blocked,
|
|
1387
|
+
batchIndex: index,
|
|
1388
|
+
batchSize: batch.length,
|
|
1389
|
+
});
|
|
1390
|
+
return blocked;
|
|
1391
|
+
}
|
|
1392
|
+
if (plan?.invalid) {
|
|
1393
|
+
const bad = invalidCall(plan.invalid);
|
|
1394
|
+
const memberEnd = Date.now();
|
|
1395
|
+
memberDurations[index] = Math.max(0, memberEnd - memberStart);
|
|
1396
|
+
reportToolCall({
|
|
1397
|
+
step,
|
|
1398
|
+
toolCallId: member.call?.id ?? "",
|
|
1399
|
+
name: member.call?.function?.name ?? "(unknown)",
|
|
1400
|
+
startedAt: telemetryIso(memberStart),
|
|
1401
|
+
endedAt: telemetryIso(memberEnd),
|
|
1402
|
+
durationMs: Math.max(0, memberEnd - memberStart),
|
|
1403
|
+
argsJson: telemetryArgsJson(plan.args),
|
|
1404
|
+
result: bad,
|
|
1405
|
+
batchIndex: index,
|
|
1406
|
+
batchSize: batch.length,
|
|
1407
|
+
});
|
|
1408
|
+
return bad;
|
|
1409
|
+
}
|
|
1410
|
+
const r = await runOneToolWithArgs(member.call, plan?.args ?? member.parsed, opts, execute, preDecisions.get(index) ?? null, recordGoalReport);
|
|
983
1411
|
const memberEnd = Date.now();
|
|
984
1412
|
memberDurations[index] = Math.max(0, memberEnd - memberStart);
|
|
985
1413
|
reportToolCall({
|
|
@@ -989,12 +1417,12 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
989
1417
|
startedAt: telemetryIso(memberStart),
|
|
990
1418
|
endedAt: telemetryIso(memberEnd),
|
|
991
1419
|
durationMs: Math.max(0, memberEnd - memberStart),
|
|
992
|
-
argsJson: telemetryArgsJson(member.parsed),
|
|
993
|
-
result: r,
|
|
1420
|
+
argsJson: telemetryArgsJson(plan?.args ?? member.parsed),
|
|
1421
|
+
result: r.result,
|
|
994
1422
|
batchIndex: index,
|
|
995
1423
|
batchSize: batch.length,
|
|
996
1424
|
});
|
|
997
|
-
return r;
|
|
1425
|
+
return r.result;
|
|
998
1426
|
}
|
|
999
1427
|
catch (e) {
|
|
1000
1428
|
const memberEnd = Date.now();
|
|
@@ -1028,7 +1456,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
1028
1456
|
}
|
|
1029
1457
|
for (let i = 0; i < batch.length; i++) {
|
|
1030
1458
|
const member = batch[i];
|
|
1031
|
-
await commitToolResult(member.call?.function?.name ?? "(unknown)", member.parsed, member.call, results[i], memberDurations[i]);
|
|
1459
|
+
await commitToolResult(member.call?.function?.name ?? "(unknown)", memberPlans.get(i)?.args ?? member.parsed, member.call, results[i], memberDurations[i]);
|
|
1032
1460
|
}
|
|
1033
1461
|
if (repHardStop) {
|
|
1034
1462
|
const stopBase = msg.content ?? "";
|
|
@@ -1045,6 +1473,19 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
1045
1473
|
}
|
|
1046
1474
|
}
|
|
1047
1475
|
}
|
|
1476
|
+
catch (e) {
|
|
1477
|
+
// Cancel during a live goal pauses (preserves) it with a notice — never
|
|
1478
|
+
// clears — so a later /goal resume can continue. Failed POSTs skip this
|
|
1479
|
+
// entirely: the caller rolls back per the existing splice contract and
|
|
1480
|
+
// the goal stays active and carries on.
|
|
1481
|
+
if (isCancelError(e) || signal?.aborted) {
|
|
1482
|
+
const cancelledGoal = readLiveGoal();
|
|
1483
|
+
if (cancelledGoal !== null && cancelledGoal.active) {
|
|
1484
|
+
pauseLiveGoal(cancelledGoal.objective, "(cancelled)");
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
throw e;
|
|
1488
|
+
}
|
|
1048
1489
|
finally {
|
|
1049
1490
|
finishStats();
|
|
1050
1491
|
}
|