@nexrall/code-core 1.4.26 → 1.4.28

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.
@@ -1,4 +1,47 @@
1
1
  import type { Message, AgentLoopOptions, EnvContext } from '../types';
2
+ /**
3
+ * Fingerprint one round's tool failures, for the repeated-failure runaway guard.
4
+ *
5
+ * Exported (with the limits) purely as a test seam: the guard's whole value is in the
6
+ * edge cases — that a DIFFERENT error each round must NOT trip it, that call order
7
+ * within a round is irrelevant, that a long error body doesn't make every occurrence
8
+ * look unique — and none of that is reachable without driving a live model loop.
9
+ *
10
+ * Sorted so parallel tool calls completing in a different order still compare equal;
11
+ * truncated because errors often embed a varying path or timestamp late in the string.
12
+ */
13
+ export declare function errorRoundSignature(errored: Array<{
14
+ name: string;
15
+ error: string;
16
+ }>): string;
17
+ /** Runaway-guard limits, exposed for tests. */
18
+ export declare const _stallLimits: {
19
+ STALL_LIMIT: number;
20
+ REPEAT_STALL_LIMIT: number;
21
+ };
22
+ /**
23
+ * Why an agent run stopped.
24
+ *
25
+ * `'clean'` and `'aborted'` are the two silent-by-design outcomes: the model gave a
26
+ * final answer, or the user pressed Ctrl+C and already knows why it stopped.
27
+ * EVERYTHING else owes the user an explanation, which is what stopReasonNotice covers.
28
+ */
29
+ export type StopReason = 'clean' | 'aborted' | 'reported-elsewhere' | 'empty-response' | 'no-balance' | 'stalled' | 'stalled-repeat' | 'budget' | 'unknown';
30
+ /**
31
+ * The message shown when a run ends for any reason other than a clean finish.
32
+ *
33
+ * Pure and exported so every branch is testable: reaching some of these for real needs
34
+ * an empty wallet, a dead upstream, or hundreds of iterations. Returns null only for
35
+ * the two outcomes that are deliberately silent.
36
+ *
37
+ * `'unknown'` deliberately produces a message rather than nothing. If a future `break`
38
+ * forgets to set a reason, the symptom should be a visible "ended unexpectedly" line —
39
+ * annoying and reportable — not the silent stop that made this refactor necessary.
40
+ */
41
+ export declare function stopReasonNotice(reason: StopReason, ctx?: {
42
+ budget?: number;
43
+ repeatError?: string | null;
44
+ }): string | null;
2
45
  export declare function resolveMaxIterations(optionValue: number | undefined, settingsRaw: Record<string, unknown>): number;
