atom-agent 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +5 -4
- package/dist/App.js +738 -79
- package/dist/adapters.js +30 -8
- package/dist/agent/gates.js +14 -1
- package/dist/agent/loop-guard.js +11 -13
- package/dist/agent/loop.js +212 -69
- package/dist/agent/normalize.js +9 -2
- package/dist/cli.js +16 -2
- package/dist/compact.js +128 -2
- package/dist/env-block.js +43 -5
- package/dist/scheduler.js +101 -21
- package/dist/sessions.js +524 -0
- package/dist/system.js +89 -13
- package/dist/tools/dir-cache.js +7 -0
- package/dist/tools/filesystem.js +3 -2
- package/dist/tools/registry.js +1 -0
- package/dist/tools/ripgrep.js +256 -0
- package/dist/tools/search.js +119 -58
- package/dist/tools/shared.js +39 -0
- package/dist/tools/shell.js +7 -5
- package/dist/tools/web.js +6 -6
- package/dist/tools.js +1 -0
- package/dist/ui/diff-view.js +7 -2
- package/dist/ui/live-host.js +18 -0
- package/dist/ui/live-tail.js +9 -3
- package/dist/ui/markdown.js +26 -2
- package/dist/ui/palette.js +2 -0
- package/dist/ui/side-by-side.js +2 -2
- package/dist/ui/status-host.js +22 -0
- package/dist/ui/stream-store.js +48 -0
- package/dist/ui/tool-inspector.js +7 -1
- package/dist/ui/transcript.js +92 -38
- package/dist/zen.js +66 -13
- package/package.json +1 -1
package/dist/adapters.js
CHANGED
|
@@ -318,6 +318,24 @@ async function collectSSEText(res) {
|
|
|
318
318
|
const body = res.body;
|
|
319
319
|
const decoder = new TextDecoder();
|
|
320
320
|
let rawText = "";
|
|
321
|
+
// Data-silence tracking (mirrors zen.readSSEMessage): queue comments and
|
|
322
|
+
// keep-alives carry bytes but no model output, so only chunks containing a
|
|
323
|
+
// `data:` line start refresh the clock. The tail window catches a marker
|
|
324
|
+
// split across chunk boundaries.
|
|
325
|
+
let lastDataAt = Date.now();
|
|
326
|
+
let tail = "";
|
|
327
|
+
const noteChunk = (chunkText) => {
|
|
328
|
+
const joined = tail + chunkText;
|
|
329
|
+
if (/(?:^|\n)data:/.test(joined))
|
|
330
|
+
lastDataAt = Date.now();
|
|
331
|
+
tail = joined.slice(-8);
|
|
332
|
+
};
|
|
333
|
+
const throwIfDataStalled = () => {
|
|
334
|
+
const budget = sseStallTimeoutMs();
|
|
335
|
+
if (Date.now() - lastDataAt > budget) {
|
|
336
|
+
throw new Error(`Truncated stream from model (stall: no output for ${budget}ms — queued or stalled upstream; resend to retry).`);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
321
339
|
if (body == null)
|
|
322
340
|
return { rawText, events: [] };
|
|
323
341
|
try {
|
|
@@ -346,10 +364,12 @@ async function collectSSEText(res) {
|
|
|
346
364
|
if (chunk.done)
|
|
347
365
|
break;
|
|
348
366
|
const v = chunk.value;
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
367
|
+
const textPart = typeof v === "string"
|
|
368
|
+
? v
|
|
369
|
+
: decoder.decode(v, { stream: true });
|
|
370
|
+
rawText += textPart;
|
|
371
|
+
noteChunk(textPart);
|
|
372
|
+
throwIfDataStalled();
|
|
353
373
|
}
|
|
354
374
|
}
|
|
355
375
|
finally {
|
|
@@ -369,10 +389,12 @@ async function collectSSEText(res) {
|
|
|
369
389
|
if (step.done)
|
|
370
390
|
break;
|
|
371
391
|
const v = step.value;
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
392
|
+
const textPart = typeof v === "string"
|
|
393
|
+
? v
|
|
394
|
+
: decoder.decode(v, { stream: true });
|
|
395
|
+
rawText += textPart;
|
|
396
|
+
noteChunk(textPart);
|
|
397
|
+
throwIfDataStalled();
|
|
376
398
|
}
|
|
377
399
|
}
|
|
378
400
|
finally {
|
package/dist/agent/gates.js
CHANGED
|
@@ -19,6 +19,15 @@ export function todoCompletionGate(finalText, ctx) {
|
|
|
19
19
|
finalText: `${finalText}${finalText ? "\n" : ""}(blocked: ${open.length} open todo(s) — resolve with todo_update/todowrite before ending the turn:\n${items})`,
|
|
20
20
|
};
|
|
21
21
|
}
|
|
22
|
+
// Guard cycles are bounded (like the verification gate): a model that
|
|
23
|
+
// keeps answering without resolving todos ends with a blocked statement
|
|
24
|
+
// instead of looping forever. Normal flows resolve within a round or two.
|
|
25
|
+
if ((ctx.todoRounds ?? 0) >= MAX_TODO_ROUNDS) {
|
|
26
|
+
return {
|
|
27
|
+
action: "end",
|
|
28
|
+
finalText: `${finalText}${finalText ? "\n" : ""}(blocked: ${open.length} open todo(s) remain after ${MAX_TODO_ROUNDS} guard rounds — resolve with todo_update/todowrite before ending the turn:\n${items})`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
22
31
|
return {
|
|
23
32
|
action: "continue",
|
|
24
33
|
assistantText: finalText,
|
|
@@ -30,11 +39,15 @@ export function todoCompletionGate(finalText, ctx) {
|
|
|
30
39
|
// report — the system prompt forbids unverified finishes, so the runtime
|
|
31
40
|
// must not terminate while just labeling): the attempt is recorded and a
|
|
32
41
|
// verification follow-up re-enters the loop, exactly like the todo guard.
|
|
33
|
-
// Two bounded exits:
|
|
42
|
+
// Two bounded exits: an explicit step budget spent, or MAX_VERIFY_ROUNDS nag
|
|
34
43
|
// without a passing run — both end with an explicit labeled statement naming
|
|
35
44
|
// what is unverified and why the loop stopped. Turns with no code writes
|
|
36
45
|
// (questions, docs, explanations, read-only work) are unaffected.
|
|
37
46
|
export const MAX_VERIFY_ROUNDS = 3;
|
|
47
|
+
// Todo-guard continues before the turn ends blocked: a model that keeps
|
|
48
|
+
// answering final text without resolving open todos is sent back at most
|
|
49
|
+
// this many times. Mirrors MAX_VERIFY_ROUNDS so no gate can spin forever.
|
|
50
|
+
export const MAX_TODO_ROUNDS = 3;
|
|
38
51
|
// Source-code extensions whose writes require a passing verification run.
|
|
39
52
|
// Curated heuristic boundary (not a parser): docs, configs, data, and
|
|
40
53
|
// extensionless files never arm the gate, so a README edit finishes clean.
|
package/dist/agent/loop-guard.js
CHANGED
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
// Loop-guard: repetition/runaway detection + error-streak recovery for the
|
|
2
2
|
// agentic loop. Pure state machines, no I/O, never throw.
|
|
3
3
|
//
|
|
4
|
-
// Why this exists:
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
4
|
+
// Why this exists: turns are uncapped by default, so a model stuck calling
|
|
5
|
+
// `read <same path>` forever burns POSTs without end. The guard spots the
|
|
6
|
+
// pattern early (consecutive identical signatures) and the loop nudges the
|
|
7
|
+
// model toward a different approach with a bounded follow-up — then stops
|
|
8
|
+
// hard if the pattern survives the nudges. Error streaks get the same
|
|
9
|
+
// treatment: ending on 3+ unaddressed `Error:` results is almost always
|
|
10
|
+
// premature, so the loop asks for a fix-forward attempt before accepting
|
|
11
|
+
// final text.
|
|
12
12
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// notice". Error-streak recovery defaults to 3 (single errors still end
|
|
17
|
-
// normally — the model may be reporting a blocker).
|
|
13
|
+
// Repetition intervention is OPT-IN (maxRepeatedCalls set by the caller;
|
|
14
|
+
// unset = track-only for stats). Error-streak recovery defaults to 3
|
|
15
|
+
// (single errors still end normally — the model may be reporting a blocker).
|
|
18
16
|
//
|
|
19
17
|
// All thresholds clamp to sane minima; every method is safe to call with any
|
|
20
18
|
// input.
|
package/dist/agent/loop.js
CHANGED
|
@@ -9,9 +9,12 @@
|
|
|
9
9
|
// call); everything else executes strictly serially in program order. A
|
|
10
10
|
// failure in one call NEVER skips the remaining commits of its block when
|
|
11
11
|
// the results are values; malformed calls yield their error result inline.
|
|
12
|
-
// A
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// A length-truncated response (`truncated: true`: the output limit cut tool
|
|
13
|
+
// arguments off) executes nothing — each carried call commits a
|
|
14
|
+
// repair-oriented error result and the turn continues to the next model
|
|
15
|
+
// round, bounded by the step/total-call budgets. A thrown execution error
|
|
16
|
+
// (or cancel) aborts the turn exactly as the old serial loop did — the
|
|
17
|
+
// caller rolls the partial turn back, so assistant/tool pairing stays valid.
|
|
15
18
|
//
|
|
16
19
|
// Dependency direction: agent/loop -> {tools, tools/read-cache,
|
|
17
20
|
// scheduler, config, context-manager, agent/gates, agent/loop-guard,
|
|
@@ -20,7 +23,7 @@
|
|
|
20
23
|
import { loadAtomConfig } from "../config.js";
|
|
21
24
|
import { createContextManager, historyCharBudget, historyChars, historyMessageBudget, truncateHistoryWithCaps, } from "../context-manager.js";
|
|
22
25
|
import { planBatches } from "../scheduler.js";
|
|
23
|
-
import { describeToolCall, executeTool, invalidCall,
|
|
26
|
+
import { describeToolCall, executeTool, invalidCall, needsApproval, toolNames, validateAskQuestionArgs, validateToolArgs, } from "../tools.js";
|
|
24
27
|
import { getReadCacheStats } from "../tools/read-cache.js";
|
|
25
28
|
import { bashExitCode, evaluateTurnEnd, isCodePath, isVerificationCommand, openTodoNeedles, } from "./gates.js";
|
|
26
29
|
import { errorStreakFollowUp, ErrorStreakTracker, repetitionFollowUp, RepetitionGuard, repetitionStopNotice, } from "./loop-guard.js";
|
|
@@ -53,9 +56,10 @@ export function throwIfCancelled(signal) {
|
|
|
53
56
|
if (signal?.aborted)
|
|
54
57
|
throw new LoopCancelledError();
|
|
55
58
|
}
|
|
56
|
-
// Tool-round budget for one agentic turn (env → atom.json →
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
+
// Tool-round budget for one agentic turn (env → atom.json → unlimited).
|
|
60
|
+
// No default cap: the turn runs until the model ends it, a gate stops it,
|
|
61
|
+
// or the user cancels. An explicit `opts.maxSteps` (or env/file value) still
|
|
62
|
+
// caps the turn (tests inject it).
|
|
59
63
|
export function toolStepBudget() {
|
|
60
64
|
const raw = process.env.ATOM_MAX_TOOL_STEPS;
|
|
61
65
|
if (raw !== undefined) {
|
|
@@ -66,7 +70,7 @@ export function toolStepBudget() {
|
|
|
66
70
|
return Math.min(Math.max(Math.floor(n), 5), 100);
|
|
67
71
|
}
|
|
68
72
|
}
|
|
69
|
-
return loadAtomConfig().config.maxToolSteps ??
|
|
73
|
+
return loadAtomConfig().config.maxToolSteps ?? Number.POSITIVE_INFINITY;
|
|
70
74
|
}
|
|
71
75
|
// Legacy trim entry: byte-identical contract (legacy env/config/default caps
|
|
72
76
|
// + live todo pinning, same notice, same in-place splice). New code should
|
|
@@ -75,6 +79,24 @@ export function toolStepBudget() {
|
|
|
75
79
|
export function truncateHistory(history, notify, reserve) {
|
|
76
80
|
return truncateHistoryWithCaps(history, { maxMessages: historyMessageBudget(), maxChars: historyCharBudget() }, { notify, reserve, todoNeedles: openTodoNeedles() });
|
|
77
81
|
}
|
|
82
|
+
// Empty-response recovery (live-proven on free-tier gateways: a 200-OK
|
|
83
|
+
// stream can carry only queue comments and reasoning with zero answer text
|
|
84
|
+
// and zero tool calls, which the transport reports as an `Empty reply`
|
|
85
|
+
// error). A failed POST normally aborts the turn — EXCEPT this one: ending
|
|
86
|
+
// the turn on model silence with no fallback makes flaky backends fatal, so
|
|
87
|
+
// the loop spends a bounded number of extra POSTs asking the model to repair
|
|
88
|
+
// (same assistant+user follow-up shape as the turn-end gates, so pairing
|
|
89
|
+
// stays valid). When the budget is spent the original error throws, exactly
|
|
90
|
+
// as before — the caller rolls back and the user sees it.
|
|
91
|
+
export const MAX_EMPTY_ROUNDS = 2;
|
|
92
|
+
export function isEmptyReplyError(e) {
|
|
93
|
+
return e instanceof Error && e.message.startsWith("Empty reply");
|
|
94
|
+
}
|
|
95
|
+
export function emptyResponseFollowUp(attempt) {
|
|
96
|
+
return (`(empty response: attempt ${attempt} returned no text and no tool calls — ` +
|
|
97
|
+
`the turn cannot end on silence. Continue with tool calls toward the goal, ` +
|
|
98
|
+
`or answer in text. If there is genuinely nothing to do, end by saying so explicitly.)`);
|
|
99
|
+
}
|
|
78
100
|
// Per-tool outer timeout (ms): undefined → default 60s (enabled); explicit
|
|
79
101
|
// <=0/NaN → disabled (direct await, zero overhead). Clamped 1s–120s when
|
|
80
102
|
// enabled so a stuck executor can never hang the turn past the bash ceiling.
|
|
@@ -88,12 +110,13 @@ export function resolveToolTimeoutMs(raw) {
|
|
|
88
110
|
return null;
|
|
89
111
|
return Math.min(Math.max(Math.floor(raw), 1000), 120_000);
|
|
90
112
|
}
|
|
91
|
-
// Total tool-call budget per turn (default
|
|
92
|
-
//
|
|
93
|
-
|
|
113
|
+
// Total tool-call budget per turn (no default cap; explicit opts value only,
|
|
114
|
+
// min 1). Previously defaulted to 200 as a parallel-batch explosion guard.
|
|
115
|
+
// Deprecated alias kept for import compatibility; the loop no longer uses it.
|
|
116
|
+
export const DEFAULT_MAX_TOTAL_TOOL_CALLS = Number.POSITIVE_INFINITY;
|
|
94
117
|
export function resolveMaxTotalToolCalls(raw) {
|
|
95
118
|
if (typeof raw !== "number" || !Number.isFinite(raw))
|
|
96
|
-
return
|
|
119
|
+
return Number.POSITIVE_INFINITY;
|
|
97
120
|
return Math.max(1, Math.floor(raw));
|
|
98
121
|
}
|
|
99
122
|
// Race one execution against the outer timeout. Timeout resolves to an
|
|
@@ -142,6 +165,27 @@ export async function executeWithTimeout(execute, name, parsed, timeoutMs, signa
|
|
|
142
165
|
clearTimeout(timer);
|
|
143
166
|
}
|
|
144
167
|
}
|
|
168
|
+
// Permission-gate resolution shared by the serial path and the parallel
|
|
169
|
+
// pre-pass: returns the hook's decision, or null when no approval applies
|
|
170
|
+
// (no hook, or a tool that never needs it). A "no" (or a throwing hook)
|
|
171
|
+
// denies without executing; cancellation always propagates.
|
|
172
|
+
async function resolveApproval(name, parsed, opts) {
|
|
173
|
+
if (!opts?.approve || !needsApproval(name))
|
|
174
|
+
return null;
|
|
175
|
+
try {
|
|
176
|
+
const decision = await opts.approve(name, parsed);
|
|
177
|
+
// Abort that lands as a resolved denial still cancels the whole turn.
|
|
178
|
+
throwIfCancelled(opts?.signal);
|
|
179
|
+
return decision;
|
|
180
|
+
}
|
|
181
|
+
catch (e) {
|
|
182
|
+
// Whole-turn cancellation must propagate (Ctrl+C cancels the turn,
|
|
183
|
+
// not just deny one call). Anything else is a denial.
|
|
184
|
+
if (isCancelError(e) || opts?.signal?.aborted)
|
|
185
|
+
throw new LoopCancelledError();
|
|
186
|
+
return "no";
|
|
187
|
+
}
|
|
188
|
+
}
|
|
145
189
|
// Execute one parsed tool call through validation + permission +
|
|
146
190
|
// ask_question gates. Model mistakes (unknown name, invalid args) return
|
|
147
191
|
// repairs-oriented results WITHOUT executing; cancellations propagate as
|
|
@@ -149,10 +193,11 @@ export async function executeWithTimeout(execute, name, parsed, timeoutMs, signa
|
|
|
149
193
|
// returns a result string fed back to the model:
|
|
150
194
|
// - ask_question never needs approval; without an askUser hook it resolves
|
|
151
195
|
// to "Error: ask_question has no UI hook".
|
|
152
|
-
// - write/edit/bash consult the approve hook when one is provided
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
196
|
+
// - write/edit/bash consult the approve hook when one is provided (or a
|
|
197
|
+
// pre-resolved batch decision); a "no" resolves to
|
|
198
|
+
// "Error: denied by user: <tool>" (final, no retry/rollback). Without a
|
|
199
|
+
// hook every tool executes immediately.
|
|
200
|
+
async function runOneTool(call, parsed, opts, execute, preDecision) {
|
|
156
201
|
const name = call?.function?.name ?? "(unknown)";
|
|
157
202
|
// Unknown tool: model mistake — list actual names, never execute.
|
|
158
203
|
if (!toolNames().includes(name)) {
|
|
@@ -171,26 +216,12 @@ async function runOneTool(call, parsed, opts, execute) {
|
|
|
171
216
|
// the next POST (no new POSTs, pairing stays valid until rollback).
|
|
172
217
|
return runAskQuestion(parsed, opts?.askUser, opts?.signal);
|
|
173
218
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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).
|
|
219
|
+
const decision = preDecision ?? (await resolveApproval(name, parsed, opts));
|
|
220
|
+
if (decision === "no") {
|
|
221
|
+
return `Error: denied by user: ${name}`;
|
|
193
222
|
}
|
|
223
|
+
// "once"/"always" run this call (the caller caches the always-allowed set
|
|
224
|
+
// session-wide so later calls skip the prompt).
|
|
194
225
|
// No new executions after a cancel: stop after the current tool finishes.
|
|
195
226
|
// The current tool (if already running) is awaited to completion and its
|
|
196
227
|
// result IS recorded — the loop then stops before the next tool/POST, so
|
|
@@ -210,6 +241,35 @@ async function runOneTool(call, parsed, opts, execute) {
|
|
|
210
241
|
throw e;
|
|
211
242
|
}
|
|
212
243
|
}
|
|
244
|
+
// After-tool-call result hook (issue 06): the single rewrite seam between
|
|
245
|
+
// execution and commit. Absent hook (or null/undefined/void/non-object
|
|
246
|
+
// return) → the original result, byte-identical. A string return replaces
|
|
247
|
+
// content (error flag kept); an object may replace content, override
|
|
248
|
+
// isError, or veto the commit. Hook failures degrade to the original — the
|
|
249
|
+
// turn never breaks. Telemetry keeps the pre-hook execution result; only the
|
|
250
|
+
// committed history/transcript/activity sees the rewrite.
|
|
251
|
+
async function applyToolResultHook(hook, name, parsed, result, isError) {
|
|
252
|
+
if (!hook)
|
|
253
|
+
return { content: result, isError, veto: false };
|
|
254
|
+
try {
|
|
255
|
+
const decision = await hook({ name, args: parsed, result, isError });
|
|
256
|
+
if (decision === null || decision === undefined)
|
|
257
|
+
return { content: result, isError, veto: false };
|
|
258
|
+
if (typeof decision === "string")
|
|
259
|
+
return { content: decision, isError, veto: false };
|
|
260
|
+
if (typeof decision === "object") {
|
|
261
|
+
if (decision.veto === true)
|
|
262
|
+
return { content: result, isError, veto: true };
|
|
263
|
+
const content = typeof decision.content === "string" ? decision.content : result;
|
|
264
|
+
const nextIsError = typeof decision.isError === "boolean" ? decision.isError : isError;
|
|
265
|
+
return { content, isError: nextIsError, veto: false };
|
|
266
|
+
}
|
|
267
|
+
return { content: result, isError, veto: false };
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return { content: result, isError, veto: false };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
213
273
|
async function runAskQuestion(parsed, askUser, signal) {
|
|
214
274
|
const invalid = validateAskQuestionArgs(parsed);
|
|
215
275
|
if (invalid)
|
|
@@ -302,9 +362,12 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
302
362
|
let verifiedAfterWrite = false;
|
|
303
363
|
let needsVerification = false;
|
|
304
364
|
let unverifiedPaths = [];
|
|
305
|
-
// Verification-gate nag cycles spent (bounds the continue loop
|
|
306
|
-
//
|
|
365
|
+
// Verification-gate nag cycles spent (bounds the continue loop via
|
|
366
|
+
// MAX_VERIFY_ROUNDS — a model that never verifies still terminates).
|
|
307
367
|
let verifyRounds = 0;
|
|
368
|
+
// Todo-guard cycles spent (bounds guard continues via MAX_TODO_ROUNDS —
|
|
369
|
+
// a model that never resolves open todos still terminates).
|
|
370
|
+
let todoRounds = 0;
|
|
308
371
|
// At most one truncation notice per turn; silence when nothing dropped.
|
|
309
372
|
let truncationNoticed = false;
|
|
310
373
|
// Window-aware trimmer when the caller knows the model (App passes it);
|
|
@@ -313,8 +376,8 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
313
376
|
const contextManager = opts?.context
|
|
314
377
|
? createContextManager({ model: opts.context.model, toolsChars: opts.context.toolsChars })
|
|
315
378
|
: null;
|
|
316
|
-
// ---- Hardened-loop state (additive;
|
|
317
|
-
//
|
|
379
|
+
// ---- Hardened-loop state (additive; explicit caps still honored —
|
|
380
|
+
// see AgenticOpts docs) ----
|
|
318
381
|
const maxTotalToolCalls = resolveMaxTotalToolCalls(opts?.maxTotalToolCalls);
|
|
319
382
|
const repGuard = new RepetitionGuard({ maxRepeatedCalls: opts?.maxRepeatedCalls });
|
|
320
383
|
const errStreak = new ErrorStreakTracker(opts?.maxConsecutiveErrors);
|
|
@@ -337,6 +400,9 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
337
400
|
let toolCalls = 0;
|
|
338
401
|
let failures = 0;
|
|
339
402
|
let droppedTurnsTotal = 0;
|
|
403
|
+
// Empty-response repairs spent (bounded by MAX_EMPTY_ROUNDS — a model
|
|
404
|
+
// that only answers silence still terminates).
|
|
405
|
+
let emptyRounds = 0;
|
|
340
406
|
let bottleneck = null;
|
|
341
407
|
const noteBottleneck = (name, durationMs) => {
|
|
342
408
|
if (!Number.isFinite(durationMs) || durationMs < 0)
|
|
@@ -449,6 +515,16 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
449
515
|
});
|
|
450
516
|
if (isCancelError(e) || signal?.aborted)
|
|
451
517
|
throw new LoopCancelledError();
|
|
518
|
+
// Empty-response recovery: a silent POST spends repair budget instead
|
|
519
|
+
// of aborting the turn (see MAX_EMPTY_ROUNDS). Cancellation above still
|
|
520
|
+
// wins; every other failure throws exactly as before.
|
|
521
|
+
if (isEmptyReplyError(e) && emptyRounds < MAX_EMPTY_ROUNDS) {
|
|
522
|
+
emptyRounds += 1;
|
|
523
|
+
failures += 1;
|
|
524
|
+
history.push({ role: "assistant", content: "" });
|
|
525
|
+
history.push({ role: "user", content: emptyResponseFollowUp(emptyRounds) });
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
452
528
|
throw e;
|
|
453
529
|
}
|
|
454
530
|
throwIfCancelled(signal);
|
|
@@ -525,12 +601,15 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
525
601
|
needsVerification,
|
|
526
602
|
unverifiedPaths: [...unverifiedPaths],
|
|
527
603
|
verifyRounds,
|
|
604
|
+
todoRounds,
|
|
528
605
|
});
|
|
529
606
|
if (outcome.kind === "continue") {
|
|
530
|
-
//
|
|
531
|
-
//
|
|
607
|
+
// Guard continues are bounded per turn so a model that never
|
|
608
|
+
// verifies or never resolves todos still terminates.
|
|
532
609
|
if (outcome.via === "verification")
|
|
533
610
|
verifyRounds += 1;
|
|
611
|
+
else if (outcome.via === "todoCompletionGate")
|
|
612
|
+
todoRounds += 1;
|
|
534
613
|
history.push({ role: "assistant", content: outcome.assistantText });
|
|
535
614
|
history.push({ role: "user", content: outcome.followUp });
|
|
536
615
|
continue;
|
|
@@ -567,9 +646,9 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
567
646
|
}
|
|
568
647
|
return notice;
|
|
569
648
|
}
|
|
570
|
-
// Total tool-call budget (
|
|
571
|
-
//
|
|
572
|
-
//
|
|
649
|
+
// Total tool-call budget (explicit `opts.maxTotalToolCalls` only;
|
|
650
|
+
// uncapped by default): counts every tool_call the model emits and stops
|
|
651
|
+
// the turn when the explicit budget is exceeded.
|
|
573
652
|
if (toolCalls + calls.length > maxTotalToolCalls) {
|
|
574
653
|
const base = msg.content ?? "";
|
|
575
654
|
const notice = `${base}${base ? "\n" : ""}(stopped: too many tool calls) (limit is ${maxTotalToolCalls} per turn)`;
|
|
@@ -587,8 +666,15 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
587
666
|
// bookkeeping + one ordered transcript entry per call. Only successful
|
|
588
667
|
// executions count — denials, validation errors, and unknown tools (all
|
|
589
668
|
// `Error:` results) never ran, so they neither arm nor clear the gate.
|
|
590
|
-
const commitToolResult = (name, parsed, call, result, durationMs) => {
|
|
591
|
-
const
|
|
669
|
+
const commitToolResult = async (name, parsed, call, result, durationMs) => {
|
|
670
|
+
const baseIsError = typeof result === "string" && result.startsWith("Error");
|
|
671
|
+
const hooked = await applyToolResultHook(opts?.onToolResult, name, parsed, result, baseIsError);
|
|
672
|
+
// Veto: skip the commit entirely — no counters, no gates, no history,
|
|
673
|
+
// no activity. The turn continues; pairing risk is the hook author's.
|
|
674
|
+
if (hooked.veto)
|
|
675
|
+
return false;
|
|
676
|
+
const finalResult = hooked.content;
|
|
677
|
+
const isError = hooked.isError;
|
|
592
678
|
toolCalls += 1;
|
|
593
679
|
if (isError)
|
|
594
680
|
failures += 1;
|
|
@@ -608,7 +694,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
608
694
|
else if (!isError && name === "bash") {
|
|
609
695
|
const command = parsed["command"];
|
|
610
696
|
if (typeof command === "string" && isVerificationCommand(command) && filesWritten) {
|
|
611
|
-
const exit = bashExitCode(
|
|
697
|
+
const exit = bashExitCode(finalResult);
|
|
612
698
|
if (exit === null || exit === 0) {
|
|
613
699
|
// Passing check (or a legacy runner that reports no envelope):
|
|
614
700
|
// clears everything the gate tracks.
|
|
@@ -624,14 +710,59 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
624
710
|
}
|
|
625
711
|
}
|
|
626
712
|
}
|
|
627
|
-
history.push({ role: "tool", tool_call_id: call?.id ?? "", content:
|
|
713
|
+
history.push({ role: "tool", tool_call_id: call?.id ?? "", content: finalResult });
|
|
628
714
|
try {
|
|
629
|
-
opts?.onToolActivity?.(describeToolCall(name, parsed),
|
|
715
|
+
opts?.onToolActivity?.(describeToolCall(name, parsed), finalResult, isError);
|
|
630
716
|
}
|
|
631
717
|
catch {
|
|
632
718
|
// ignore observer errors
|
|
633
719
|
}
|
|
720
|
+
return true;
|
|
634
721
|
};
|
|
722
|
+
// Length-truncated response (the output limit cut the tool arguments
|
|
723
|
+
// off): NOTHING executes — every carried call commits a repair-oriented
|
|
724
|
+
// error result and the turn continues to the next model round. The
|
|
725
|
+
// results are `Error:` strings, so failure/error-streak accounting flows
|
|
726
|
+
// through commitToolResult exactly like any other tool error, and the
|
|
727
|
+
// step/total-call budgets above keep bounding runaway retries.
|
|
728
|
+
// Truncated-without-calls never reaches here (handled by the turn-end
|
|
729
|
+
// gates above, exactly as before).
|
|
730
|
+
if (msg.truncated === true) {
|
|
731
|
+
for (let i = 0; i < calls.length; i++) {
|
|
732
|
+
const call = calls[i];
|
|
733
|
+
const name = call?.function?.name ?? "(unknown)";
|
|
734
|
+
const result = `Error: truncated response: the output limit cut off the arguments for tool "${name}" — ` +
|
|
735
|
+
`nothing was executed. Re-issue the call with complete arguments ` +
|
|
736
|
+
`(narrow the scope or split into smaller calls if it keeps truncating).`;
|
|
737
|
+
const toolAt = Date.now();
|
|
738
|
+
reportToolCall({
|
|
739
|
+
step,
|
|
740
|
+
toolCallId: call?.id ?? "",
|
|
741
|
+
name,
|
|
742
|
+
startedAt: telemetryIso(toolAt),
|
|
743
|
+
endedAt: telemetryIso(toolAt),
|
|
744
|
+
durationMs: 0,
|
|
745
|
+
argsJson: telemetryArgsJson(call?.function?.arguments ?? "{}"),
|
|
746
|
+
result,
|
|
747
|
+
batchIndex: i,
|
|
748
|
+
batchSize: calls.length,
|
|
749
|
+
});
|
|
750
|
+
// Truncated arguments are often not valid JSON (cut mid-string) —
|
|
751
|
+
// fall back to {} for bookkeeping (an error result never arms the
|
|
752
|
+
// verification gate, so this only shapes the activity label).
|
|
753
|
+
let parsed;
|
|
754
|
+
try {
|
|
755
|
+
const raw = call?.function?.arguments ?? "{}";
|
|
756
|
+
const v = JSON.parse(typeof raw === "string" ? raw : "{}");
|
|
757
|
+
parsed = typeof v === "object" && v !== null ? v : {};
|
|
758
|
+
}
|
|
759
|
+
catch {
|
|
760
|
+
parsed = {};
|
|
761
|
+
}
|
|
762
|
+
await commitToolResult(name, parsed, call, result);
|
|
763
|
+
}
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
635
766
|
for (const batch of planBatches(calls)) {
|
|
636
767
|
// No new executions after a cancel: the current tool (if any) already
|
|
637
768
|
// finished; stop before starting the next batch.
|
|
@@ -656,18 +787,12 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
656
787
|
catch {
|
|
657
788
|
parsed = {};
|
|
658
789
|
const result = `Error: invalid call: invalid JSON arguments for tool "${name}" (arguments must be valid JSON). Fix the arguments and retry.`;
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
790
|
+
// Invalid JSON never executes — route through the commit funnel so
|
|
791
|
+
// the result hook still sees every committed result. Bookkeeping
|
|
792
|
+
// (counters, error streak, bottleneck, history, activity) is
|
|
793
|
+
// identical to the inline block this replaced; only the committed
|
|
794
|
+
// content may differ when a hook rewrites it.
|
|
662
795
|
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
796
|
const toolEnd = Date.now();
|
|
672
797
|
reportToolCall({
|
|
673
798
|
step,
|
|
@@ -681,12 +806,13 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
681
806
|
batchIndex: 0,
|
|
682
807
|
batchSize: 1,
|
|
683
808
|
});
|
|
809
|
+
await commitToolResult(name, parsed, call, result, Math.max(0, toolEnd - toolStart));
|
|
684
810
|
continue;
|
|
685
811
|
}
|
|
686
812
|
let result;
|
|
687
|
-
// Repetition guard (opt-in via maxRepeatedCalls; unset = track-only
|
|
688
|
-
//
|
|
689
|
-
//
|
|
813
|
+
// Repetition guard (opt-in via maxRepeatedCalls; unset = track-only):
|
|
814
|
+
// a repeated signature skips execution and yields a guidance error;
|
|
815
|
+
// exhausted nudges stop hard.
|
|
690
816
|
const repSig = toolSignature(name, parsed);
|
|
691
817
|
const repNote = repGuard.note(repSig, name);
|
|
692
818
|
if (repNote.intervened) {
|
|
@@ -705,7 +831,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
705
831
|
batchIndex: 0,
|
|
706
832
|
batchSize: 1,
|
|
707
833
|
});
|
|
708
|
-
commitToolResult(name, parsed, call, guarded, 0);
|
|
834
|
+
await commitToolResult(name, parsed, call, guarded, 0);
|
|
709
835
|
continue;
|
|
710
836
|
}
|
|
711
837
|
const guarded = `Error: invalid call: ${repetitionFollowUp(repSig, repNote.consecutive)} Fix the approach and retry.`;
|
|
@@ -721,7 +847,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
721
847
|
batchIndex: 0,
|
|
722
848
|
batchSize: 1,
|
|
723
849
|
});
|
|
724
|
-
commitToolResult(name, parsed, call, guarded, 0);
|
|
850
|
+
await commitToolResult(name, parsed, call, guarded, 0);
|
|
725
851
|
const stopBase = msg.content ?? "";
|
|
726
852
|
const stopNotice = `${stopBase}${stopBase ? "\n" : ""}${repetitionStopNotice(repSig, repNote.consecutive)}`;
|
|
727
853
|
history.push({ role: "assistant", content: stopNotice });
|
|
@@ -779,11 +905,13 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
779
905
|
batchSize: 1,
|
|
780
906
|
});
|
|
781
907
|
}
|
|
782
|
-
commitToolResult(name, parsed, call, result, Math.max(0, Date.now() - toolStart));
|
|
908
|
+
await commitToolResult(name, parsed, call, result, Math.max(0, Date.now() - toolStart));
|
|
783
909
|
continue;
|
|
784
910
|
}
|
|
785
911
|
// Parallel batch: every member is pre-validated parallel-safe (see
|
|
786
|
-
// planToolBatches)
|
|
912
|
+
// planToolBatches). Approval-gated members (parallel writes) resolve
|
|
913
|
+
// their decisions serially in call order first, so prompts never run
|
|
914
|
+
// concurrently; denied members yield inline errors without executing.
|
|
787
915
|
// Phases fire upfront in call order; results commit in call order, so
|
|
788
916
|
// each call still shows separately and tool_call_ids re-pair by index.
|
|
789
917
|
// A throw (cancel or execution error) aborts the turn exactly like the
|
|
@@ -809,6 +937,21 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
809
937
|
repHardStop = { sig: note.signature, consecutive: note.consecutive };
|
|
810
938
|
}
|
|
811
939
|
}
|
|
940
|
+
// Serial approval pre-pass (in call order, skipping repetition-guarded
|
|
941
|
+
// members exactly as the serial path would): prompts resolve before
|
|
942
|
+
// any member executes, so concurrent writes never prompt at once.
|
|
943
|
+
// Cancel between prompts aborts the batch with nothing executed.
|
|
944
|
+
const preDecisions = new Map();
|
|
945
|
+
for (let i = 0; i < batch.length; i++) {
|
|
946
|
+
throwIfCancelled(signal);
|
|
947
|
+
if (repNotes[i].intervened)
|
|
948
|
+
continue;
|
|
949
|
+
const member = batch[i];
|
|
950
|
+
const memberName = member.call?.function?.name ?? "(unknown)";
|
|
951
|
+
const decision = await resolveApproval(memberName, member.parsed, opts);
|
|
952
|
+
if (decision !== null)
|
|
953
|
+
preDecisions.set(i, decision);
|
|
954
|
+
}
|
|
812
955
|
try {
|
|
813
956
|
// Each member is timed individually (concurrent wall-clock per call,
|
|
814
957
|
// not the whole batch attributed to each) and reported in call order
|
|
@@ -836,7 +979,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
836
979
|
return guarded;
|
|
837
980
|
}
|
|
838
981
|
try {
|
|
839
|
-
const r = await runOneTool(member.call, member.parsed, opts, execute);
|
|
982
|
+
const r = await runOneTool(member.call, member.parsed, opts, execute, preDecisions.get(index) ?? null);
|
|
840
983
|
const memberEnd = Date.now();
|
|
841
984
|
memberDurations[index] = Math.max(0, memberEnd - memberStart);
|
|
842
985
|
reportToolCall({
|
|
@@ -885,7 +1028,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
885
1028
|
}
|
|
886
1029
|
for (let i = 0; i < batch.length; i++) {
|
|
887
1030
|
const member = batch[i];
|
|
888
|
-
commitToolResult(member.call?.function?.name ?? "(unknown)", member.parsed, member.call, results[i], memberDurations[i]);
|
|
1031
|
+
await commitToolResult(member.call?.function?.name ?? "(unknown)", member.parsed, member.call, results[i], memberDurations[i]);
|
|
889
1032
|
}
|
|
890
1033
|
if (repHardStop) {
|
|
891
1034
|
const stopBase = msg.content ?? "";
|
package/dist/agent/normalize.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { truncateHead } from "../tools/shared.js";
|
|
1
2
|
// Safety net for custom executors (built-in tools already cap: read 64KB
|
|
2
3
|
// head + truncation note + overflow pointer ≈ 66KB, bash 8KB, webfetch 64KB
|
|
3
4
|
// + notes). The cap sits at 128KB so legitimate built-in outputs (overflow
|
|
@@ -27,8 +28,8 @@ export function normalizeToolResult(result) {
|
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
30
|
if (text.length > TOOL_RESULT_CAP_CHARS) {
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
const t = truncateHead(text, TOOL_RESULT_CAP_CHARS, `\n[truncated: tool result exceeded ${TOOL_RESULT_CAP_CHARS} chars]`);
|
|
32
|
+
return t.head + t.note;
|
|
32
33
|
}
|
|
33
34
|
return text;
|
|
34
35
|
}
|
|
@@ -92,6 +93,10 @@ export function normalizeChatResult(raw) {
|
|
|
92
93
|
result["usage"] = m["usage"];
|
|
93
94
|
if (m["reasoning"] !== undefined)
|
|
94
95
|
result["reasoning"] = m["reasoning"];
|
|
96
|
+
// Length-truncation flag survives normalization (no calls or not — the
|
|
97
|
+
// loop decides; truncated-without-calls behaves as before).
|
|
98
|
+
if (m["truncated"] === true)
|
|
99
|
+
result.truncated = true;
|
|
95
100
|
return { result: result, warnings };
|
|
96
101
|
}
|
|
97
102
|
if (!Array.isArray(callsRaw)) {
|
|
@@ -140,5 +145,7 @@ export function normalizeChatResult(raw) {
|
|
|
140
145
|
out["usage"] = m["usage"];
|
|
141
146
|
if (m["reasoning"] !== undefined)
|
|
142
147
|
out["reasoning"] = m["reasoning"];
|
|
148
|
+
if (m["truncated"] === true)
|
|
149
|
+
out.truncated = true;
|
|
143
150
|
return { result: out, warnings };
|
|
144
151
|
}
|