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.
Files changed (134) hide show
  1. package/README.md +13 -13
  2. package/dist/agent-sdk/src/scaffold.d.ts +1 -1
  3. package/dist/agent-sdk/src/scaffold.js +1 -1
  4. package/dist/cli/chat.d.ts +159 -6
  5. package/dist/cli/chat.d.ts.map +1 -1
  6. package/dist/cli/chat.js +583 -35
  7. package/dist/cli/chat.js.map +1 -1
  8. package/dist/cli/duties.d.ts +11 -0
  9. package/dist/cli/duties.d.ts.map +1 -1
  10. package/dist/cli/duties.js +31 -0
  11. package/dist/cli/duties.js.map +1 -1
  12. package/dist/cli/edit.d.ts.map +1 -1
  13. package/dist/cli/edit.js +3 -2
  14. package/dist/cli/edit.js.map +1 -1
  15. package/dist/cli/execute.d.ts.map +1 -1
  16. package/dist/cli/execute.js +10 -3
  17. package/dist/cli/execute.js.map +1 -1
  18. package/dist/cli/loop-executor.d.ts +16 -0
  19. package/dist/cli/loop-executor.d.ts.map +1 -1
  20. package/dist/cli/loop-executor.js +9 -0
  21. package/dist/cli/loop-executor.js.map +1 -1
  22. package/dist/cli/models.d.ts.map +1 -1
  23. package/dist/cli/models.js +55 -0
  24. package/dist/cli/models.js.map +1 -1
  25. package/dist/cli/plan.d.ts.map +1 -1
  26. package/dist/cli/plan.js +18 -6
  27. package/dist/cli/plan.js.map +1 -1
  28. package/dist/cli/run.d.ts.map +1 -1
  29. package/dist/cli/run.js +23 -8
  30. package/dist/cli/run.js.map +1 -1
  31. package/dist/cli/ui/answer-view.d.ts +38 -0
  32. package/dist/cli/ui/answer-view.d.ts.map +1 -0
  33. package/dist/cli/ui/answer-view.js +72 -0
  34. package/dist/cli/ui/answer-view.js.map +1 -0
  35. package/dist/cli/ui/cards.d.ts +61 -0
  36. package/dist/cli/ui/cards.d.ts.map +1 -0
  37. package/dist/cli/ui/cards.js +141 -0
  38. package/dist/cli/ui/cards.js.map +1 -0
  39. package/dist/cli/ui/highlight.d.ts +32 -0
  40. package/dist/cli/ui/highlight.d.ts.map +1 -0
  41. package/dist/cli/ui/highlight.js +261 -0
  42. package/dist/cli/ui/highlight.js.map +1 -0
  43. package/dist/cli/ui/index.d.ts +21 -0
  44. package/dist/cli/ui/index.d.ts.map +1 -0
  45. package/dist/cli/ui/index.js +14 -0
  46. package/dist/cli/ui/index.js.map +1 -0
  47. package/dist/cli/ui/markdown.d.ts +90 -0
  48. package/dist/cli/ui/markdown.d.ts.map +1 -0
  49. package/dist/cli/ui/markdown.js +292 -0
  50. package/dist/cli/ui/markdown.js.map +1 -0
  51. package/dist/cli/ui/presenter.d.ts +147 -0
  52. package/dist/cli/ui/presenter.d.ts.map +1 -0
  53. package/dist/cli/ui/presenter.js +313 -0
  54. package/dist/cli/ui/presenter.js.map +1 -0
  55. package/dist/cli/ui/render.d.ts +52 -0
  56. package/dist/cli/ui/render.d.ts.map +1 -0
  57. package/dist/cli/ui/render.js +384 -0
  58. package/dist/cli/ui/render.js.map +1 -0
  59. package/dist/cli/ui/theme.d.ts +36 -0
  60. package/dist/cli/ui/theme.d.ts.map +1 -0
  61. package/dist/cli/ui/theme.js +92 -0
  62. package/dist/cli/ui/theme.js.map +1 -0
  63. package/dist/inference/model-entitlement.d.ts +10 -0
  64. package/dist/inference/model-entitlement.d.ts.map +1 -1
  65. package/dist/inference/model-entitlement.js +1 -1
  66. package/dist/inference/model-entitlement.js.map +1 -1
  67. package/dist/learning/auto-router.d.ts +38 -1
  68. package/dist/learning/auto-router.d.ts.map +1 -1
  69. package/dist/learning/auto-router.js +139 -8
  70. package/dist/learning/auto-router.js.map +1 -1
  71. package/dist/learning/duty-lease.d.ts +163 -0
  72. package/dist/learning/duty-lease.d.ts.map +1 -0
  73. package/dist/learning/duty-lease.js +301 -0
  74. package/dist/learning/duty-lease.js.map +1 -0
  75. package/dist/learning/model-first-router.d.ts +14 -0
  76. package/dist/learning/model-first-router.d.ts.map +1 -1
  77. package/dist/learning/model-first-router.js +54 -17
  78. package/dist/learning/model-first-router.js.map +1 -1
  79. package/dist/learning/model-warmup.d.ts +92 -0
  80. package/dist/learning/model-warmup.d.ts.map +1 -1
  81. package/dist/learning/model-warmup.js +137 -2
  82. package/dist/learning/model-warmup.js.map +1 -1
  83. package/dist/learning/reasoning-trace.d.ts +21 -1
  84. package/dist/learning/reasoning-trace.d.ts.map +1 -1
  85. package/dist/learning/reasoning-trace.js.map +1 -1
  86. package/dist/learning/resilient-call.d.ts.map +1 -1
  87. package/dist/learning/resilient-call.js +70 -4
  88. package/dist/learning/resilient-call.js.map +1 -1
  89. package/dist/learning/retry-patience.d.ts +139 -0
  90. package/dist/learning/retry-patience.d.ts.map +1 -0
  91. package/dist/learning/retry-patience.js +169 -0
  92. package/dist/learning/retry-patience.js.map +1 -0
  93. package/dist/tools/process-liveness.d.ts +227 -0
  94. package/dist/tools/process-liveness.d.ts.map +1 -0
  95. package/dist/tools/process-liveness.js +486 -0
  96. package/dist/tools/process-liveness.js.map +1 -0
  97. package/dist/tools/registry.d.ts.map +1 -1
  98. package/dist/tools/registry.js +4 -2
  99. package/dist/tools/registry.js.map +1 -1
  100. package/dist/tools/run-registry.d.ts +282 -0
  101. package/dist/tools/run-registry.d.ts.map +1 -0
  102. package/dist/tools/run-registry.js +544 -0
  103. package/dist/tools/run-registry.js.map +1 -0
  104. package/dist/tools/run-terminal.d.ts +100 -2
  105. package/dist/tools/run-terminal.d.ts.map +1 -1
  106. package/dist/tools/run-terminal.js +841 -67
  107. package/dist/tools/run-terminal.js.map +1 -1
  108. package/dist/tools/tool-loop.d.ts +87 -3
  109. package/dist/tools/tool-loop.d.ts.map +1 -1
  110. package/dist/tools/tool-loop.js +375 -6
  111. package/dist/tools/tool-loop.js.map +1 -1
  112. package/dist/web-dashboard/chat-console.d.ts +25 -0
  113. package/dist/web-dashboard/chat-console.d.ts.map +1 -1
  114. package/dist/web-dashboard/chat-console.js +16 -1
  115. package/dist/web-dashboard/chat-console.js.map +1 -1
  116. package/dist/web-dashboard/server.d.ts.map +1 -1
  117. package/dist/web-dashboard/server.js +7 -1
  118. package/dist/web-dashboard/server.js.map +1 -1
  119. package/dist/web-dashboard/src/types.d.ts +32 -1
  120. package/dist/web-dashboard/src/types.d.ts.map +1 -1
  121. package/dist/web-dashboard/task-registry.d.ts +98 -0
  122. package/dist/web-dashboard/task-registry.d.ts.map +1 -0
  123. package/dist/web-dashboard/task-registry.js +159 -0
  124. package/dist/web-dashboard/task-registry.js.map +1 -0
  125. package/dist/web-dashboard/task-runner.d.ts +155 -5
  126. package/dist/web-dashboard/task-runner.d.ts.map +1 -1
  127. package/dist/web-dashboard/task-runner.js +505 -14
  128. package/dist/web-dashboard/task-runner.js.map +1 -1
  129. package/package.json +4 -1
  130. package/src/web-dashboard/public/assets/index-DycGBWwy.js +236 -0
  131. package/src/web-dashboard/public/assets/index-DycGBWwy.js.map +1 -0
  132. package/src/web-dashboard/public/index.html +1 -1
  133. package/src/web-dashboard/public/assets/index-yky0dw5e.js +0 -236
  134. package/src/web-dashboard/public/assets/index-yky0dw5e.js.map +0 -1
@@ -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} old tool result(s) trimmed to fit the ${Math.round(budgetChars / 1000)}K-char context budget.`);
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
- * 3. Oldest-first: tool results longer than 500 chars collapse to the stub;
3783
- * if still over budget, remaining old tool results collapse entirely.
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