agent-nuvira 3.5.0 → 3.5.2
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/README.md +13 -13
- package/dist/agent-sdk/src/scaffold.d.ts +1 -1
- package/dist/agent-sdk/src/scaffold.js +1 -1
- package/dist/cli/chat.d.ts +159 -6
- package/dist/cli/chat.d.ts.map +1 -1
- package/dist/cli/chat.js +583 -35
- package/dist/cli/chat.js.map +1 -1
- package/dist/cli/duties.d.ts +11 -0
- package/dist/cli/duties.d.ts.map +1 -1
- package/dist/cli/duties.js +31 -0
- package/dist/cli/duties.js.map +1 -1
- package/dist/cli/edit.d.ts.map +1 -1
- package/dist/cli/edit.js +3 -2
- package/dist/cli/edit.js.map +1 -1
- package/dist/cli/execute.d.ts.map +1 -1
- package/dist/cli/execute.js +10 -3
- package/dist/cli/execute.js.map +1 -1
- package/dist/cli/loop-executor.d.ts +16 -0
- package/dist/cli/loop-executor.d.ts.map +1 -1
- package/dist/cli/loop-executor.js +9 -0
- package/dist/cli/loop-executor.js.map +1 -1
- package/dist/cli/models.d.ts.map +1 -1
- package/dist/cli/models.js +55 -0
- package/dist/cli/models.js.map +1 -1
- package/dist/cli/plan.d.ts.map +1 -1
- package/dist/cli/plan.js +18 -6
- package/dist/cli/plan.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +23 -8
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/ui/answer-view.d.ts +38 -0
- package/dist/cli/ui/answer-view.d.ts.map +1 -0
- package/dist/cli/ui/answer-view.js +72 -0
- package/dist/cli/ui/answer-view.js.map +1 -0
- package/dist/cli/ui/cards.d.ts +61 -0
- package/dist/cli/ui/cards.d.ts.map +1 -0
- package/dist/cli/ui/cards.js +141 -0
- package/dist/cli/ui/cards.js.map +1 -0
- package/dist/cli/ui/highlight.d.ts +32 -0
- package/dist/cli/ui/highlight.d.ts.map +1 -0
- package/dist/cli/ui/highlight.js +261 -0
- package/dist/cli/ui/highlight.js.map +1 -0
- package/dist/cli/ui/index.d.ts +21 -0
- package/dist/cli/ui/index.d.ts.map +1 -0
- package/dist/cli/ui/index.js +14 -0
- package/dist/cli/ui/index.js.map +1 -0
- package/dist/cli/ui/markdown.d.ts +90 -0
- package/dist/cli/ui/markdown.d.ts.map +1 -0
- package/dist/cli/ui/markdown.js +292 -0
- package/dist/cli/ui/markdown.js.map +1 -0
- package/dist/cli/ui/presenter.d.ts +147 -0
- package/dist/cli/ui/presenter.d.ts.map +1 -0
- package/dist/cli/ui/presenter.js +313 -0
- package/dist/cli/ui/presenter.js.map +1 -0
- package/dist/cli/ui/render.d.ts +52 -0
- package/dist/cli/ui/render.d.ts.map +1 -0
- package/dist/cli/ui/render.js +384 -0
- package/dist/cli/ui/render.js.map +1 -0
- package/dist/cli/ui/theme.d.ts +36 -0
- package/dist/cli/ui/theme.d.ts.map +1 -0
- package/dist/cli/ui/theme.js +92 -0
- package/dist/cli/ui/theme.js.map +1 -0
- package/dist/inference/model-entitlement.d.ts +10 -0
- package/dist/inference/model-entitlement.d.ts.map +1 -1
- package/dist/inference/model-entitlement.js +1 -1
- package/dist/inference/model-entitlement.js.map +1 -1
- package/dist/learning/auto-router.d.ts +38 -1
- package/dist/learning/auto-router.d.ts.map +1 -1
- package/dist/learning/auto-router.js +139 -8
- package/dist/learning/auto-router.js.map +1 -1
- package/dist/learning/duty-lease.d.ts +163 -0
- package/dist/learning/duty-lease.d.ts.map +1 -0
- package/dist/learning/duty-lease.js +301 -0
- package/dist/learning/duty-lease.js.map +1 -0
- package/dist/learning/model-first-router.d.ts +14 -0
- package/dist/learning/model-first-router.d.ts.map +1 -1
- package/dist/learning/model-first-router.js +54 -17
- package/dist/learning/model-first-router.js.map +1 -1
- package/dist/learning/model-warmup.d.ts +92 -0
- package/dist/learning/model-warmup.d.ts.map +1 -1
- package/dist/learning/model-warmup.js +137 -2
- package/dist/learning/model-warmup.js.map +1 -1
- package/dist/learning/reasoning-trace.d.ts +21 -1
- package/dist/learning/reasoning-trace.d.ts.map +1 -1
- package/dist/learning/reasoning-trace.js.map +1 -1
- package/dist/learning/resilient-call.d.ts.map +1 -1
- package/dist/learning/resilient-call.js +70 -4
- package/dist/learning/resilient-call.js.map +1 -1
- package/dist/learning/retry-patience.d.ts +139 -0
- package/dist/learning/retry-patience.d.ts.map +1 -0
- package/dist/learning/retry-patience.js +169 -0
- package/dist/learning/retry-patience.js.map +1 -0
- package/dist/tools/process-liveness.d.ts +227 -0
- package/dist/tools/process-liveness.d.ts.map +1 -0
- package/dist/tools/process-liveness.js +486 -0
- package/dist/tools/process-liveness.js.map +1 -0
- package/dist/tools/registry.d.ts.map +1 -1
- package/dist/tools/registry.js +4 -2
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/run-registry.d.ts +282 -0
- package/dist/tools/run-registry.d.ts.map +1 -0
- package/dist/tools/run-registry.js +544 -0
- package/dist/tools/run-registry.js.map +1 -0
- package/dist/tools/run-terminal.d.ts +100 -2
- package/dist/tools/run-terminal.d.ts.map +1 -1
- package/dist/tools/run-terminal.js +841 -67
- package/dist/tools/run-terminal.js.map +1 -1
- package/dist/tools/tool-loop.d.ts +87 -3
- package/dist/tools/tool-loop.d.ts.map +1 -1
- package/dist/tools/tool-loop.js +375 -6
- package/dist/tools/tool-loop.js.map +1 -1
- package/dist/web-dashboard/chat-console.d.ts +25 -0
- package/dist/web-dashboard/chat-console.d.ts.map +1 -1
- package/dist/web-dashboard/chat-console.js +16 -1
- package/dist/web-dashboard/chat-console.js.map +1 -1
- package/dist/web-dashboard/server.d.ts.map +1 -1
- package/dist/web-dashboard/server.js +7 -1
- package/dist/web-dashboard/server.js.map +1 -1
- package/dist/web-dashboard/src/types.d.ts +32 -1
- package/dist/web-dashboard/src/types.d.ts.map +1 -1
- package/dist/web-dashboard/task-registry.d.ts +98 -0
- package/dist/web-dashboard/task-registry.d.ts.map +1 -0
- package/dist/web-dashboard/task-registry.js +159 -0
- package/dist/web-dashboard/task-registry.js.map +1 -0
- package/dist/web-dashboard/task-runner.d.ts +155 -5
- package/dist/web-dashboard/task-runner.d.ts.map +1 -1
- package/dist/web-dashboard/task-runner.js +505 -14
- package/dist/web-dashboard/task-runner.js.map +1 -1
- package/package.json +4 -1
- package/src/web-dashboard/public/assets/index-DycGBWwy.js +236 -0
- package/src/web-dashboard/public/assets/index-DycGBWwy.js.map +1 -0
- package/src/web-dashboard/public/index.html +1 -1
- package/src/web-dashboard/public/assets/index-yky0dw5e.js +0 -236
- package/src/web-dashboard/public/assets/index-yky0dw5e.js.map +0 -1
package/dist/tools/tool-loop.js
CHANGED
|
@@ -32,7 +32,7 @@ import { runBeforeToolHooks, runToolOutcomeHooks, toolHookRefusalText, } from '.
|
|
|
32
32
|
import { stepDigest } from '../learning/step-checkpoint.js';
|
|
33
33
|
// P7 — the identical-command guard's memory. The loop drops it when a WRITE is
|
|
34
34
|
// applied, so a repaired project can re-run its own check (see the context below).
|
|
35
|
-
import { resetTerminalFailureStreaks } from './run-terminal.js';
|
|
35
|
+
import { RUN_TERMINAL_INCOMPLETE_PREFIX, resetTerminalFailureStreaks } from './run-terminal.js';
|
|
36
36
|
import { createRunCommandMemo } from './command-memo.js';
|
|
37
37
|
// WS6 (#28) — the declared fault seam. `faultAt` is a null check when this
|
|
38
38
|
// process declared no fault (`NUVIRA_INJECT_FAULT`), so an ordinary turn is
|
|
@@ -488,6 +488,13 @@ export const MAX_THINK_CONTINUES = 3;
|
|
|
488
488
|
* demonstrated it is not answering this step, and re-calling it is the loop that
|
|
489
489
|
* killed a real turn (five consecutive empties, then the turn reported itself
|
|
490
490
|
* bounded with zero tool calls).
|
|
491
|
+
*
|
|
492
|
+
* THIS IS THE HANDOFF TRIGGER, NOT THE WHOLE PATIENCE POLICY. Since P5 the caller
|
|
493
|
+
* may grant extra same-pair attempts (`emptyResponsePatienceMs`), spent as
|
|
494
|
+
* bounded waits BEFORE this threshold is honored — which is what tells a two
|
|
495
|
+
* second blink apart from a dead pair. The threshold itself is unchanged, so a
|
|
496
|
+
* caller that grants nothing, and every mock/sub-agent child, behaves exactly as
|
|
497
|
+
* before.
|
|
491
498
|
*/
|
|
492
499
|
export const MAX_SAME_MODEL_EMPTY_RETRIES = 1;
|
|
493
500
|
/**
|
|
@@ -569,6 +576,26 @@ export function isTransientGenerationFailure(message) {
|
|
|
569
576
|
function sleep(ms) {
|
|
570
577
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
571
578
|
}
|
|
579
|
+
/**
|
|
580
|
+
* Sleep that a CANCELLED turn interrupts.
|
|
581
|
+
*
|
|
582
|
+
* The blip patience below can wait ten seconds on a pair, and a 10s wait that
|
|
583
|
+
* ignores Esc is a 10s delay before the turn admits it was cancelled — the same
|
|
584
|
+
* defect `chat.ts`'s `abortableSleep` was written to fix. Sliced rather than
|
|
585
|
+
* event-driven because the loop only holds an `AbortSignal`, and at 250ms a slice
|
|
586
|
+
* the granularity is imperceptible next to the wait it is bounding.
|
|
587
|
+
*/
|
|
588
|
+
async function sleepUnlessAborted(ms, signal) {
|
|
589
|
+
const deadline = Date.now() + Math.max(0, ms);
|
|
590
|
+
for (;;) {
|
|
591
|
+
if (signal?.aborted)
|
|
592
|
+
return;
|
|
593
|
+
const remaining = deadline - Date.now();
|
|
594
|
+
if (remaining <= 0)
|
|
595
|
+
return;
|
|
596
|
+
await sleep(Math.min(250, remaining));
|
|
597
|
+
}
|
|
598
|
+
}
|
|
572
599
|
/**
|
|
573
600
|
* Run one tool-call turn:
|
|
574
601
|
* generate → execute tools → feed results back → repeat until the model
|
|
@@ -617,6 +644,12 @@ async function runToolLoopInner(opts, progress) {
|
|
|
617
644
|
// bounds how many times the turn may move.
|
|
618
645
|
let emptyRetriesOnCurrentModel = 0;
|
|
619
646
|
let modelHandoffs = 0;
|
|
647
|
+
// P5 — the empty-response patience plan for the CURRENT streak, asked for once
|
|
648
|
+
// (see `emptyResponsePatienceMs`) plus how many of its delays have been spent.
|
|
649
|
+
// `undefined` = not asked yet; an empty array = asked, and the caller granted
|
|
650
|
+
// nothing. Both are reset wherever the streak resets.
|
|
651
|
+
let emptyPatienceSchedule;
|
|
652
|
+
let emptyPatienceSpent = 0;
|
|
620
653
|
// P3 — the ATTEMPT budget (see MAX_HANDOFF_ATTEMPT_CREDIT): step credit
|
|
621
654
|
// granted by mid-turn handoffs, so a run against several dead models cannot
|
|
622
655
|
// exhaust its road before it has done any work.
|
|
@@ -1056,7 +1089,7 @@ async function runToolLoopInner(opts, progress) {
|
|
|
1056
1089
|
if (digest)
|
|
1057
1090
|
upsertWorkDigest(thread, digest);
|
|
1058
1091
|
}
|
|
1059
|
-
deps.onEvent?.(` ✂️ ${trimmedResult.trimmed}
|
|
1092
|
+
deps.onEvent?.(` ✂️ ${trimmedResult.trimmed} message(s) trimmed to fit the ${Math.round(budgetChars / 1000)}K-char context budget.`);
|
|
1060
1093
|
}
|
|
1061
1094
|
}
|
|
1062
1095
|
// ── The route feed, re-synced before every call ────────────────────────
|
|
@@ -1240,8 +1273,14 @@ async function runToolLoopInner(opts, progress) {
|
|
|
1240
1273
|
// P2 — the model just produced something usable (text, or at least one tool
|
|
1241
1274
|
// call), so its empty-response streak is over: a later blip starts a NEW
|
|
1242
1275
|
// streak rather than inheriting one from twenty steps ago.
|
|
1243
|
-
if (toolCalls.length > 0 || !responseThinkOnly)
|
|
1276
|
+
if (toolCalls.length > 0 || !responseThinkOnly) {
|
|
1244
1277
|
emptyRetriesOnCurrentModel = 0;
|
|
1278
|
+
// P5 — a usable response ends the blink, so the next streak asks for a
|
|
1279
|
+
// FRESH patience plan instead of inheriting spent patience from a step
|
|
1280
|
+
// twenty turns of the loop ago.
|
|
1281
|
+
emptyPatienceSchedule = undefined;
|
|
1282
|
+
emptyPatienceSpent = 0;
|
|
1283
|
+
}
|
|
1245
1284
|
if (toolCalls.length === 0) {
|
|
1246
1285
|
// No tools → end turn UNLESS the content is think-only (
|
|
1247
1286
|
// isThinkOnlyResponse: continue instead of ending).
|
|
@@ -1262,6 +1301,66 @@ async function runToolLoopInner(opts, progress) {
|
|
|
1262
1301
|
// is the exact class of defect this work exists to remove, so the two
|
|
1263
1302
|
// cases are named apart even though they are bounded together.
|
|
1264
1303
|
const emptyResponse = response.content.trim().length === 0;
|
|
1304
|
+
// ── P5 — WAIT OUT A BLINK ON THE PAIR THAT ANSWERED NOTHING ──────────
|
|
1305
|
+
//
|
|
1306
|
+
// An empty 200 is the one unusable response that never reaches the blip
|
|
1307
|
+
// policy: `chat.generateWithTransientRetry` applies the empty-response
|
|
1308
|
+
// schedule only when the attempt THROWS, and a provider that answers 200
|
|
1309
|
+
// with no content does not throw — it lands here. So the policy has to be
|
|
1310
|
+
// reachable from the loop too, or it is inert for exactly the failure it
|
|
1311
|
+
// was written for. Both traces that motivated this did precisely that:
|
|
1312
|
+
// two empties in a row, the loop's single retry spent, handoff, local last
|
|
1313
|
+
// resort, weak answer (trace-1791906998502-txddq4,
|
|
1314
|
+
// trace-1791907393457-7seewg).
|
|
1315
|
+
//
|
|
1316
|
+
// The caller decides how long to stay (see `emptyResponsePatienceMs`); the
|
|
1317
|
+
// loop memoizes that answer per streak so a shifting plan cannot extend the
|
|
1318
|
+
// wait, sleeps each granted delay, and re-asks the SAME pair. Cancelling
|
|
1319
|
+
// mid-wait is honored.
|
|
1320
|
+
if (emptyResponse && !opts.signal?.aborted) {
|
|
1321
|
+
if (emptyPatienceSchedule === undefined) {
|
|
1322
|
+
try {
|
|
1323
|
+
emptyPatienceSchedule = deps.emptyResponsePatienceMs?.({
|
|
1324
|
+
emptyStreak: emptyRetriesOnCurrentModel + 1,
|
|
1325
|
+
handoffs: modelHandoffs,
|
|
1326
|
+
deliveredWorkThisTurn: progress.successfulToolCalls.length > 0,
|
|
1327
|
+
}) ?? [];
|
|
1328
|
+
}
|
|
1329
|
+
catch {
|
|
1330
|
+
// A broken patience hook must neither extend nor block a turn.
|
|
1331
|
+
emptyPatienceSchedule = [];
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
if (emptyPatienceSpent < emptyPatienceSchedule.length) {
|
|
1335
|
+
const waitMs = emptyPatienceSchedule[emptyPatienceSpent++];
|
|
1336
|
+
emptyRetriesOnCurrentModel += 1;
|
|
1337
|
+
thinkContinues = 0;
|
|
1338
|
+
// Name the pair generically when the route is unknown — a wrong name
|
|
1339
|
+
// would be worse than none (see `servedRoute`).
|
|
1340
|
+
let pairLabel = 'the same model';
|
|
1341
|
+
try {
|
|
1342
|
+
const route = opts.servedRoute?.();
|
|
1343
|
+
if (route?.model && !isUnresolvedModel(route.model)) {
|
|
1344
|
+
pairLabel = `${route.providerType}/${route.model}`;
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
catch {
|
|
1348
|
+
// Unknown route reads generically.
|
|
1349
|
+
}
|
|
1350
|
+
if (waitMs > 0) {
|
|
1351
|
+
deps.onEvent?.(` 👀 ${pairLabel} returned nothing — waiting ${Math.round(waitMs / 100) / 10}s ` +
|
|
1352
|
+
`before re-asking it (blip patience ${emptyPatienceSpent}/${emptyPatienceSchedule.length}).`);
|
|
1353
|
+
await sleepUnlessAborted(waitMs, opts.signal);
|
|
1354
|
+
}
|
|
1355
|
+
traceEvent({
|
|
1356
|
+
kind: 'decision',
|
|
1357
|
+
gate: 'wait',
|
|
1358
|
+
summary: `the provider returned an empty response — waiting ${waitMs}ms and re-asking the SAME pair ` +
|
|
1359
|
+
`(blip patience ${emptyPatienceSpent}/${emptyPatienceSchedule.length}) instead of handing off`,
|
|
1360
|
+
});
|
|
1361
|
+
continue;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1265
1364
|
// ── P2 — MID-TURN MODEL HANDOFF ────────────────────────────────────
|
|
1266
1365
|
// An empty completion is a PROVIDER failure, not a state of mind, and
|
|
1267
1366
|
// re-asking the model that just returned nothing is what killed a real
|
|
@@ -1288,6 +1387,43 @@ async function runToolLoopInner(opts, progress) {
|
|
|
1288
1387
|
}
|
|
1289
1388
|
if (switched) {
|
|
1290
1389
|
modelHandoffs += 1;
|
|
1390
|
+
// ── CONTEXT HANDOFF NOTE (Proposal 2, reassessed) ─────────────
|
|
1391
|
+
//
|
|
1392
|
+
// The CONTEXT already travels: one `thread` is kept across the
|
|
1393
|
+
// switch, so the replacement receives the ask, every tool call and
|
|
1394
|
+
// every result. What deliberately does NOT travel is the previous
|
|
1395
|
+
// model's `reasoningContent` (replaying a stranger's chain-of-
|
|
1396
|
+
// thought is either rejected outright or anchors the new model to
|
|
1397
|
+
// the failed reading of the task) — that stays dropped.
|
|
1398
|
+
//
|
|
1399
|
+
// What was missing is the plain STATEMENT of the handoff: a model
|
|
1400
|
+
// that starts mid-thread with no note reads the transcript as its
|
|
1401
|
+
// own and re-plans from the ask, re-running finished commands and
|
|
1402
|
+
// re-reading files whose results are already in front of it. The
|
|
1403
|
+
// note names the change, tells it the context is its own, and hands
|
|
1404
|
+
// it the facts (files changed, commands with their verdicts) so it
|
|
1405
|
+
// continues instead of restarting.
|
|
1406
|
+
let servedNow;
|
|
1407
|
+
try {
|
|
1408
|
+
const route = opts.servedRoute?.();
|
|
1409
|
+
if (route?.model && !isUnresolvedModel(route.model)) {
|
|
1410
|
+
servedNow = `${route.providerType}/${route.model}`;
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
catch {
|
|
1414
|
+
// An unknown route is fine — the note reads generically then.
|
|
1415
|
+
}
|
|
1416
|
+
upsertHandoffNote(thread, buildHandoffNote({
|
|
1417
|
+
route: servedNow,
|
|
1418
|
+
facts: isWorkDigestEnabled(context.configManager)
|
|
1419
|
+
? buildWorkDigest({
|
|
1420
|
+
successfulTools: progress.successfulToolCalls,
|
|
1421
|
+
mutatedPaths: progress.mutatedPaths,
|
|
1422
|
+
executedActions: progress.executedActions,
|
|
1423
|
+
})
|
|
1424
|
+
: '',
|
|
1425
|
+
}));
|
|
1426
|
+
deps.onEvent?.(' 🤝 handoff note written into the thread — the replacement model starts from the work already done.');
|
|
1291
1427
|
// ── C5 — RE-FIT THE THREAD TO THE MODEL THAT NOW ANSWERS ──────
|
|
1292
1428
|
// `budgetChars` was resolved before this loop started, from the pair
|
|
1293
1429
|
// the turn BEGAN on — and the handoff worth catching is the one to a
|
|
@@ -1311,6 +1447,10 @@ async function runToolLoopInner(opts, progress) {
|
|
|
1311
1447
|
// The streak and the think streak belong to the model that failed.
|
|
1312
1448
|
emptyRetriesOnCurrentModel = 0;
|
|
1313
1449
|
thinkContinues = 0;
|
|
1450
|
+
// P5 — the replacement pair gets its OWN plan, not the one granted to
|
|
1451
|
+
// (and half-spent on) the model that just returned nothing.
|
|
1452
|
+
emptyPatienceSchedule = undefined;
|
|
1453
|
+
emptyPatienceSpent = 0;
|
|
1314
1454
|
// ── DROP THE FAILED MODEL'S PRIVATE REASONING ─────────────────
|
|
1315
1455
|
// Every assistant turn records `reasoningContent`, and the wire
|
|
1316
1456
|
// layer echoes it back on that assistant message — which is
|
|
@@ -2091,7 +2231,17 @@ async function runToolLoopInner(opts, progress) {
|
|
|
2091
2231
|
// outcome reading the same as its opposite — so the refusal classifier is
|
|
2092
2232
|
// now the authority for both the event KIND and the success verdict.
|
|
2093
2233
|
const refusal = classifyToolRefusal(rawResult);
|
|
2234
|
+
// A command that outlived its wait budget is STILL RUNNING under
|
|
2235
|
+
// monitoring: it has not verified anything yet, and it has not failed
|
|
2236
|
+
// either. Neither verdict may be recorded for it — `successfulToolCalls`
|
|
2237
|
+
// feeds the "did you check it" gate and the honest accounting, so counting
|
|
2238
|
+
// a live run as a success is how a turn claims "the tests pass" about a
|
|
2239
|
+
// suite still on its first file. The command's real verdict arrives when
|
|
2240
|
+
// it lands, via `run_terminal(action:'poll')` — which is a normal success
|
|
2241
|
+
// or `Error:` result and is accounted for like any other.
|
|
2242
|
+
const stillRunning = rawResult.startsWith(RUN_TERMINAL_INCOMPLETE_PREFIX);
|
|
2094
2243
|
const ranOk = refusal === null &&
|
|
2244
|
+
!stillRunning &&
|
|
2095
2245
|
!rawResult.startsWith('Error:') &&
|
|
2096
2246
|
(!DELIVERY_TOOL_NAMES.has(call.name) || deliveryResultSucceeded(rawResult));
|
|
2097
2247
|
if (ranOk) {
|
|
@@ -3771,16 +3921,127 @@ export const DEFAULT_THREAD_BUDGET_CHARS = 200_000;
|
|
|
3771
3921
|
const RECENT_KEEP = 6;
|
|
3772
3922
|
/** Stub left in place of a trimmed tool result. */
|
|
3773
3923
|
const TRIM_STUB = '[earlier tool result trimmed to fit the context budget]';
|
|
3924
|
+
/**
|
|
3925
|
+
* Floor for the per-message tool-result clamp (the tail clamp). A single tool
|
|
3926
|
+
* result may never exceed half the thread budget, so one oversized output cannot
|
|
3927
|
+
* dominate the prompt; this floor keeps the clamp sane for a tiny budget.
|
|
3928
|
+
*/
|
|
3929
|
+
const MIN_TOOL_RESULT_CHARS = 8_000;
|
|
3930
|
+
/**
|
|
3931
|
+
* Bounded scan window for the clamp's error-line rescue. An elided middle can be
|
|
3932
|
+
* megabytes, and scanning all of it would cost more CPU than the prompt costs
|
|
3933
|
+
* tokens, so only the EDGES of the middle are searched. Nothing here is
|
|
3934
|
+
* unrecoverable: a clipped result is re-runnable (`run_terminal` again) or
|
|
3935
|
+
* re-readable (`read_file` with offset/limit), which the notice says out loud.
|
|
3936
|
+
*/
|
|
3937
|
+
const CLAMP_SCAN_CHARS = 250_000;
|
|
3938
|
+
/** Cap on rescued error-looking lines, and on the chars they may add back. */
|
|
3939
|
+
const CLAMP_ERROR_LINES = 12;
|
|
3940
|
+
const CLAMP_ERROR_CHARS = 2_000;
|
|
3941
|
+
/**
|
|
3942
|
+
* A line that looks like the REASON something failed. Deliberately broad: one
|
|
3943
|
+
* extra line costs a few tokens, while dropping the only line that says WHY the
|
|
3944
|
+
* build broke costs a wrong fix built on a log the model never saw.
|
|
3945
|
+
*/
|
|
3946
|
+
const ERROR_LINE_RE = /\b(?:error|errors|fail|failed|failing|failure|exception|panic|fatal|traceback|assert|assertion|expected|received|refused|denied|unhandled|cannot|unable|timeout|enoent|econnrefused)\b|\btimed out\b|\bexit(?:ed)?(?: with)?(?: code)? [1-9]\d*\b|[✗✘✕]/i;
|
|
3947
|
+
/** `5234567` → `5,234,567` (deterministic grouping — no locale/ICU dependence). */
|
|
3948
|
+
function withThousands(n) {
|
|
3949
|
+
return String(Math.max(0, Math.trunc(n))).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
3950
|
+
}
|
|
3774
3951
|
/** Estimated character size of one thread message (chars/4 ≈ tokens). */
|
|
3775
3952
|
function messageChars(m) {
|
|
3776
3953
|
return (m.content?.length ?? 0) + (m.toolCalls?.length ? JSON.stringify(m.toolCalls).length : 0);
|
|
3777
3954
|
}
|
|
3955
|
+
/**
|
|
3956
|
+
* Rescue the error-looking lines from the MIDDLE of a result that is about to be
|
|
3957
|
+
* clamped away. The clamp keeps head + tail, and a tool's verdict usually lands
|
|
3958
|
+
* in the tail — but the REASON for a failure (an assertion, a stack frame, `exit
|
|
3959
|
+
* code 1`) can sit in the middle, and a model that never sees it fixes the wrong
|
|
3960
|
+
* thing. Deterministic: a line filter, no LLM, no summary, no drift.
|
|
3961
|
+
*/
|
|
3962
|
+
function errorLinesFrom(middle) {
|
|
3963
|
+
const regions = middle.length <= CLAMP_SCAN_CHARS * 2
|
|
3964
|
+
? [middle]
|
|
3965
|
+
: [middle.slice(0, CLAMP_SCAN_CHARS), middle.slice(-CLAMP_SCAN_CHARS)];
|
|
3966
|
+
const out = [];
|
|
3967
|
+
let used = 0;
|
|
3968
|
+
for (const region of regions) {
|
|
3969
|
+
for (const raw of region.split('\n')) {
|
|
3970
|
+
const line = raw.trim();
|
|
3971
|
+
if (!line)
|
|
3972
|
+
continue;
|
|
3973
|
+
// NO length filter. A tool's output rarely ends on a newline, so the tail
|
|
3974
|
+
// cut lands MID-LINE and fuses the last real line with the kept tail —
|
|
3975
|
+
// MEASURED: a 38-char `AssertionError: 2 !== 3 (received 3)` arrived here
|
|
3976
|
+
// as 1,036 chars, and a `> 1000` skip dropped it, i.e. the rescue silently
|
|
3977
|
+
// lost exactly the line it exists to save. Long lines are kept by their
|
|
3978
|
+
// first 200 chars instead (an error keyword in a minified blob is still
|
|
3979
|
+
// the start of the reason).
|
|
3980
|
+
if (!ERROR_LINE_RE.test(line))
|
|
3981
|
+
continue;
|
|
3982
|
+
const kept = line.slice(0, 200);
|
|
3983
|
+
if (out.includes(kept))
|
|
3984
|
+
continue;
|
|
3985
|
+
if (used + kept.length > CLAMP_ERROR_CHARS)
|
|
3986
|
+
return out;
|
|
3987
|
+
out.push(kept);
|
|
3988
|
+
used += kept.length;
|
|
3989
|
+
if (out.length >= CLAMP_ERROR_LINES)
|
|
3990
|
+
return out;
|
|
3991
|
+
}
|
|
3992
|
+
}
|
|
3993
|
+
return out;
|
|
3994
|
+
}
|
|
3995
|
+
/**
|
|
3996
|
+
* The notice a clamp leaves behind: it names the tool, counts the elided
|
|
3997
|
+
* characters and says how to get them back. A model handed a SILENTLY partial
|
|
3998
|
+
* result cannot tell whether anything is missing; one handed a QUANTIFIED cut
|
|
3999
|
+
* can re-run the command or re-read the file — far cheaper than a fix built on a
|
|
4000
|
+
* log it never saw.
|
|
4001
|
+
*/
|
|
4002
|
+
function clampNotice(tool, elided, errorLines) {
|
|
4003
|
+
const who = tool ? `${tool}: ` : '';
|
|
4004
|
+
const rescued = errorLines.length > 0 ? '; the error-looking lines from the elided middle are kept below' : '';
|
|
4005
|
+
return (`\n[tool result trimmed to fit the context budget — ${who}${withThousands(elided)} chars elided ` +
|
|
4006
|
+
`from the middle${rescued}. The beginning and the end are kept verbatim; re-run the command, or ` +
|
|
4007
|
+
`read the file again with offset/limit, to see the rest.]\n` +
|
|
4008
|
+
(errorLines.length > 0 ? `${errorLines.join('\n')}\n` : ''));
|
|
4009
|
+
}
|
|
4010
|
+
/**
|
|
4011
|
+
* Clamp one oversized tool result to `cap` chars, keeping its HEAD and TAIL so
|
|
4012
|
+
* the model still sees which tool ran and how its output ended, plus any
|
|
4013
|
+
* error-looking lines from the elided middle. Deterministic — no summary, no
|
|
4014
|
+
* model call. The result never exceeds `cap`: the head absorbs what the notice
|
|
4015
|
+
* and the rescued lines do not use.
|
|
4016
|
+
*/
|
|
4017
|
+
function clampToolResult(content, cap, tool) {
|
|
4018
|
+
if (content.length <= cap)
|
|
4019
|
+
return content;
|
|
4020
|
+
const tailLen = Math.min(1_000, Math.floor(cap / 4));
|
|
4021
|
+
const tailStart = Math.max(0, content.length - tailLen);
|
|
4022
|
+
const middle = content.slice(0, tailStart);
|
|
4023
|
+
const errors = errorLinesFrom(middle);
|
|
4024
|
+
// Sized with the LARGEST possible elided count, so the exact (smaller) notice
|
|
4025
|
+
// that follows can never push the result past `cap`.
|
|
4026
|
+
const headLen = Math.max(0, cap - tailLen - clampNotice(tool, middle.length, errors).length);
|
|
4027
|
+
const head = content.slice(0, headLen);
|
|
4028
|
+
return head + clampNotice(tool, Math.max(0, middle.length - headLen), errors) + content.slice(tailStart);
|
|
4029
|
+
}
|
|
3778
4030
|
/**
|
|
3779
4031
|
* Deterministically trim a thread to `maxChars`:
|
|
3780
4032
|
* 1. Never touch the system prompt or the FIRST user message (the ask).
|
|
3781
|
-
* 2. Never touch the last `RECENT_KEEP` messages (recent context)
|
|
3782
|
-
*
|
|
3783
|
-
*
|
|
4033
|
+
* 2. Never touch the last `RECENT_KEEP` messages (recent context) — except for
|
|
4034
|
+
* the tail clamps in steps 4–5, which run ONLY when the oldest-first passes
|
|
4035
|
+
* could not pay for the overage.
|
|
4036
|
+
* 3. Oldest-first pays FIRST, and only as much as the deficit needs: tool
|
|
4037
|
+
* results longer than 500 chars collapse to the stub; if still over budget,
|
|
4038
|
+
* remaining old tool results collapse entirely; then oversized old user
|
|
4039
|
+
* messages shrink.
|
|
4040
|
+
* 4. Only if the thread is STILL over does the protected tail pay: an oversized
|
|
4041
|
+
* recent tool result is clamped to head + rescued error lines + tail, and
|
|
4042
|
+
* then clamped harder, so the prompt actually fits the window it goes to.
|
|
4043
|
+
* The ORDER is what protects delivery: recent context is the model's working
|
|
4044
|
+
* memory, so it is spent only when nothing older can cover the overage.
|
|
3784
4045
|
* Returns a NEW array (input untouched) + how many messages were trimmed.
|
|
3785
4046
|
*/
|
|
3786
4047
|
export function trimThreadBudget(thread, maxChars = DEFAULT_THREAD_BUDGET_CHARS) {
|
|
@@ -3801,6 +4062,9 @@ export function trimThreadBudget(thread, maxChars = DEFAULT_THREAD_BUDGET_CHARS)
|
|
|
3801
4062
|
}
|
|
3802
4063
|
let over = total - maxChars;
|
|
3803
4064
|
let trimmed = 0;
|
|
4065
|
+
// A single result may never exceed half the budget, whatever the passes below
|
|
4066
|
+
// do (see MIN_TOOL_RESULT_CHARS).
|
|
4067
|
+
const perMessageCap = Math.max(MIN_TOOL_RESULT_CHARS, Math.floor(maxChars / 2));
|
|
3804
4068
|
// Pass 1: collapse long OLD tool results to the stub (keep the first 500
|
|
3805
4069
|
// chars so the model retains the gist of what it did).
|
|
3806
4070
|
for (const i of eligible) {
|
|
@@ -3848,6 +4112,66 @@ export function trimThreadBudget(thread, maxChars = DEFAULT_THREAD_BUDGET_CHARS)
|
|
|
3848
4112
|
trimmed++;
|
|
3849
4113
|
}
|
|
3850
4114
|
}
|
|
4115
|
+
// ── Pass 4 — only NOW does the PROTECTED TAIL pay ─────────────────────────
|
|
4116
|
+
// Nothing older could cover the deficit, so an oversized RECENT tool result
|
|
4117
|
+
// must be clamped — otherwise the prompt goes out verbatim and either the
|
|
4118
|
+
// provider rejects it or the model silently loses its middle.
|
|
4119
|
+
//
|
|
4120
|
+
// MEASURED (trace-1791879575576-x731yp): step 4 ran a 24s test command; its
|
|
4121
|
+
// result landed in the tail, the oldest-first passes could not reach it, and
|
|
4122
|
+
// step 5 — handed to `local/qwen2.5:0.5b` (32K window) — recorded 5,292,237
|
|
4123
|
+
// context chars (~1.18M tokens), carried into the dashboard and cost ledger
|
|
4124
|
+
// as REAL input. Running this pass here (rather than before the oldest-first
|
|
4125
|
+
// passes) is deliberate: recent context is the model's working memory, so it
|
|
4126
|
+
// is spent only when older output cannot pay for the overage.
|
|
4127
|
+
// A tool message carries only the call ID, so the notice's tool name comes
|
|
4128
|
+
// from the assistant message that made the call. Built lazily: a turn whose
|
|
4129
|
+
// thread fits never walks the thread for names it will not print.
|
|
4130
|
+
const toolNameById = new Map();
|
|
4131
|
+
const toolNameOf = (m) => {
|
|
4132
|
+
if (!m.toolCallId)
|
|
4133
|
+
return undefined;
|
|
4134
|
+
if (toolNameById.size === 0) {
|
|
4135
|
+
for (const t of thread) {
|
|
4136
|
+
for (const call of t.toolCalls ?? []) {
|
|
4137
|
+
if (call?.id)
|
|
4138
|
+
toolNameById.set(call.id, call.name);
|
|
4139
|
+
}
|
|
4140
|
+
}
|
|
4141
|
+
}
|
|
4142
|
+
return toolNameById.get(m.toolCallId);
|
|
4143
|
+
};
|
|
4144
|
+
if (over > 0) {
|
|
4145
|
+
for (let i = Math.max(0, out.length - RECENT_KEEP); i < out.length && over > 0; i++) {
|
|
4146
|
+
const m = out[i];
|
|
4147
|
+
if (m.role !== 'tool' || m.content.length <= perMessageCap)
|
|
4148
|
+
continue;
|
|
4149
|
+
const clamped = clampToolResult(m.content, perMessageCap, toolNameOf(m));
|
|
4150
|
+
if (clamped.length >= m.content.length)
|
|
4151
|
+
continue;
|
|
4152
|
+
out[i] = { ...m, content: clamped };
|
|
4153
|
+
over -= m.content.length - clamped.length;
|
|
4154
|
+
trimmed++;
|
|
4155
|
+
}
|
|
4156
|
+
}
|
|
4157
|
+
// Pass 5: STILL over — the protected tail holds the bulk even after the clamp
|
|
4158
|
+
// above; clamp its tool results harder rather than ship a prompt the model's
|
|
4159
|
+
// window cannot hold. Only TOOL results in the tail are touched — the user's
|
|
4160
|
+
// ask and the latest assistant turn stay.
|
|
4161
|
+
if (over > 0) {
|
|
4162
|
+
const tailFloor = Math.max(500, Math.floor(perMessageCap / 4));
|
|
4163
|
+
for (let i = Math.max(0, out.length - RECENT_KEEP); i < out.length && over > 0; i++) {
|
|
4164
|
+
const m = out[i];
|
|
4165
|
+
if (m.role !== 'tool')
|
|
4166
|
+
continue;
|
|
4167
|
+
const clamped = clampToolResult(m.content, tailFloor, toolNameOf(m));
|
|
4168
|
+
if (clamped.length >= m.content.length)
|
|
4169
|
+
continue;
|
|
4170
|
+
out[i] = { ...m, content: clamped };
|
|
4171
|
+
over -= m.content.length - clamped.length;
|
|
4172
|
+
trimmed++;
|
|
4173
|
+
}
|
|
4174
|
+
}
|
|
3851
4175
|
return { thread: out, trimmed };
|
|
3852
4176
|
}
|
|
3853
4177
|
// ─── Within-turn work digest (the memory a trimmed thread keeps) ────────────
|
|
@@ -3895,6 +4219,51 @@ export function buildWorkDigest(input) {
|
|
|
3895
4219
|
return '';
|
|
3896
4220
|
return `${WORK_DIGEST_MARKER}\n${lines.join('\n')}`;
|
|
3897
4221
|
}
|
|
4222
|
+
/** Marker prefix identifying the loop's mid-turn HANDOFF note. */
|
|
4223
|
+
export const HANDOFF_NOTE_MARKER = '[handoff — the model serving this turn changed]';
|
|
4224
|
+
/**
|
|
4225
|
+
* The note a mid-turn model switch leaves in the thread.
|
|
4226
|
+
*
|
|
4227
|
+
* It says three things the TRANSCRIPT cannot say by itself: that the pair
|
|
4228
|
+
* answering this step is not the pair that answered the previous ones, that the
|
|
4229
|
+
* context above is genuinely this model's to use (nothing was restarted), and
|
|
4230
|
+
* which work is already DONE (files changed, commands and their verdicts) — so
|
|
4231
|
+
* the replacement continues instead of re-deriving a plan from the ask. It also
|
|
4232
|
+
* states the rule that costs real steps when broken: do not re-run finished
|
|
4233
|
+
* commands or restate the work in more words.
|
|
4234
|
+
*
|
|
4235
|
+
* Deterministic and LLM-free, like the digest it borrows its facts from — a
|
|
4236
|
+
* handoff must not spend a model call to describe itself.
|
|
4237
|
+
*/
|
|
4238
|
+
export function buildHandoffNote(input) {
|
|
4239
|
+
const served = input.route
|
|
4240
|
+
? ` This step is served by ${input.route}.`
|
|
4241
|
+
: ' This step is served by a different model.';
|
|
4242
|
+
const facts = (input.facts ?? '').trim();
|
|
4243
|
+
return (`${HANDOFF_NOTE_MARKER}${served}\n` +
|
|
4244
|
+
'The pair that had been answering returned nothing usable, so the turn was handed to you mid-flight —\n' +
|
|
4245
|
+
'everything above is YOUR context: the ask, every tool call and every result. Nothing was restarted.\n' +
|
|
4246
|
+
(facts ? `${facts}\n` : '') +
|
|
4247
|
+
'Continue from the work that is already done. Do not re-run finished commands, re-read files whose results\n' +
|
|
4248
|
+
'are above, or restate the work in more words.');
|
|
4249
|
+
}
|
|
4250
|
+
/**
|
|
4251
|
+
* Insert or refresh the single handoff note. Idempotent — a turn may hand off
|
|
4252
|
+
* more than once (a second pair can also answer with nothing), and stacking a
|
|
4253
|
+
* note per handoff would fill the thread with the same information. Kept with
|
|
4254
|
+
* the run's framing (right after the leading system prompt) so it is a `system`
|
|
4255
|
+
* message: the thread-budget passes never trim one, and it therefore survives
|
|
4256
|
+
* every compaction between the handoff and the answer.
|
|
4257
|
+
*/
|
|
4258
|
+
export function upsertHandoffNote(thread, note) {
|
|
4259
|
+
const existing = thread.findIndex((m) => m.content.startsWith(HANDOFF_NOTE_MARKER));
|
|
4260
|
+
if (existing !== -1) {
|
|
4261
|
+
thread[existing] = { ...thread[existing], content: note };
|
|
4262
|
+
return;
|
|
4263
|
+
}
|
|
4264
|
+
const insertAt = thread.length > 0 && thread[0].role === 'system' ? 1 : 0;
|
|
4265
|
+
thread.splice(insertAt, 0, { role: 'system', content: note });
|
|
4266
|
+
}
|
|
3898
4267
|
/**
|
|
3899
4268
|
* Insert or refresh the single work-digest message. Idempotent: a digest already
|
|
3900
4269
|
* in the thread is UPDATED in place, never stacked, so repeated compaction in a
|