@nexrall/code-core 1.4.26 → 1.4.27

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,24 @@
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
+ };
2
22
  export declare function resolveMaxIterations(optionValue: number | undefined, settingsRaw: Record<string, unknown>): number;
3
23
  /**
4
24
  * Minimal concurrency gate. Hand-rolled rather than pulling in `p-limit` because
@@ -111,6 +131,15 @@ export interface ProgressLedger {
111
131
  tool: string;
112
132
  edits: number;
113
133
  }>;
134
+ /**
135
+ * DISTINCT paths ever touched, including any since evicted from `filesTouched`.
136
+ *
137
+ * The Map is bounded (see ledgerRecord), so `filesTouched.size` is a window, not a
138
+ * total. The preamble states "FILES CHANGED THIS SESSION (N)" as a fact the model
139
+ * reasons about, so N must not silently shrink when eviction kicks in on a very long
140
+ * run — that would tell the model less work happened than actually did.
141
+ */
142
+ filesTouchedTotal: number;
114
143
  verifications: Array<{
115
144
  cmd: string;
116
145
  ok: boolean;
@@ -121,6 +150,19 @@ export interface ProgressLedger {
121
150
  path: string;
122
151
  reason: string;
123
152
  }>;
153
+ /**
154
+ * TOTAL test-integrity findings ever recorded, including ones since trimmed.
155
+ *
156
+ * `testIntegrity` is a bounded window (older entries are spliced off once it grows
157
+ * past 2×LEDGER_MAX_NOTES), so its `.length` STOPS being a running total after the
158
+ * first trim. The one-shot nudge compares "how many findings exist" against "how
159
+ * many I've already surfaced", and comparing against a window that shrinks meant the
160
+ * count could never move ahead again — silently disabling the reward-hacking warning
161
+ * for the remainder of a long session, i.e. exactly when it matters most.
162
+ *
163
+ * This counter only ever increases, so it is a safe basis for that comparison.
164
+ */
165
+ testIntegrityTotal: number;
124
166
  /**
125
167
  * Monotonic mutation epoch: incremented on every successful source write. Two
126
168
  * 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":"AAAA,OAAO,KAAK,EACV,OAAO,EAMP,gBAAgB,EAChB,UAAU,EACX,MAAM,UAAU,CAAC;AA2KlB,wBAAgB,oBAAoB,CAClC,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,MAAM,CAWR;AAkED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,CAgBlF;AAuJD;;;;;;;;;;GAUG;AACH,qBAAa,mBAAoB,SAAQ,KAAK;gBAChC,OAAO,EAAE,MAAM;CAI5B;AA4BD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,UAAU,UAAO,GAAG,MAAM,CAYjF;AAED,8EAA8E;AAC9E,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,SAAc,GAAG,MAAM,CAKtE;AAED;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,CAoBpE;AAkTD,oGAAoG;AACpG,wBAAgB,gBAAgB,CAAC,KAAK,CAAC,EAAE,OAAO,GAAG,KAAK,GAAG,OAAO,GAAG,MAAM,CAE1E;AA8BD,kHAAkH;AAClH,wBAAgB,oBAAoB,IAAI;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAEzE;AAuBD,+EAA+E;AAC/E,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,CAM7D;AAsBD,iFAAiF;AACjF,eAAO,MAAM,gBAAgB,aAA+G,CAAC;AAC7I;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GAAG,OAAO,CA8BrG;AAED,gGAAgG;AAChG,eAAO,MAAM,aAAa,QAA2J,CAAC;AAEtL;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAK5E;AAUD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,CAwBxD;AAoBD,MAAM,WAAW,cAAc;IAC7B,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC3D,aAAa,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,OAAO,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAClE,qFAAqF;IACrF,aAAa,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACvD;;;;OAIG;IACH,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wBAAgB,YAAY,IAAI,cAAc,CAE7C;AAED,kFAAkF;AAClF,wBAAgB,YAAY,CAC1B,MAAM,EAAE,cAAc,EACtB,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAC1C,EAAE,EAAE,OAAO,EACX,MAAM,CAAC,EAAE,MAAM,EACf,QAAQ,CAAC,EAAE,MAAM,GAChB,IAAI,CAoDN;AAED,kFAAkF;AAClF,wBAAgB,aAAa,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM,CA6B5D;AAmBD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,eAAe,SAAI,GAAG,MAAM,CAgCpF;AAsKD,gFAAgF;AAChF,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,CAE/D;AAED;;;;;;;;;GASG;AACH,wBAAsB,wBAAwB,CAC5C,QAAQ,EAAE,OAAO,EAAE,EACnB,IAAI,EAAE;IACJ,KAAK,CAAC,EAAE,OAAO,GAAG,KAAK,GAAG,OAAO,CAAC;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,GAAG,CAAC,EAAE,UAAU,CAAC;IACjB,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC,GACA,OAAO,CAAC,OAAO,CAAC,CAqElB;AAID,wBAAsB,YAAY,CAChC,eAAe,EAAE,OAAO,EAAE,EAC1B,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,OAAO,EAAE,CAAC,CAgxBpB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,OAAO,EAAE,CAqCtE"}
1
+ {"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,OAAO,EAMP,gBAAgB,EAChB,UAAU,EACX,MAAM,UAAU,CAAC;AAuKlB;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,GAC9C,MAAM,CAKR;AAED,+CAA+C;AAC/C,eAAO,MAAM,YAAY;;;CAAsC,CAAC;AAWhE,wBAAgB,oBAAoB,CAClC,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,MAAM,CAWR;AAkED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,CAgBlF;AAuJD;;;;;;;;;;GAUG;AACH,qBAAa,mBAAoB,SAAQ,KAAK;gBAChC,OAAO,EAAE,MAAM;CAI5B;AA4BD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,UAAU,UAAO,GAAG,MAAM,CAYjF;AAED,8EAA8E;AAC9E,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,SAAc,GAAG,MAAM,CAKtE;AAED;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,CAoBpE;AAkTD,oGAAoG;AACpG,wBAAgB,gBAAgB,CAAC,KAAK,CAAC,EAAE,OAAO,GAAG,KAAK,GAAG,OAAO,GAAG,MAAM,CAE1E;AA8BD,kHAAkH;AAClH,wBAAgB,oBAAoB,IAAI;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAEzE;AA6CD,+EAA+E;AAC/E,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,CAM7D;AAsBD,iFAAiF;AACjF,eAAO,MAAM,gBAAgB,aAA+G,CAAC;AAC7I;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GAAG,OAAO,CA8BrG;AAED,gGAAgG;AAChG,eAAO,MAAM,aAAa,QAA2J,CAAC;AAEtL;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAK5E;AAUD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,CAwBxD;AAoBD,MAAM,WAAW,cAAc;IAC7B,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC3D;;;;;;;OAOG;IACH,iBAAiB,EAAE,MAAM,CAAC;IAC1B,aAAa,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,OAAO,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAClE,qFAAqF;IACrF,aAAa,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACvD;;;;;;;;;;;OAWG;IACH,kBAAkB,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wBAAgB,YAAY,IAAI,cAAc,CAE7C;AAED,kFAAkF;AAClF,wBAAgB,YAAY,CAC1B,MAAM,EAAE,cAAc,EACtB,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAC1C,EAAE,EAAE,OAAO,EACX,MAAM,CAAC,EAAE,MAAM,EACf,QAAQ,CAAC,EAAE,MAAM,GAChB,IAAI,CA2EN;AAED,kFAAkF;AAClF,wBAAgB,aAAa,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM,CAgC5D;AAmBD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,eAAe,SAAI,GAAG,MAAM,CAgCpF;AAsKD,gFAAgF;AAChF,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,CAE/D;AAED;;;;;;;;;GASG;AACH,wBAAsB,wBAAwB,CAC5C,QAAQ,EAAE,OAAO,EAAE,EACnB,IAAI,EAAE;IACJ,KAAK,CAAC,EAAE,OAAO,GAAG,KAAK,GAAG,OAAO,CAAC;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,GAAG,CAAC,EAAE,UAAU,CAAC;IACjB,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC,GACA,OAAO,CAAC,OAAO,CAAC,CAqElB;AAID,wBAAsB,YAAY,CAChC,eAAe,EAAE,OAAO,EAAE,EAC1B,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,OAAO,EAAE,CAAC,CAg4BpB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,OAAO,EAAE,CAqCtE"}
@@ -33,7 +33,8 @@ 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;
37
38
  exports.resolveMaxIterations = resolveMaxIterations;
38
39
  exports.createLimiter = createLimiter;
39
40
  exports.extractSubTaskText = extractSubTaskText;
@@ -189,6 +190,30 @@ const DEFAULT_MAX_ITERATIONS = 500;
189
190
  const MAX_ITERATIONS_CEILING = 2000; // default auto-continue backstop (no explicit opt-in)
190
191
  const HARD_ITERATIONS_CAP = 100000; // absolute safety cap — even explicit opt-in can't exceed this
191
192
  const STALL_LIMIT = 8; // consecutive all-failed tool rounds → give up (runaway guard)
193
+ // Consecutive rounds producing the IDENTICAL error(s) → give up, even if other calls in
194
+ // those rounds succeeded. Higher than STALL_LIMIT because a repeat is weaker evidence of
195
+ // being stuck than a total failure: legitimately retrying one failing command a few times
196
+ // while making progress elsewhere is normal, twelve times is not.
197
+ const REPEAT_STALL_LIMIT = 12;
198
+ /**
199
+ * Fingerprint one round's tool failures, for the repeated-failure runaway guard.
200
+ *
201
+ * Exported (with the limits) purely as a test seam: the guard's whole value is in the
202
+ * edge cases — that a DIFFERENT error each round must NOT trip it, that call order
203
+ * within a round is irrelevant, that a long error body doesn't make every occurrence
204
+ * look unique — and none of that is reachable without driving a live model loop.
205
+ *
206
+ * Sorted so parallel tool calls completing in a different order still compare equal;
207
+ * truncated because errors often embed a varying path or timestamp late in the string.
208
+ */
209
+ function errorRoundSignature(errored) {
210
+ return errored
211
+ .map(({ name, error }) => `${name}:${String(error).slice(0, 200)}`)
212
+ .sort()
213
+ .join('|');
214
+ }
215
+ /** Runaway-guard limits, exposed for tests. */
216
+ exports._stallLimits = { STALL_LIMIT, REPEAT_STALL_LIMIT };
192
217
  // Resolve the soft iteration budget. Precedence:
193
218
  // options.maxIterations → env NEXRALL_MAX_ITERATIONS → settings.maxIterations → default
194
219
  //
@@ -830,6 +855,26 @@ function compactionThresholds() {
830
855
  // so we require at least this many bytes reclaimed before accepting a prune.
831
856
  const PRUNE_MIN_RECLAIM_BYTES = 256 * 1024; // 256 KB
832
857
  const COMPACT_KEEP_MIN = 6; // always keep at least the last N messages verbatim
858
+ /**
859
+ * Bytes a compaction must reclaim to count as productive.
860
+ *
861
+ * Deliberately much smaller than PRUNE_MIN_RECLAIM_BYTES: a prune declines when the
862
+ * gain isn't worth busting the prompt cache, whereas by the time we are summarising
863
+ * we are already committed to rewriting the prefix — the only question is whether the
864
+ * summariser is making ANY headway. 32 KB is small enough that a genuinely useful
865
+ * compaction always clears it, large enough that shuffling a few bytes doesn't.
866
+ */
867
+ const COMPACT_MIN_RECLAIM_BYTES = 32 * 1024; // 32 KB
868
+ /**
869
+ * Consecutive non-productive compaction attempts before auto-compaction is switched
870
+ * off for the rest of the run.
871
+ *
872
+ * 3 rather than 1 because the failure is often transient — a summariser stream that
873
+ * blipped will usually succeed on the next turn, and giving up instantly would lose
874
+ * the safety net for a whole long session over one network hiccup. 3 also bounds the
875
+ * wasted spend: at most three summariser calls, not hundreds.
876
+ */
877
+ const COMPACT_MAX_FAILURES = 3;
833
878
  // Byte-level safety net, independent of the token estimate.
834
879
  //
835
880
  // Tool-heavy sessions on large codebases accumulate many tool_result blocks
@@ -1013,7 +1058,7 @@ function transcriptOf(messages) {
1013
1058
  const LEDGER_MAX_FILES = 60; // cap the file list so the preamble can't balloon
1014
1059
  const LEDGER_MAX_NOTES = 20; // cap verification/among notes
1015
1060
  function createLedger() {
1016
- return { filesTouched: new Map(), verifications: [], testIntegrity: [], epoch: 0 };
1061
+ return { filesTouched: new Map(), filesTouchedTotal: 0, verifications: [], testIntegrity: [], testIntegrityTotal: 0, epoch: 0 };
1017
1062
  }
1018
1063
  /** Record one tool call's effect on the ledger (deterministic, no model call). */
1019
1064
  function ledgerRecord(ledger, toolName, input, ok, output, exitCode) {
@@ -1026,7 +1071,32 @@ function ledgerRecord(ledger, toolName, input, ok, output, exitCode) {
1026
1071
  const p = typeof input?.path === 'string' ? input.path : undefined;
1027
1072
  if (p) {
1028
1073
  const prev = ledger.filesTouched.get(p);
1074
+ if (!prev)
1075
+ ledger.filesTouchedTotal++;
1076
+ // DELETE before SET, so a re-touched path moves to the BACK of the insertion
1077
+ // order. `Map.set` on an existing key keeps its ORIGINAL slot, which quietly
1078
+ // broke the eviction policy below: a file edited hundreds of times over a long
1079
+ // session kept the position of its FIRST edit, so it aged out like a file nobody
1080
+ // had looked at since — and on the next edit it was re-inserted as "new", double-
1081
+ // counting filesTouchedTotal (which is documented as DISTINCT paths). Making the
1082
+ // Map a true LRU-by-touch is what lets the `key !== p` guard below mean anything.
1083
+ ledger.filesTouched.delete(p);
1029
1084
  ledger.filesTouched.set(p, { tool: toolName, edits: (prev?.edits ?? 0) + 1 });
1085
+ // Bound the Map itself, not just its rendering. LEDGER_MAX_FILES caps how many
1086
+ // paths the preamble PRINTS (see ledgerSummary's slice), but the Map was only ever
1087
+ // written to — so a multi-hour run touching thousands of files grew it without
1088
+ // limit, and it is deliberately retained across every compaction. Evict the
1089
+ // least-recently-touched entries once we hold well beyond what can ever be
1090
+ // displayed. Hysteresis (evict down to 2× only once we exceed 4×) keeps this an
1091
+ // occasional bulk sweep instead of a delete on every single write.
1092
+ if (ledger.filesTouched.size > LEDGER_MAX_FILES * 4) {
1093
+ for (const key of ledger.filesTouched.keys()) {
1094
+ if (ledger.filesTouched.size <= LEDGER_MAX_FILES * 2)
1095
+ break;
1096
+ if (key !== p)
1097
+ ledger.filesTouched.delete(key);
1098
+ }
1099
+ }
1030
1100
  }
1031
1101
  // Reward-hacking guard: if this write WEAKENED a test file, record it so the
1032
1102
  // signal survives compaction and can be surfaced before the agent finishes.
@@ -1046,6 +1116,7 @@ function ledgerRecord(ledger, toolName, input, ok, output, exitCode) {
1046
1116
  if (reasons.length && p) {
1047
1117
  for (const reason of reasons) {
1048
1118
  ledger.testIntegrity.push({ path: p, reason });
1119
+ ledger.testIntegrityTotal++;
1049
1120
  }
1050
1121
  if (ledger.testIntegrity.length > LEDGER_MAX_NOTES * 2) {
1051
1122
  ledger.testIntegrity.splice(0, ledger.testIntegrity.length - LEDGER_MAX_NOTES);
@@ -1078,8 +1149,11 @@ function ledgerSummary(ledger) {
1078
1149
  const lines = [];
1079
1150
  if (ledger.filesTouched.size) {
1080
1151
  const files = [...ledger.filesTouched.entries()];
1081
- const shown = files.slice(0, LEDGER_MAX_FILES);
1082
- lines.push(`FILES CHANGED THIS SESSION (${ledger.filesTouched.size}):`);
1152
+ // The TAIL, not the head: the Map is ordered least-recently-touched first, so
1153
+ // slicing from the front showed the OLDEST files and reliably omitted the ones the
1154
+ // agent was working on right now — the opposite of what this preamble is for.
1155
+ const shown = files.slice(-LEDGER_MAX_FILES);
1156
+ lines.push(`FILES CHANGED THIS SESSION (${ledger.filesTouchedTotal || ledger.filesTouched.size}):`);
1083
1157
  for (const [p, meta] of shown) {
1084
1158
  lines.push(` • ${p} (${meta.tool}${meta.edits > 1 ? ` ×${meta.edits}` : ''})`);
1085
1159
  }
@@ -1472,6 +1546,12 @@ async function runAgentLoop(initialMessages, options) {
1472
1546
  let completedRounds = 0;
1473
1547
  let stalledOut = false; // tripped the runaway guard (all-failed rounds)
1474
1548
  let consecutiveErrorRounds = 0; // rounds where every tool call errored
1549
+ // Repeated-identical-failure guard: see the runaway guards below. Tracked
1550
+ // separately from consecutiveErrorRounds because a round can contain a succeeding
1551
+ // call and still be part of a livelock.
1552
+ let repeatedErrorRounds = 0;
1553
+ let lastErrorSignature = '';
1554
+ let stalledRepeatError = null;
1475
1555
  let budget = maxIterations; // extended by auto-continue, capped at hardCap
1476
1556
  let iteration = 0;
1477
1557
  // ─── Verification nudge (GAP D) ───────────────────────────────────────────────
@@ -1498,6 +1578,31 @@ async function runAgentLoop(initialMessages, options) {
1498
1578
  let claimEvidenceNudged = false;
1499
1579
  // GAP E — deterministic progress ledger, preserved verbatim across compactions.
1500
1580
  const ledger = createLedger();
1581
+ // ─── Auto-compact circuit breaker ─────────────────────────────────────────────
1582
+ //
1583
+ // The in-loop compaction trigger below re-derives its pressure from the CURRENT
1584
+ // body on every iteration. That is correct, but it means a compaction which does
1585
+ // not shrink anything leaves the trigger condition still true — so the next
1586
+ // iteration pays for another summariser call over the same (up to ~600KB)
1587
+ // transcript, and so on for the rest of the run. Two ways that happens:
1588
+ //
1589
+ // • autoCompactMessages returns false (summariser stream threw, or came back
1590
+ // empty). Nothing was replaced, so the pressure is unchanged.
1591
+ // • It returns true but cannot get under MAX_BODY_BYTES, because the messages
1592
+ // it must retain (COMPACT_KEEP_MIN) are themselves huge. `lastPromptTokens = 0`
1593
+ // suppresses only the TOKEN trigger; the BYTE trigger fires again immediately.
1594
+ //
1595
+ // Both are invisible to the user (the success notice only prints when `did`), so
1596
+ // the symptom is a long run that silently gets slower and more expensive. Count
1597
+ // consecutive non-productive attempts and stop trying after a few — losing
1598
+ // compaction degrades gracefully (the turn may still fit, and the prune pass
1599
+ // still runs), whereas an unbounded retry loop does not.
1600
+ //
1601
+ // The resume-time compactor already had exactly these guards
1602
+ // (compactMessagesForResume: a bounded loop plus `if (!did) break`); this brings
1603
+ // the in-loop path in line with it.
1604
+ let compactFailures = 0;
1605
+ let compactDisabled = false;
1501
1606
  try {
1502
1607
  for (; iteration < budget; iteration++) {
1503
1608
  if (options.abortSignal?.aborted)
@@ -1546,10 +1651,17 @@ async function runAgentLoop(initialMessages, options) {
1546
1651
  (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
1652
  }
1548
1653
  }
1549
- if (autoCompact && !compacting && (tokenPressure || bytePressure) && messages.length > COMPACT_KEEP_MIN + 2) {
1654
+ if (autoCompact && !compactDisabled && !compacting && (tokenPressure || bytePressure) && messages.length > COMPACT_KEEP_MIN + 2) {
1550
1655
  compacting = true;
1551
1656
  try {
1657
+ // Measured BEFORE, so "did it actually help?" is a fact about bytes rather
1658
+ // than a claim from the compactor. A compaction that returns true but
1659
+ // reclaims nothing is a failure for our purposes — it leaves the trigger
1660
+ // armed for the next iteration, which is precisely the runaway.
1661
+ const bytesBefore = estimateBodyBytes(messages);
1552
1662
  const did = await autoCompactMessages(messages, options, ledger);
1663
+ const bytesAfter = did ? estimateBodyBytes(messages) : bytesBefore;
1664
+ const reclaimed = bytesBefore - bytesAfter;
1553
1665
  if (did) {
1554
1666
  lastPromptTokens = 0; // stale — next usage event refreshes it
1555
1667
  const reason = bytePressure
@@ -1558,6 +1670,24 @@ async function runAgentLoop(initialMessages, options) {
1558
1670
  // Same reasoning as above: this is a system notice about housekeeping,
1559
1671
  // not part of the model's answer — keep it out of the text bubble.
1560
1672
  (options.onNotice ?? options.onText)(`\u267b\ufe0f Auto-compacted earlier conversation to stay within the ${reason}.`);
1673
+ // Refresh local pressure so the rest of THIS iteration sees the new size.
1674
+ bodyBytes = bytesAfter;
1675
+ bytePressure = bodyBytes > MAX_BODY_BYTES;
1676
+ }
1677
+ // Productive == it shrank the body meaningfully. A successful-but-useless
1678
+ // compaction counts as a failure, otherwise the "cannot get under the byte
1679
+ // cap" case would never trip the breaker.
1680
+ if (did && reclaimed >= COMPACT_MIN_RECLAIM_BYTES) {
1681
+ compactFailures = 0;
1682
+ }
1683
+ else if (++compactFailures >= COMPACT_MAX_FAILURES) {
1684
+ compactDisabled = true;
1685
+ // Surfaced ONCE. The user needs to know the automatic safety net is off
1686
+ // (so a context-window error later isn't a total surprise) and what to do
1687
+ // about it, but repeating this every iteration would be its own spam.
1688
+ (options.onNotice ?? options.onText)(`\u26a0\ufe0f Auto-compaction isn't reducing this conversation any further, so it's been switched off ` +
1689
+ `for the rest of this run to avoid repeated summarising. If the context fills up, start a fresh ` +
1690
+ `chat or run /compact manually.`);
1561
1691
  }
1562
1692
  }
1563
1693
  finally {
@@ -1772,9 +1902,19 @@ async function runAgentLoop(initialMessages, options) {
1772
1902
  // and require the agent to either justify each change (legit refactor) or
1773
1903
  // revert it and fix the real code. Deterministic — the signal comes from
1774
1904
  // 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;
1905
+ //
1906
+ // Compared against testIntegrityTotal, NOT testIntegrity.length: the array is a
1907
+ // bounded window that gets trimmed, so once a long session passed ~40 findings
1908
+ // its length stopped growing and could even fall BELOW the already-nudged count,
1909
+ // permanently wedging this condition false and disabling the guard for the rest
1910
+ // of the run. The total only ever increases.
1911
+ if (ledger.testIntegrityTotal > testIntegrityNudgedCount) {
1912
+ // How many are genuinely new, clamped to what the window still holds — the
1913
+ // trimmed-away ones are unrecoverable, and reporting the tail we DO have is
1914
+ // strictly better than reporting nothing.
1915
+ const newCount = Math.min(ledger.testIntegrityTotal - testIntegrityNudgedCount, ledger.testIntegrity.length);
1916
+ const fresh = ledger.testIntegrity.slice(ledger.testIntegrity.length - newCount);
1917
+ testIntegrityNudgedCount = ledger.testIntegrityTotal;
1778
1918
  const bullet = fresh.map((t) => ` • ${t.path}: ${t.reason}`).join('\n');
1779
1919
  messages.push({
1780
1920
  role: 'user',
@@ -2073,16 +2213,46 @@ async function runAgentLoop(initialMessages, options) {
2073
2213
  // tool_result user turn). Let the caller checkpoint progress so a crash
2074
2214
  // mid-run loses only the in-flight step, not the whole session.
2075
2215
  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);
2216
+ // ── Runaway guards ────────────────────────────────────────────────────────
2217
+ //
2218
+ // TWO independent counters, because "stuck" has two shapes and the original
2219
+ // all-failed test only caught the first.
2220
+ //
2221
+ // 1. TOTAL failure: every call in the round errored (a command that always
2222
+ // errors, the user denying every permission). Unambiguous.
2223
+ //
2224
+ // 2. REPEATED failure: the SAME error keeps coming back, round after round,
2225
+ // even though other calls in those rounds succeed. This is the livelock the
2226
+ // `.every()` test missed entirely — one trivially-succeeding sibling (say a
2227
+ // `read_file` alongside an `edit_file` that fails identically every time)
2228
+ // reset the counter to 0 forever, so a genuine loop burned the full
2229
+ // 2000-iteration ceiling instead of stopping at 8. That is the expensive,
2230
+ // user-visible "it just spun for an hour" failure.
2231
+ //
2232
+ // Keyed on tool + error text so a DIFFERENT error each round (real progress
2233
+ // through a chain of distinct problems) does not trip it.
2234
+ const errored = toolResults.filter(({ result }) => result.error !== undefined);
2235
+ const allErrored = toolResults.length > 0 && errored.length === toolResults.length;
2081
2236
  consecutiveErrorRounds = allErrored ? consecutiveErrorRounds + 1 : 0;
2082
2237
  if (consecutiveErrorRounds >= STALL_LIMIT) {
2083
2238
  stalledOut = true;
2084
2239
  break;
2085
2240
  }
2241
+ // Signature of this round's failures, order-independent and truncated so a long
2242
+ // error body (or a path echoed inside it) doesn't make every occurrence unique.
2243
+ const errSignature = errorRoundSignature(errored.map(({ block, result }) => ({ name: block.name, error: String(result.error) })));
2244
+ if (errSignature && errSignature === lastErrorSignature) {
2245
+ repeatedErrorRounds++;
2246
+ }
2247
+ else {
2248
+ repeatedErrorRounds = 0;
2249
+ lastErrorSignature = errSignature;
2250
+ }
2251
+ if (repeatedErrorRounds >= REPEAT_STALL_LIMIT) {
2252
+ stalledOut = true;
2253
+ stalledRepeatError = errored[0] ? String(errored[0].result.error).slice(0, 300) : null;
2254
+ break;
2255
+ }
2086
2256
  // Auto-continue: about to exhaust the current budget but the model is still
2087
2257
  // calling tools (task unfinished) and we're under the ceiling → extend the
2088
2258
  // budget by another segment and keep going, so the agent finishes on its own
@@ -2098,12 +2268,19 @@ async function runAgentLoop(initialMessages, options) {
2098
2268
  // long run never just goes silent. History ends on a tool_result turn, so
2099
2269
  // "continue" resumes exactly where it left off.
2100
2270
  if (!options.abortSignal?.aborted && !completedCleanly) {
2271
+ // Routed through onNotice (falling back to onText) like every other housekeeping
2272
+ // message in this file — these are statements from the harness, not from the model,
2273
+ // and splicing them into the assistant's own bubble reads as if it said them.
2101
2274
  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`);
2275
+ (options.onNotice ?? options.onText)(stalledRepeatError
2276
+ ? `\n🛑 Stopped: the same tool error repeated ${REPEAT_STALL_LIMIT} rounds in a row, so the agent ` +
2277
+ `was looping without making progress. The recurring error was:\n${stalledRepeatError}\n` +
2278
+ `Fix that underlying cause (or grant the needed permission) and send "continue".\n`
2279
+ : `\n🛑 Stopped: the last ${STALL_LIMIT} tool rounds all failed, so the agent looked stuck. ` +
2280
+ `Fix the underlying error (or grant the needed permission) and send "continue".\n`);
2104
2281
  }
2105
2282
  else if (iteration >= budget) {
2106
- options.onText(`\n⏸️ Stopped at the ${budget}-step safety limit — the task may be incomplete. ` +
2283
+ (options.onNotice ?? options.onText)(`\n⏸️ Stopped at the ${budget}-step safety limit — the task may be incomplete. ` +
2107
2284
  `Send "continue" to resume, or raise the limit via "maxIterations" in .nexrall/settings.json ` +
2108
2285
  `(or the NEXRALL_MAX_ITERATIONS env var). Auto-continue can be disabled with "autoContinue": false.\n`);
2109
2286
  }
@@ -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"}