3
46
  /**
4
47
  * Minimal concurrency gate. Hand-rolled rather than pulling in `p-limit` because
@@ -111,6 +154,15 @@ export interface ProgressLedger {
111
154
  tool: string;
112
155
  edits: number;
113
156
  }>;
157
+ /**
158
+ * DISTINCT paths ever touched, including any since evicted from `filesTouched`.
159
+ *
160
+ * The Map is bounded (see ledgerRecord), so `filesTouched.size` is a window, not a
161
+ * total. The preamble states "FILES CHANGED THIS SESSION (N)" as a fact the model
162
+ * reasons about, so N must not silently shrink when eviction kicks in on a very long
163
+ * run — that would tell the model less work happened than actually did.
164
+ */
165
+ filesTouchedTotal: number;
114
166
  verifications: Array<{
115
167
  cmd: string;
116
168
  ok: boolean;
@@ -121,6 +173,19 @@ export interface ProgressLedger {
121
173
  path: string;
122
174
  reason: string;
123
175
  }>;
176
+ /**
177
+ * TOTAL test-integrity findings ever recorded, including ones since trimmed.
178
+ *
179
+ * `testIntegrity` is a bounded window (older entries are spliced off once it grows
180
+ * past 2×LEDGER_MAX_NOTES), so its `.length` STOPS being a running total after the
181
+ * first trim. The one-shot nudge compares "how many findings exist" against "how
182
+ * many I've already surfaced", and comparing against a window that shrinks meant the
183
+ * count could never move ahead again — silently disabling the reward-hacking warning
184
+ * for the remainder of a long session, i.e. exactly when it matters most.
185
+ *
186
+ * This counter only ever increases, so it is a safe basis for that comparison.
187
+ */
188
+ testIntegrityTotal: number;
124
189
  /**
125
190
  * Monotonic mutation epoch: incremented on every successful source write. Two
126
191
  * verification runs sharing an epoch had NO edit between them, so a PASS↔FAIL
@@ -1 +1 @@
1
- {"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"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"}
@@ -33,7 +33,9 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.VERIFY_CMD_RE = exports.WRITE_TOOL_NAMES = exports.ToolNotAllowedError = void 0;
36
+ exports.VERIFY_CMD_RE = exports.WRITE_TOOL_NAMES = exports.ToolNotAllowedError = exports._stallLimits = void 0;
37
+ exports.errorRoundSignature = errorRoundSignature;
38
+ exports.stopReasonNotice = stopReasonNotice;
37
39
  exports.resolveMaxIterations = resolveMaxIterations;
38
40
  exports.createLimiter = createLimiter;
39
41
  exports.extractSubTaskText = extractSubTaskText;
@@ -189,6 +191,74 @@ const DEFAULT_MAX_ITERATIONS = 500;
189
191
  const MAX_ITERATIONS_CEILING = 2000; // default auto-continue backstop (no explicit opt-in)
190
192
  const HARD_ITERATIONS_CAP = 100000; // absolute safety cap — even explicit opt-in can't exceed this
191
193
  const STALL_LIMIT = 8; // consecutive all-failed tool rounds → give up (runaway guard)
194
+ // Consecutive rounds producing the IDENTICAL error(s) → give up, even if other calls in
195
+ // those rounds succeeded. Higher than STALL_LIMIT because a repeat is weaker evidence of
196
+ // being stuck than a total failure: legitimately retrying one failing command a few times
197
+ // while making progress elsewhere is normal, twelve times is not.
198
+ const REPEAT_STALL_LIMIT = 12;
199
+ /**
200
+ * Fingerprint one round's tool failures, for the repeated-failure runaway guard.
201
+ *
202
+ * Exported (with the limits) purely as a test seam: the guard's whole value is in the
203
+ * edge cases — that a DIFFERENT error each round must NOT trip it, that call order
204
+ * within a round is irrelevant, that a long error body doesn't make every occurrence
205
+ * look unique — and none of that is reachable without driving a live model loop.
206
+ *
207
+ * Sorted so parallel tool calls completing in a different order still compare equal;
208
+ * truncated because errors often embed a varying path or timestamp late in the string.
209
+ */
210
+ function errorRoundSignature(errored) {
211
+ return errored
212
+ .map(({ name, error }) => `${name}:${String(error).slice(0, 200)}`)
213
+ .sort()
214
+ .join('|');
215
+ }
216
+ /** Runaway-guard limits, exposed for tests. */
217
+ exports._stallLimits = { STALL_LIMIT, REPEAT_STALL_LIMIT };
218
+ /**
219
+ * The message shown when a run ends for any reason other than a clean finish.
220
+ *
221
+ * Pure and exported so every branch is testable: reaching some of these for real needs
222
+ * an empty wallet, a dead upstream, or hundreds of iterations. Returns null only for
223
+ * the two outcomes that are deliberately silent.
224
+ *
225
+ * `'unknown'` deliberately produces a message rather than nothing. If a future `break`
226
+ * forgets to set a reason, the symptom should be a visible "ended unexpectedly" line —
227
+ * annoying and reportable — not the silent stop that made this refactor necessary.
228
+ */
229
+ function stopReasonNotice(reason, ctx = {}) {
230
+ switch (reason) {
231
+ case 'clean':
232
+ case 'aborted':
233
+ // A dedicated channel (e.g. the zero-balance bubble via onBalanceStatus) has already
234
+ // told the user why this stopped. Naming this case explicitly — rather than reusing
235
+ // 'aborted' — keeps "the user cancelled" from silently coming to mean two things.
236
+ case 'reported-elsewhere':
237
+ return null;
238
+ case 'empty-response':
239
+ return `\n\u26a0\ufe0f The model returned an empty response, so nothing was done. This is usually a transient ` +
240
+ `upstream hiccup \u2014 send "continue" to retry.\n`;
241
+ case 'no-balance':
242
+ return `\n\ud83d\udcb3 Stopped: your balance is empty, so the request was rejected before it started. ` +
243
+ `Top up and send "continue" \u2014 no tokens were used for this turn.\n`;
244
+ case 'stalled':
245
+ return `\n\ud83d\uded1 Stopped: the last ${STALL_LIMIT} tool rounds all failed, so the agent looked stuck. ` +
246
+ `Fix the underlying error (or grant the needed permission) and send "continue".\n`;
247
+ case 'stalled-repeat':
248
+ return `\n\ud83d\uded1 Stopped: the same tool error repeated ${REPEAT_STALL_LIMIT} rounds in a row, so the agent ` +
249
+ `was looping without making progress.` +
250
+ (ctx.repeatError ? ` The recurring error was:\n${ctx.repeatError}\n` : '\n') +
251
+ `Fix that underlying cause (or grant the needed permission) and send "continue".\n`;
252
+ case 'budget':
253
+ return `\n\u23f8\ufe0f Stopped at the ${ctx.budget}-step safety limit \u2014 the task may be incomplete. ` +
254
+ `Send "continue" to resume, or raise the limit via "maxIterations" in .nexrall/settings.json ` +
255
+ `(or the NEXRALL_MAX_ITERATIONS env var). Auto-continue can be disabled with "autoContinue": false.\n`;
256
+ case 'unknown':
257
+ default:
258
+ return `\n\u26a0\ufe0f The run ended unexpectedly without completing. Your work so far is preserved \u2014 ` +
259
+ `send "continue" to resume.\n`;
260
+ }
261
+ }
192
262
  // Resolve the soft iteration budget. Precedence:
193
263
  // options.maxIterations → env NEXRALL_MAX_ITERATIONS → settings.maxIterations → default
194
264
  //
@@ -830,6 +900,26 @@ function compactionThresholds() {
830
900
  // so we require at least this many bytes reclaimed before accepting a prune.
831
901
  const PRUNE_MIN_RECLAIM_BYTES = 256 * 1024; // 256 KB
832
902
  const COMPACT_KEEP_MIN = 6; // always keep at least the last N messages verbatim
903
+ /**
904
+ * Bytes a compaction must reclaim to count as productive.
905
+ *
906
+ * Deliberately much smaller than PRUNE_MIN_RECLAIM_BYTES: a prune declines when the
907
+ * gain isn't worth busting the prompt cache, whereas by the time we are summarising
908
+ * we are already committed to rewriting the prefix — the only question is whether the
909
+ * summariser is making ANY headway. 32 KB is small enough that a genuinely useful
910
+ * compaction always clears it, large enough that shuffling a few bytes doesn't.
911
+ */
912
+ const COMPACT_MIN_RECLAIM_BYTES = 32 * 1024; // 32 KB
913
+ /**
914
+ * Consecutive non-productive compaction attempts before auto-compaction is switched
915
+ * off for the rest of the run.
916
+ *
917
+ * 3 rather than 1 because the failure is often transient — a summariser stream that
918
+ * blipped will usually succeed on the next turn, and giving up instantly would lose
919
+ * the safety net for a whole long session over one network hiccup. 3 also bounds the
920
+ * wasted spend: at most three summariser calls, not hundreds.
921
+ */
922
+ const COMPACT_MAX_FAILURES = 3;
833
923
  // Byte-level safety net, independent of the token estimate.
834
924
  //
835
925
  // Tool-heavy sessions on large codebases accumulate many tool_result blocks
@@ -1013,7 +1103,7 @@ function transcriptOf(messages) {
1013
1103
  const LEDGER_MAX_FILES = 60; // cap the file list so the preamble can't balloon
1014
1104
  const LEDGER_MAX_NOTES = 20; // cap verification/among notes
1015
1105
  function createLedger() {
1016
- return { filesTouched: new Map(), verifications: [], testIntegrity: [], epoch: 0 };
1106
+ return { filesTouched: new Map(), filesTouchedTotal: 0, verifications: [], testIntegrity: [], testIntegrityTotal: 0, epoch: 0 };
1017
1107
  }
1018
1108
  /** Record one tool call's effect on the ledger (deterministic, no model call). */
1019
1109
  function ledgerRecord(ledger, toolName, input, ok, output, exitCode) {
@@ -1026,7 +1116,32 @@ function ledgerRecord(ledger, toolName, input, ok, output, exitCode) {
1026
1116
  const p = typeof input?.path === 'string' ? input.path : undefined;
1027
1117
  if (p) {
1028
1118
  const prev = ledger.filesTouched.get(p);
1119
+ if (!prev)
1120
+ ledger.filesTouchedTotal++;
1121
+ // DELETE before SET, so a re-touched path moves to the BACK of the insertion
1122
+ // order. `Map.set` on an existing key keeps its ORIGINAL slot, which quietly
1123
+ // broke the eviction policy below: a file edited hundreds of times over a long
1124
+ // session kept the position of its FIRST edit, so it aged out like a file nobody
1125
+ // had looked at since — and on the next edit it was re-inserted as "new", double-
1126
+ // counting filesTouchedTotal (which is documented as DISTINCT paths). Making the
1127
+ // Map a true LRU-by-touch is what lets the `key !== p` guard below mean anything.
1128
+ ledger.filesTouched.delete(p);
1029
1129
  ledger.filesTouched.set(p, { tool: toolName, edits: (prev?.edits ?? 0) + 1 });
1130
+ // Bound the Map itself, not just its rendering. LEDGER_MAX_FILES caps how many
1131
+ // paths the preamble PRINTS (see ledgerSummary's slice), but the Map was only ever
1132
+ // written to — so a multi-hour run touching thousands of files grew it without
1133
+ // limit, and it is deliberately retained across every compaction. Evict the
1134
+ // least-recently-touched entries once we hold well beyond what can ever be
1135
+ // displayed. Hysteresis (evict down to 2× only once we exceed 4×) keeps this an
1136
+ // occasional bulk sweep instead of a delete on every single write.
1137
+ if (ledger.filesTouched.size > LEDGER_MAX_FILES * 4) {
1138
+ for (const key of ledger.filesTouched.keys()) {
1139
+ if (ledger.filesTouched.size <= LEDGER_MAX_FILES * 2)
1140
+ break;
1141
+ if (key !== p)
1142
+ ledger.filesTouched.delete(key);
1143
+ }
1144
+ }
1030
1145
  }
1031
1146
  // Reward-hacking guard: if this write WEAKENED a test file, record it so the
1032
1147
  // signal survives compaction and can be surfaced before the agent finishes.
@@ -1046,6 +1161,7 @@ function ledgerRecord(ledger, toolName, input, ok, output, exitCode) {
1046
1161
  if (reasons.length && p) {
1047
1162
  for (const reason of reasons) {
1048
1163
  ledger.testIntegrity.push({ path: p, reason });
1164
+ ledger.testIntegrityTotal++;
1049
1165
  }
1050
1166
  if (ledger.testIntegrity.length > LEDGER_MAX_NOTES * 2) {
1051
1167
  ledger.testIntegrity.splice(0, ledger.testIntegrity.length - LEDGER_MAX_NOTES);
@@ -1078,8 +1194,11 @@ function ledgerSummary(ledger) {
1078
1194
  const lines = [];
1079
1195
  if (ledger.filesTouched.size) {
1080
1196
  const files = [...ledger.filesTouched.entries()];
1081
- const shown = files.slice(0, LEDGER_MAX_FILES);
1082
- lines.push(`FILES CHANGED THIS SESSION (${ledger.filesTouched.size}):`);
1197
+ // The TAIL, not the head: the Map is ordered least-recently-touched first, so
1198
+ // slicing from the front showed the OLDEST files and reliably omitted the ones the
1199
+ // agent was working on right now — the opposite of what this preamble is for.
1200
+ const shown = files.slice(-LEDGER_MAX_FILES);
1201
+ lines.push(`FILES CHANGED THIS SESSION (${ledger.filesTouchedTotal || ledger.filesTouched.size}):`);
1083
1202
  for (const [p, meta] of shown) {
1084
1203
  lines.push(` • ${p} (${meta.tool}${meta.edits > 1 ? ` ×${meta.edits}` : ''})`);
1085
1204
  }
@@ -1461,17 +1580,35 @@ async function runAgentLoop(initialMessages, options) {
1461
1580
  // condition where one sub-agent's chdir overwrites another's, making every tool
1462
1581
  // that falls back to process.cwd() resolve paths against the wrong directory.
1463
1582
  // workDir is now threaded explicitly through executeTool → resolvePath instead.
1464
- // Distinguish a clean finish (model produced a final answer) from hitting the
1465
- // iteration backstop, so we can tell the user what happened rather than
1466
- // silently returning a half-done task.
1467
- let completedCleanly = false;
1583
+ // ─── Why did this run stop? ───────────────────────────────────────────────────
1584
+ //
1585
+ // The epilogue used to INFER the reason by re-testing state (`stalledOut`, then
1586
+ // `iteration >= budget`). Inference only covers the cases someone thought to test,
1587
+ // so any `break` that matched none of them fell through and the run ended in total
1588
+ // silence — the worst possible outcome after an hour of work, because the user
1589
+ // cannot tell "finished" from "died". Two such holes existed:
1590
+ //
1591
+ // • the pre-flight 402 (wallet empty) broke out with no flag set at all, leaving
1592
+ // the optional onBalanceStatus callback as the ONLY signal;
1593
+ // • a thinking-only/empty model turn set completedCleanly = true, actively
1594
+ // claiming success for a turn that produced nothing.
1595
+ //
1596
+ // So the reason is now RECORDED at each exit instead of reconstructed after it.
1597
+ // Anything that isn't an explicit clean finish or a user abort must name itself,
1598
+ // and `default` in the switch below means a future `break` cannot go silent.
1599
+ let stopReason = 'unknown';
1468
1600
  // Tool rounds actually completed in this call. Counted explicitly rather than derived
1469
1601
  // from `messages.length` because auto-compaction splices the history SHORTER mid-run:
1470
1602
  // a long session that compacted and then lost its connection would show a negative
1471
1603
  // length delta and be judged "no progress", discarding the work it most needs to keep.
1472
1604
  let completedRounds = 0;
1473
- let stalledOut = false; // tripped the runaway guard (all-failed rounds)
1474
1605
  let consecutiveErrorRounds = 0; // rounds where every tool call errored
1606
+ // Repeated-identical-failure guard: see the runaway guards below. Tracked
1607
+ // separately from consecutiveErrorRounds because a round can contain a succeeding
1608
+ // call and still be part of a livelock.
1609
+ let repeatedErrorRounds = 0;
1610
+ let lastErrorSignature = '';
1611
+ let stalledRepeatError = null;
1475
1612
  let budget = maxIterations; // extended by auto-continue, capped at hardCap
1476
1613
  let iteration = 0;
1477
1614
  // ─── Verification nudge (GAP D) ───────────────────────────────────────────────
@@ -1498,10 +1635,37 @@ async function runAgentLoop(initialMessages, options) {
1498
1635
  let claimEvidenceNudged = false;
1499
1636
  // GAP E — deterministic progress ledger, preserved verbatim across compactions.
1500
1637
  const ledger = createLedger();
1638
+ // ─── Auto-compact circuit breaker ─────────────────────────────────────────────
1639
+ //
1640
+ // The in-loop compaction trigger below re-derives its pressure from the CURRENT
1641
+ // body on every iteration. That is correct, but it means a compaction which does
1642
+ // not shrink anything leaves the trigger condition still true — so the next
1643
+ // iteration pays for another summariser call over the same (up to ~600KB)
1644
+ // transcript, and so on for the rest of the run. Two ways that happens:
1645
+ //
1646
+ // • autoCompactMessages returns false (summariser stream threw, or came back
1647
+ // empty). Nothing was replaced, so the pressure is unchanged.
1648
+ // • It returns true but cannot get under MAX_BODY_BYTES, because the messages
1649
+ // it must retain (COMPACT_KEEP_MIN) are themselves huge. `lastPromptTokens = 0`
1650
+ // suppresses only the TOKEN trigger; the BYTE trigger fires again immediately.
1651
+ //
1652
+ // Both are invisible to the user (the success notice only prints when `did`), so
1653
+ // the symptom is a long run that silently gets slower and more expensive. Count
1654
+ // consecutive non-productive attempts and stop trying after a few — losing
1655
+ // compaction degrades gracefully (the turn may still fit, and the prune pass
1656
+ // still runs), whereas an unbounded retry loop does not.
1657
+ //
1658
+ // The resume-time compactor already had exactly these guards
1659
+ // (compactMessagesForResume: a bounded loop plus `if (!did) break`); this brings
1660
+ // the in-loop path in line with it.
1661
+ let compactFailures = 0;
1662
+ let compactDisabled = false;
1501
1663
  try {
1502
1664
  for (; iteration < budget; iteration++) {
1503
- if (options.abortSignal?.aborted)
1665
+ if (options.abortSignal?.aborted) {
1666
+ stopReason = 'aborted';
1504
1667
  break;
1668
+ }
1505
1669
  // Auto-compact: keep the resent prompt small BEFORE the next stream so we
1506
1670
  // never hit the context-window wall — or the backend body-size limit — and,
1507
1671
  // just as importantly, so we stop paying cache-read on an ever-growing prefix
@@ -1546,10 +1710,17 @@ async function runAgentLoop(initialMessages, options) {
1546
1710
  (options.onNotice ?? options.onText)(`\u267b\ufe0f Trimmed ~${(reclaimed / (1024 * 1024)).toFixed(1)}MB of already-processed tool output to keep this chat cheap to continue.`);
1547
1711
  }
1548
1712
  }
1549
- if (autoCompact && !compacting && (tokenPressure || bytePressure) && messages.length > COMPACT_KEEP_MIN + 2) {
1713
+ if (autoCompact && !compactDisabled && !compacting && (tokenPressure || bytePressure) && messages.length > COMPACT_KEEP_MIN + 2) {
1550
1714
  compacting = true;
1551
1715
  try {
1716
+ // Measured BEFORE, so "did it actually help?" is a fact about bytes rather
1717
+ // than a claim from the compactor. A compaction that returns true but
1718
+ // reclaims nothing is a failure for our purposes — it leaves the trigger
1719
+ // armed for the next iteration, which is precisely the runaway.
1720
+ const bytesBefore = estimateBodyBytes(messages);
1552
1721
  const did = await autoCompactMessages(messages, options, ledger);
1722
+ const bytesAfter = did ? estimateBodyBytes(messages) : bytesBefore;
1723
+ const reclaimed = bytesBefore - bytesAfter;
1553
1724
  if (did) {
1554
1725
  lastPromptTokens = 0; // stale — next usage event refreshes it
1555
1726
  const reason = bytePressure
@@ -1558,6 +1729,24 @@ async function runAgentLoop(initialMessages, options) {
1558
1729
  // Same reasoning as above: this is a system notice about housekeeping,
1559
1730
  // not part of the model's answer — keep it out of the text bubble.
1560
1731
  (options.onNotice ?? options.onText)(`\u267b\ufe0f Auto-compacted earlier conversation to stay within the ${reason}.`);
1732
+ // Refresh local pressure so the rest of THIS iteration sees the new size.
1733
+ bodyBytes = bytesAfter;
1734
+ bytePressure = bodyBytes > MAX_BODY_BYTES;
1735
+ }
1736
+ // Productive == it shrank the body meaningfully. A successful-but-useless
1737
+ // compaction counts as a failure, otherwise the "cannot get under the byte
1738
+ // cap" case would never trip the breaker.
1739
+ if (did && reclaimed >= COMPACT_MIN_RECLAIM_BYTES) {
1740
+ compactFailures = 0;
1741
+ }
1742
+ else if (++compactFailures >= COMPACT_MAX_FAILURES) {
1743
+ compactDisabled = true;
1744
+ // Surfaced ONCE. The user needs to know the automatic safety net is off
1745
+ // (so a context-window error later isn't a total surprise) and what to do
1746
+ // about it, but repeating this every iteration would be its own spam.
1747
+ (options.onNotice ?? options.onText)(`\u26a0\ufe0f Auto-compaction isn't reducing this conversation any further, so it's been switched off ` +
1748
+ `for the rest of this run to avoid repeated summarising. If the context fills up, start a fresh ` +
1749
+ `chat or run /compact manually.`);
1561
1750
  }
1562
1751
  }
1563
1752
  finally {
@@ -1672,8 +1861,10 @@ async function runAgentLoop(initialMessages, options) {
1672
1861
  }, onEvent);
1673
1862
  }
1674
1863
  catch (err) {
1675
- if (options.abortSignal?.aborted || err.name === 'AbortError')
1864
+ if (options.abortSignal?.aborted || err.name === 'AbortError') {
1865
+ stopReason = 'aborted';
1676
1866
  break;
1867
+ }
1677
1868
  // Pre-flight 402 (routes/code.js — wallet already empty, the turn couldn't even
1678
1869
  // start) is tagged with `status`/`balance` by client.ts. Surface it as the same
1679
1870
  // low/zero-balance nudge a mid-turn 'balance_status' event would, instead of a
@@ -1681,7 +1872,16 @@ async function runAgentLoop(initialMessages, options) {
1681
1872
  const status = err.status;
1682
1873
  if (status === 402) {
1683
1874
  const balance = err.balance ?? 0;
1875
+ // Prefer the rich, actionable UI (a persistent bubble with a link to Billing)
1876
+ // where the client implements it…
1684
1877
  options.onBalanceStatus?.(balance, true);
1878
+ // …but do not DEPEND on it. The callback is optional, so a client without it
1879
+ // used to show the user absolutely nothing — the run simply stopped mid-task.
1880
+ //
1881
+ // Set the reason ONLY when there is no richer channel, so clients that do
1882
+ // render the bubble don't also get a redundant plain-text line saying the same
1883
+ // thing. Either way the user is told something, which is the actual guarantee.
1884
+ stopReason = options.onBalanceStatus ? 'reported-elsewhere' : 'no-balance';
1685
1885
  break;
1686
1886
  }
1687
1887
  runSimpleHooks(hooks.OnError, options.workDir);
@@ -1694,8 +1894,10 @@ async function runAgentLoop(initialMessages, options) {
1694
1894
  // assistant→tool_result pair here, so it is valid to resend as-is.
1695
1895
  throw new types_1.AgentTurnError(`Stream failed: ${err.message}`, trimToResumableBoundary(messages), completedRounds, err);
1696
1896
  }
1697
- if (options.abortSignal?.aborted)
1897
+ if (options.abortSignal?.aborted) {
1898
+ stopReason = 'aborted';
1698
1899
  break;
1900
+ }
1699
1901
  // 2. Strip thinking blocks — API rejects them in message history
1700
1902
  assistantMessage.content = assistantMessage.content.filter((b) => b.type !== 'thinking' && b.type !== 'redacted_thinking');
1701
1903
  // If the model returned nothing usable (e.g. thinking-only, then stopped), the
@@ -1711,8 +1913,12 @@ async function runAgentLoop(initialMessages, options) {
1711
1913
  messages.push({ role: 'user', content: [{ type: 'text', text }] });
1712
1914
  continue;
1713
1915
  }
1916
+ // NOT a clean finish. This used to set completedCleanly = true, which told the
1917
+ // caller the task succeeded when the model had in fact produced nothing at all
1918
+ // — indistinguishable, to the user, from a finished job with no summary. An
1919
+ // empty turn is a (usually transient) upstream failure, so name it and say so.
1714
1920
  runSimpleHooks(hooks.PostMessageComplete, options.workDir);
1715
- completedCleanly = true;
1921
+ stopReason = 'empty-response';
1716
1922
  break;
1717
1923
  }
1718
1924
  // stopReason is transient, single-turn metadata for the truncated-tool-call guard
@@ -1772,9 +1978,19 @@ async function runAgentLoop(initialMessages, options) {
1772
1978
  // and require the agent to either justify each change (legit refactor) or
1773
1979
  // revert it and fix the real code. Deterministic — the signal comes from
1774
1980
  // diff structure, not model self-report, so it can't be gamed away.
1775
- if (ledger.testIntegrity.length > testIntegrityNudgedCount) {
1776
- const fresh = ledger.testIntegrity.slice(testIntegrityNudgedCount);
1777
- testIntegrityNudgedCount = ledger.testIntegrity.length;
1981
+ //
1982
+ // Compared against testIntegrityTotal, NOT testIntegrity.length: the array is a
1983
+ // bounded window that gets trimmed, so once a long session passed ~40 findings
1984
+ // its length stopped growing and could even fall BELOW the already-nudged count,
1985
+ // permanently wedging this condition false and disabling the guard for the rest
1986
+ // of the run. The total only ever increases.
1987
+ if (ledger.testIntegrityTotal > testIntegrityNudgedCount) {
1988
+ // How many are genuinely new, clamped to what the window still holds — the
1989
+ // trimmed-away ones are unrecoverable, and reporting the tail we DO have is
1990
+ // strictly better than reporting nothing.
1991
+ const newCount = Math.min(ledger.testIntegrityTotal - testIntegrityNudgedCount, ledger.testIntegrity.length);
1992
+ const fresh = ledger.testIntegrity.slice(ledger.testIntegrity.length - newCount);
1993
+ testIntegrityNudgedCount = ledger.testIntegrityTotal;
1778
1994
  const bullet = fresh.map((t) => ` • ${t.path}: ${t.reason}`).join('\n');
1779
1995
  messages.push({
1780
1996
  role: 'user',
@@ -1850,7 +2066,7 @@ async function runAgentLoop(initialMessages, options) {
1850
2066
  }
1851
2067
  }
1852
2068
  runSimpleHooks(hooks.PostMessageComplete, options.workDir);
1853
- completedCleanly = true;
2069
+ stopReason = 'clean';
1854
2070
  break;
1855
2071
  }
1856
2072
  // 5. Execute all tool uses in parallel
@@ -2073,14 +2289,44 @@ async function runAgentLoop(initialMessages, options) {
2073
2289
  // tool_result user turn). Let the caller checkpoint progress so a crash
2074
2290
  // mid-run loses only the in-flight step, not the whole session.
2075
2291
  options.onProgress?.(messages);
2076
- // Runaway guard: if every tool call in this round failed, count it. Enough
2077
- // consecutive all-failed rounds (e.g. a command that always errors, or the
2078
- // user denying every permission) means we're stuck stop instead of
2079
- // burning the whole budget spinning.
2080
- const allErrored = toolResults.length > 0 && toolResults.every(({ result }) => result.error !== undefined);
2292
+ // ── Runaway guards ────────────────────────────────────────────────────────
2293
+ //
2294
+ // TWO independent counters, because "stuck" has two shapes and the original
2295
+ // all-failed test only caught the first.
2296
+ //
2297
+ // 1. TOTAL failure: every call in the round errored (a command that always
2298
+ // errors, the user denying every permission). Unambiguous.
2299
+ //
2300
+ // 2. REPEATED failure: the SAME error keeps coming back, round after round,
2301
+ // even though other calls in those rounds succeed. This is the livelock the
2302
+ // `.every()` test missed entirely — one trivially-succeeding sibling (say a
2303
+ // `read_file` alongside an `edit_file` that fails identically every time)
2304
+ // reset the counter to 0 forever, so a genuine loop burned the full
2305
+ // 2000-iteration ceiling instead of stopping at 8. That is the expensive,
2306
+ // user-visible "it just spun for an hour" failure.
2307
+ //
2308
+ // Keyed on tool + error text so a DIFFERENT error each round (real progress
2309
+ // through a chain of distinct problems) does not trip it.
2310
+ const errored = toolResults.filter(({ result }) => result.error !== undefined);
2311
+ const allErrored = toolResults.length > 0 && errored.length === toolResults.length;
2081
2312
  consecutiveErrorRounds = allErrored ? consecutiveErrorRounds + 1 : 0;
2082
2313
  if (consecutiveErrorRounds >= STALL_LIMIT) {
2083
- stalledOut = true;
2314
+ stopReason = 'stalled';
2315
+ break;
2316
+ }
2317
+ // Signature of this round's failures, order-independent and truncated so a long
2318
+ // error body (or a path echoed inside it) doesn't make every occurrence unique.
2319
+ const errSignature = errorRoundSignature(errored.map(({ block, result }) => ({ name: block.name, error: String(result.error) })));
2320
+ if (errSignature && errSignature === lastErrorSignature) {
2321
+ repeatedErrorRounds++;
2322
+ }
2323
+ else {
2324
+ repeatedErrorRounds = 0;
2325
+ lastErrorSignature = errSignature;
2326
+ }
2327
+ if (repeatedErrorRounds >= REPEAT_STALL_LIMIT) {
2328
+ stopReason = 'stalled-repeat';
2329
+ stalledRepeatError = errored[0] ? String(errored[0].result.error).slice(0, 300) : null;
2084
2330
  break;
2085
2331
  }
2086
2332
  // Auto-continue: about to exhaust the current budget but the model is still
@@ -2094,20 +2340,23 @@ async function runAgentLoop(initialMessages, options) {
2094
2340
  }
2095
2341
  // Loop back to step 1
2096
2342
  }
2097
- // Explain why we stopped when it wasn't a clean finish or a user abort, so a
2098
- // long run never just goes silent. History ends on a tool_result turn, so
2099
- // "continue" resumes exactly where it left off.
2100
- if (!options.abortSignal?.aborted && !completedCleanly) {
2101
- if (stalledOut) {
2102
- options.onText(`\n🛑 Stopped: the last ${STALL_LIMIT} tool rounds all failed, so the agent looked stuck. ` +
2103
- `Fix the underlying error (or grant the needed permission) and send "continue".\n`);
2104
- }
2105
- else if (iteration >= budget) {
2106
- options.onText(`\n⏸️ Stopped at the ${budget}-step safety limit — the task may be incomplete. ` +
2107
- `Send "continue" to resume, or raise the limit via "maxIterations" in .nexrall/settings.json ` +
2108
- `(or the NEXRALL_MAX_ITERATIONS env var). Auto-continue can be disabled with "autoContinue": false.\n`);
2109
- }
2110
- }
2343
+ // Explain why we stopped, so a long run never just goes silent. History ends on a
2344
+ // tool_result turn, so "continue" resumes exactly where it left off.
2345
+ //
2346
+ // The budget case is resolved HERE rather than at a break: exhausting the `for`
2347
+ // condition is a normal loop exit, not a branch we can annotate. Everything else
2348
+ // named itself on the way out, and 'unknown' is the loud fallback for a break that
2349
+ // forgot to.
2350
+ if (stopReason === 'unknown' && iteration >= budget)
2351
+ stopReason = 'budget';
2352
+ if (options.abortSignal?.aborted)
2353
+ stopReason = 'aborted';
2354
+ // Routed through onNotice (falling back to onText) like every other housekeeping
2355
+ // message in this file — these are statements from the harness, not from the model,
2356
+ // and splicing them into the assistant's own bubble reads as if it said them.
2357
+ const notice = stopReasonNotice(stopReason, { budget, repeatError: stalledRepeatError });
2358
+ if (notice)
2359
+ (options.onNotice ?? options.onText)(notice);
2111
2360
  }
2112
2361
  catch (err) {
2113
2362
  // Every other failure path (a tool executor blowing up, a hook throwing, an
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,UAAU,EAA8B,UAAU,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAiErH,eAAO,MAAM,QAAQ,QAAmB,CAAC;AAIzC;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,SAAS;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,EAC5D,OAAO,EAAE,CAAC,EAAE,EACZ,UAAU,EAAE,KAAK,CAAC;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,GAAG,IAAI,GAAG,SAAS,GACtD,CAAC,EAAE,CAeL;AAiGD,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,UAAU,CAAC;IACjB,aAAa,CAAC,EAAE,aAAa,GAAG,IAAI,CAAC;IACrC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC;IACnC,oFAAoF;IACpF,UAAU,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IACjG,6EAA6E;IAC7E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6IAA6I;IAC7I,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;;;;;;;;;OAeG;IACH,uBAAuB,CAAC,EAAE,OAAO,CAAC;CACnC;AAiDD,wBAAsB,UAAU,CAC9B,QAAQ,EAAE,OAAO,EAAE,EACnB,OAAO,EAAE,iBAAiB,EAC1B,OAAO,EAAE,CAAC,CAAC,EAAE,QAAQ,KAAK,IAAI,GAC7B,OAAO,CAAC,OAAO,CAAC,CA6jClB;AAID;;;;;;;;;;;GAWG;AACH,wBAAsB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAU9D;AAID;;;;;;;;;;GAUG;AACH,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC,CAYxD;AAID,wBAAsB,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC,CA0BlD;AAID,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CA+B1E;AAID,wBAAsB,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CA0BhF"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,UAAU,EAA8B,UAAU,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAiErH,eAAO,MAAM,QAAQ,QAAmB,CAAC;AAIzC;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,SAAS;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,EAC5D,OAAO,EAAE,CAAC,EAAE,EACZ,UAAU,EAAE,KAAK,CAAC;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,GAAG,IAAI,GAAG,SAAS,GACtD,CAAC,EAAE,CAeL;AAiGD,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,UAAU,CAAC;IACjB,aAAa,CAAC,EAAE,aAAa,GAAG,IAAI,CAAC;IACrC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC;IACnC,oFAAoF;IACpF,UAAU,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IACjG,6EAA6E;IAC7E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6IAA6I;IAC7I,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;;;;;;;;;OAeG;IACH,uBAAuB,CAAC,EAAE,OAAO,CAAC;CACnC;AAiDD,wBAAsB,UAAU,CAC9B,QAAQ,EAAE,OAAO,EAAE,EACnB,OAAO,EAAE,iBAAiB,EAC1B,OAAO,EAAE,CAAC,CAAC,EAAE,QAAQ,KAAK,IAAI,GAC7B,OAAO,CAAC,OAAO,CAAC,CAwlClB;AAID;;;;;;;;;;;GAWG;AACH,wBAAsB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAU9D;AAID;;;;;;;;;;GAUG;AACH,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC,CAYxD;AAID,wBAAsB,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC,CA0BlD;AAID,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CA+B1E;AAID,wBAAsB,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CA0BhF"}