@nexrall/code-core 1.3.1 → 1.4.1

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.
@@ -33,8 +33,15 @@ 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 = void 0;
36
37
  exports.resolveMaxIterations = resolveMaxIterations;
38
+ exports.estimateBodyBytes = estimateBodyBytes;
37
39
  exports.findSafeCutIndex = findSafeCutIndex;
40
+ exports.transcriptOf = transcriptOf;
41
+ exports.createLedger = createLedger;
42
+ exports.ledgerRecord = ledgerRecord;
43
+ exports.ledgerSummary = ledgerSummary;
44
+ exports.pruneOldToolResults = pruneOldToolResults;
38
45
  exports.runAgentLoop = runAgentLoop;
39
46
  const client_1 = require("../api/client");
40
47
  const executor_1 = require("../tools/executor");
@@ -42,6 +49,8 @@ const agentTypes_1 = require("./agentTypes");
42
49
  const rules_1 = require("../permissions/rules");
43
50
  const sandbox_1 = require("../tools/sandbox");
44
51
  const index_1 = require("../plugins/index");
52
+ const testIntegrity_1 = require("./testIntegrity");
53
+ const flaky_1 = require("./flaky");
45
54
  const fs = __importStar(require("fs"));
46
55
  const path = __importStar(require("path"));
47
56
  const child_process_1 = require("child_process");
@@ -391,13 +400,45 @@ async function runSubTask(input, options, agentTypes) {
391
400
  // Compaction only happens at a turn boundary (top of the loop, before the next
392
401
  // streamChat) and only cuts at a "safe" user message — one with no tool_result
393
402
  // blocks — so tool_use/tool_result pairing is never broken.
403
+ // All three code tiers are NATIVELY 1M-context (backend CODE_MODEL_MAP + CTX_LIMITS):
404
+ // turbo → claude-sonnet-5 (1M)
405
+ // pro → claude-opus-4-8 (1M)
406
+ // ultra → claude-fable-5 (1M)
407
+ // Previously turbo/pro were hard-coded to 200K here, which made auto-compact fire at
408
+ // 80% × 200K = 160K tokens — throttling the agent to ~16% of the real window and forcing
409
+ // premature (lossy) summarisation on long tasks. Keep these in sync with backend
410
+ // routes/code.js CTX_LIMITS.
394
411
  const MODEL_CONTEXT_TOKENS = {
395
- turbo: 200000,
396
- pro: 200000,
412
+ turbo: 1000000,
413
+ pro: 1000000,
397
414
  ultra: 1000000,
398
415
  };
399
416
  const AUTO_COMPACT_THRESHOLD = 0.8; // compact when prompt > 80% of the window
400
417
  const COMPACT_KEEP_MIN = 6; // always keep at least the last N messages verbatim
418
+ // Byte-level safety net, independent of the token estimate.
419
+ //
420
+ // Tool-heavy sessions on large codebases accumulate many tool_result blocks
421
+ // (read_file / bash / search output). The token count can still look "under
422
+ // budget" while the SERIALISED body has grown to tens of MB — the char↔token
423
+ // ratio for JSON/code/logs is highly variable, so a token threshold alone does
424
+ // NOT bound the request body size. The backend rejects bodies over its limit
425
+ // (413), which the token-based compactor never anticipates because:
426
+ // • it reacts to lastPromptTokens from the PREVIOUS turn's usage event, so on
427
+ // a freshly-resumed (already-large) session it is 0 and never fires, and
428
+ // • 80% × 1M tokens of tool_result can be 25–45 MB — far past any body limit.
429
+ // This guard measures the ACTUAL body bytes before each send and forces a
430
+ // compaction whenever it crosses the threshold, regardless of the token count.
431
+ // Kept comfortably under the server's 25 MB /api/code limit.
432
+ const MAX_BODY_BYTES = 8 * 1024 * 1024; // 8 MB
433
+ /** Approximate serialised request-body size (bytes) for the messages array. */
434
+ function estimateBodyBytes(messages) {
435
+ try {
436
+ return Buffer.byteLength(JSON.stringify(messages), 'utf-8');
437
+ }
438
+ catch {
439
+ return 0; // circular/unserialisable — don't block on the estimate
440
+ }
441
+ }
401
442
  function resolveAutoCompact(fromOptions, rawSettings) {
402
443
  if (typeof fromOptions === 'boolean')
403
444
  return fromOptions;
@@ -411,6 +452,22 @@ function resolveAutoCompact(fromOptions, rawSettings) {
411
452
  return s;
412
453
  return true;
413
454
  }
455
+ /** Opt-out for the one-shot verification nudge (GAP D). Defaults to on. */
456
+ function resolveVerificationNudge(rawSettings) {
457
+ const env = (process.env.NEXRALL_VERIFY_NUDGE ?? '').toLowerCase();
458
+ if (env === '0' || env === 'false' || env === 'off')
459
+ return false;
460
+ if (env === '1' || env === 'true' || env === 'on')
461
+ return true;
462
+ const s = rawSettings?.verifyNudge;
463
+ if (typeof s === 'boolean')
464
+ return s;
465
+ return true;
466
+ }
467
+ /** Tools that mutate the filesystem — used by the verification nudge (GAP D). */
468
+ exports.WRITE_TOOL_NAMES = new Set(['write_file', 'edit_file', 'multi_edit', 'delete_file', 'move_file', 'copy_file', 'notebook_edit']);
469
+ /** Heuristic: does a bash command look like it's running tests/build/lint/typecheck? (GAP D) */
470
+ exports.VERIFY_CMD_RE = /\b(npm|yarn|pnpm)\s+(run\s+)?(test|build|lint|typecheck|tsc)\b|\bpytest\b|\bgo\s+(test|vet|build)\b|\btsc\b|\beslint\b|\bcargo\s+(test|build|check)\b/i;
414
471
  /**
415
472
  * Find the latest index ≤ maxIdx where history can be cut safely.
416
473
  *
@@ -431,7 +488,21 @@ function findSafeCutIndex(messages, maxIdx) {
431
488
  }
432
489
  return -1;
433
490
  }
434
- /** Render messages to a plain-text transcript for the summariser (tool noise truncated). */
491
+ // Hard ceiling on the transcript we hand to the summariser. Per-block truncation
492
+ // alone does NOT bound the total: a very long run has thousands of blocks, so the
493
+ // concatenated transcript can itself exceed the summariser call's context window →
494
+ // the summarise request 400s → autoCompactMessages returns false → NO compaction
495
+ // happens exactly when the session is largest (the context-wall failure mode).
496
+ // ~600K chars ≈ 150K tokens, well under a 1M window even with prompt overhead.
497
+ const MAX_TRANSCRIPT_CHARS = 600000;
498
+ /**
499
+ * Render messages to a plain-text transcript for the summariser (tool noise
500
+ * truncated per-block AND the whole transcript hard-capped). When the transcript
501
+ * would exceed MAX_TRANSCRIPT_CHARS we keep the HEAD (original task + early
502
+ * decisions) and the TAIL (most-recent, highest-signal context) and drop the
503
+ * middle — a middle-out elision that preserves both "what we set out to do" and
504
+ * "where we are now", which is what the continuation summary needs most.
505
+ */
435
506
  function transcriptOf(messages) {
436
507
  const parts = [];
437
508
  for (const m of messages) {
@@ -447,18 +518,207 @@ function transcriptOf(messages) {
447
518
  }
448
519
  }
449
520
  }
450
- return parts.join('\n');
521
+ const full = parts.join('\n');
522
+ if (full.length <= MAX_TRANSCRIPT_CHARS)
523
+ return full;
524
+ // Middle-out: keep 40% head, 60% tail (recent context is higher-signal for
525
+ // continuation). Slice on line boundaries so we don't cut a line in half.
526
+ const headBudget = Math.floor(MAX_TRANSCRIPT_CHARS * 0.4);
527
+ const tailBudget = MAX_TRANSCRIPT_CHARS - headBudget;
528
+ const head = full.slice(0, headBudget);
529
+ const tail = full.slice(full.length - tailBudget);
530
+ const dropped = full.length - head.length - tail.length;
531
+ return `${head}\n\n[… ${dropped} chars of mid-session transcript elided to fit the summariser's context window …]\n\n${tail}`;
532
+ }
533
+ // ─── Structured progress ledger (GAP E) ────────────────────────────────────────
534
+ //
535
+ // The single biggest long-horizon failure mode (industry-wide "context rot") is
536
+ // that each auto-compaction summarises a transcript that ALREADY contains a prior
537
+ // summary → summary-of-summary → fidelity decays: the agent forgets which files it
538
+ // edited, whether tests passed, what's still open. Prose summarisation is inherently
539
+ // lossy and gets worse every round.
540
+ //
541
+ // Defence: maintain a DETERMINISTIC, append-only ledger of high-signal facts derived
542
+ // directly from tool calls — files created/edited (with count), commands verified
543
+ // (test/build/lint) and their pass/fail, and explicit open TODOs. This is built from
544
+ // structured tool data (NOT model output), so it is LOSSLESS and never degrades. We
545
+ // inject it VERBATIM into every compaction preamble, so no matter how many times the
546
+ // prose summary is re-summarised, the concrete "what changed / what's verified /
547
+ // what's left" facts survive intact across an arbitrarily long run.
548
+ const LEDGER_MAX_FILES = 60; // cap the file list so the preamble can't balloon
549
+ const LEDGER_MAX_NOTES = 20; // cap verification/among notes
550
+ function createLedger() {
551
+ return { filesTouched: new Map(), verifications: [], testIntegrity: [], epoch: 0 };
552
+ }
553
+ /** Record one tool call's effect on the ledger (deterministic, no model call). */
554
+ function ledgerRecord(ledger, toolName, input, ok, output) {
555
+ if (exports.WRITE_TOOL_NAMES.has(toolName)) {
556
+ if (!ok)
557
+ return; // a FAILED write changed nothing — not a durable fact
558
+ // A successful source write advances the mutation epoch: any verification
559
+ // run after this point has different inputs than runs before it.
560
+ ledger.epoch += 1;
561
+ const p = typeof input?.path === 'string' ? input.path : undefined;
562
+ if (p) {
563
+ const prev = ledger.filesTouched.get(p);
564
+ ledger.filesTouched.set(p, { tool: toolName, edits: (prev?.edits ?? 0) + 1 });
565
+ }
566
+ // Reward-hacking guard: if this write WEAKENED a test file, record it so the
567
+ // signal survives compaction and can be surfaced before the agent finishes.
568
+ const reasons = [];
569
+ // write_file overwrites carry a marker computed by the executor (which had the
570
+ // prior on-disk content) — it detects REMOVED assertions/cases, not just
571
+ // additive skips/tautologies. Prefer it when present.
572
+ const markerReasons = toolName === 'write_file' ? (0, testIntegrity_1.decodeTestIntegrityMarker)(output) : [];
573
+ if (markerReasons.length) {
574
+ reasons.push(...markerReasons);
575
+ }
576
+ else {
577
+ const ti = (0, testIntegrity_1.analyzeWriteToolForTestIntegrity)(toolName, input);
578
+ if (ti?.suspicious)
579
+ reasons.push(...ti.findings.map((f) => f.reason));
580
+ }
581
+ if (reasons.length && p) {
582
+ for (const reason of reasons) {
583
+ ledger.testIntegrity.push({ path: p, reason });
584
+ }
585
+ if (ledger.testIntegrity.length > LEDGER_MAX_NOTES * 2) {
586
+ ledger.testIntegrity.splice(0, ledger.testIntegrity.length - LEDGER_MAX_NOTES);
587
+ }
588
+ }
589
+ }
590
+ else if (toolName === 'bash') {
591
+ const cmd = String(input?.command ?? '').trim();
592
+ if (cmd && exports.VERIFY_CMD_RE.test(cmd)) {
593
+ // Record BOTH outcomes: a FAILED test/build is the single most important
594
+ // fact to carry across a compaction (it tells the agent work is NOT done).
595
+ // ok===true means the command exited 0 (executor sets error on non-zero).
596
+ ledger.verifications.push({ cmd: cmd.slice(0, 120), ok, epoch: ledger.epoch });
597
+ if (ledger.verifications.length > LEDGER_MAX_NOTES * 2) {
598
+ ledger.verifications.splice(0, ledger.verifications.length - LEDGER_MAX_NOTES);
599
+ }
600
+ }
601
+ }
602
+ }
603
+ /** Render the ledger as a compact, verbatim block for the compaction preamble. */
604
+ function ledgerSummary(ledger) {
605
+ const lines = [];
606
+ if (ledger.filesTouched.size) {
607
+ const files = [...ledger.filesTouched.entries()];
608
+ const shown = files.slice(0, LEDGER_MAX_FILES);
609
+ lines.push(`FILES CHANGED THIS SESSION (${ledger.filesTouched.size}):`);
610
+ for (const [p, meta] of shown) {
611
+ lines.push(` • ${p} (${meta.tool}${meta.edits > 1 ? ` ×${meta.edits}` : ''})`);
612
+ }
613
+ if (files.length > shown.length)
614
+ lines.push(` • … and ${files.length - shown.length} more`);
615
+ }
616
+ if (ledger.verifications.length) {
617
+ const recent = ledger.verifications.slice(-LEDGER_MAX_NOTES);
618
+ lines.push(`VERIFICATION RUNS (most recent ${recent.length}):`);
619
+ for (const v of recent)
620
+ lines.push(` • [${v.ok ? 'PASS' : 'FAIL'}] ${v.cmd}`);
621
+ }
622
+ if (ledger.testIntegrity.length) {
623
+ const recent = ledger.testIntegrity.slice(-LEDGER_MAX_NOTES);
624
+ lines.push(`⚠ TEST-INTEGRITY ALERTS (test files were weakened — must justify or revert):`);
625
+ for (const t of recent)
626
+ lines.push(` • ${t.path}: ${t.reason}`);
627
+ }
628
+ const flaky = (0, flaky_1.detectFlaky)(ledger.verifications);
629
+ if (flaky.length) {
630
+ lines.push(`⚠ FLAKY TESTS (same command flipped PASS↔FAIL with no edit between — a green run proves nothing):`);
631
+ for (const f of flaky.slice(0, LEDGER_MAX_NOTES)) {
632
+ lines.push(` • ${f.cmd} (${f.passes} pass / ${f.fails} fail at identical code)`);
633
+ }
634
+ }
635
+ return lines.join('\n');
636
+ }
637
+ // How many of the most-recent messages keep their tool_result content verbatim.
638
+ // Older tool_result bodies are the bulk of a large body and are the safest thing
639
+ // to shed first (the model has already acted on them), so we replace their content
640
+ // with a short stub while KEEPING the block (so tool_use/tool_result pairing and
641
+ // turn structure stay intact — unlike summarisation, which drops whole turns).
642
+ const PRUNE_KEEP_RECENT = 8;
643
+ const PRUNE_STUB_KEEP_CHARS = 400; // keep a short head of each pruned result for context
644
+ // Marker sentinel appended to a pruned tool_result's content. We detect
645
+ // "already pruned" by this suffix rather than by an out-of-schema field on the
646
+ // block, because the block object is serialised verbatim onto the request body
647
+ // and forwarded to Anthropic — any extra property (e.g. a `_pruned` flag) would
648
+ // be rejected as an unknown field on a content block (400). Encoding the state
649
+ // inside the (string) content keeps the wire payload schema-clean AND idempotent.
650
+ const PRUNE_MARKER = '\n\n[… ';
651
+ const PRUNE_MARKER_TAIL = ' pruned to conserve context. Re-run the tool if you need the full result.]';
652
+ /**
653
+ * Lossy-but-structure-preserving prune: shrink OLD, large tool_result blocks in
654
+ * place, keeping the last PRUNE_KEEP_RECENT messages untouched. This is tried
655
+ * BEFORE summarisation because it:
656
+ * • keeps every turn and every tool_use/tool_result pair (API stays valid),
657
+ * • never makes an extra model call (summarisation does — cost + latency),
658
+ * • degrades gracefully on repeat (summarise-of-summarise loses the most on
659
+ * long runs; pruning just trims already-consumed output further).
660
+ *
661
+ * IMPORTANT: pruned state is encoded in the content string (PRUNE_MARKER_TAIL
662
+ * suffix), NOT as an extra property on the block — a stray field on a content
663
+ * block is rejected by the Anthropic API as an unknown key (400). This keeps the
664
+ * serialised body schema-clean while remaining idempotent across repeat calls.
665
+ *
666
+ * Returns the number of bytes reclaimed (0 if nothing was prunable).
667
+ */
668
+ function pruneOldToolResults(messages) {
669
+ const cutoff = messages.length - PRUNE_KEEP_RECENT;
670
+ if (cutoff <= 1)
671
+ return 0;
672
+ let reclaimed = 0;
673
+ for (let i = 0; i < cutoff; i++) {
674
+ const m = messages[i];
675
+ if (!Array.isArray(m.content))
676
+ continue;
677
+ for (const b of m.content) {
678
+ if (b.type !== 'tool_result')
679
+ continue;
680
+ const text = typeof b.content === 'string' ? b.content : JSON.stringify(b.content ?? '');
681
+ if (text.endsWith(PRUNE_MARKER_TAIL))
682
+ continue; // already pruned (idempotent)
683
+ if (text.length <= PRUNE_STUB_KEEP_CHARS + 80)
684
+ continue; // already small
685
+ const head = text.slice(0, PRUNE_STUB_KEEP_CHARS);
686
+ const omitted = text.length - head.length;
687
+ b.content = `${head}${PRUNE_MARKER}${omitted} chars of earlier tool output${PRUNE_MARKER_TAIL}`;
688
+ reclaimed += omitted;
689
+ }
690
+ }
691
+ return reclaimed;
451
692
  }
452
693
  /**
453
694
  * Compact `messages` in place: summarise everything before a safe cut point and
454
695
  * replace it with a summary preamble. Returns true if compaction happened.
455
696
  */
456
- async function autoCompactMessages(messages, options) {
697
+ /** Extract the first user turn's plain text — the ORIGINAL task/goal. */
698
+ function originalTaskText(messages) {
699
+ const first = messages.find((m) => m.role === 'user');
700
+ if (!first || !Array.isArray(first.content))
701
+ return '';
702
+ return first.content
703
+ .filter((b) => b.type === 'text' && b.text)
704
+ .map((b) => b.text)
705
+ .join('\n')
706
+ .trim();
707
+ }
708
+ async function autoCompactMessages(messages, options, ledger) {
457
709
  const cut = findSafeCutIndex(messages, messages.length - COMPACT_KEEP_MIN);
458
710
  if (cut < 2)
459
711
  return false; // nothing meaningful to fold
460
712
  const toSummarize = messages.slice(0, cut);
461
713
  const kept = messages.slice(cut);
714
+ // Pin the ORIGINAL task verbatim. findSafeCutIndex can (and on a long single
715
+ // run usually does) cut PAST the first user turn, folding the user's actual
716
+ // goal into the lossy summary — after a few compactions the agent drifts off
717
+ // what it was asked to do. We re-inject the first user turn's text verbatim
718
+ // into the replacement preamble so the objective survives every compaction.
719
+ // (We cannot keep it as a separate user message: the API requires alternating
720
+ // roles and kept[0] is already an assistant turn — two user turns would 400.)
721
+ const originalTask = originalTaskText(toSummarize);
462
722
  const summaryPrompt = `Summarize this coding-session transcript into concise bullet points the assistant needs to continue the work: ` +
463
723
  `key decisions, files changed (and how), commands run, unresolved problems, and user preferences. Max 400 words.\n\n` +
464
724
  transcriptOf(toSummarize);
@@ -488,7 +748,17 @@ async function autoCompactMessages(messages, options) {
488
748
  // and no orphaned tool_result is left behind. We intentionally do NOT insert
489
749
  // an assistant-ack here: that would put two assistant messages back-to-back
490
750
  // (kept[0] is already an assistant), which the API rejects.
491
- messages.splice(0, cut, { role: 'user', content: [{ type: 'text', text: `[Auto-compacted ${toSummarize.length} earlier messages]\n\nSummary of the earlier conversation so far:\n${summary}\n\nContinue the work from here.` }] });
751
+ const taskBlock = originalTask
752
+ ? `ORIGINAL TASK (verbatim — keep working toward this, do not lose sight of it):\n${originalTask}\n\n`
753
+ : '';
754
+ // GAP E — the deterministic ledger (files changed + verification pass/fail) is
755
+ // injected VERBATIM, so these concrete facts never decay through repeated
756
+ // summary-of-summary compactions the way the prose summary does.
757
+ const ledgerText = ledger ? ledgerSummary(ledger) : '';
758
+ const ledgerBlock = ledgerText
759
+ ? `PROGRESS LEDGER (authoritative, machine-tracked — trust this over the prose summary for what changed/verified):\n${ledgerText}\n\n`
760
+ : '';
761
+ messages.splice(0, cut, { role: 'user', content: [{ type: 'text', text: `[Auto-compacted ${toSummarize.length} earlier messages]\n\n${taskBlock}${ledgerBlock}Summary of the earlier conversation so far:\n${summary}\n\nContinue the work from here.` }] });
492
762
  // `kept` follows automatically since splice only replaced the head.
493
763
  void kept;
494
764
  return true;
@@ -511,6 +781,7 @@ async function runAgentLoop(initialMessages, options) {
511
781
  const maxIterations = resolveMaxIterations(options.maxIterations, settings.raw);
512
782
  const autoContinue = resolveAutoContinue(options.autoContinue, settings.raw);
513
783
  const autoCompact = resolveAutoCompact(options.autoCompact, settings.raw);
784
+ const verifyNudgeOn = resolveVerificationNudge(settings.raw);
514
785
  const contextWindow = MODEL_CONTEXT_TOKENS[model] ?? 200000;
515
786
  // Live prompt-size estimate, updated from usage events after every stream.
516
787
  let lastPromptTokens = 0;
@@ -532,20 +803,63 @@ async function runAgentLoop(initialMessages, options) {
532
803
  let consecutiveErrorRounds = 0; // rounds where every tool call errored
533
804
  let budget = maxIterations; // extended by auto-continue, capped at hardCap
534
805
  let iteration = 0;
806
+ // ─── Verification nudge (GAP D) ───────────────────────────────────────────────
807
+ // Coding agents commonly claim "done" after editing files without ever running a
808
+ // build/test/lint command to confirm the change actually works (the industry's
809
+ // unsolved "verification problem" — we can't guarantee correctness, but we CAN
810
+ // make the agent check its own work when it visibly skipped that step). This is a
811
+ // single one-shot text nudge, NOT a forced extra model/sub-agent call: cheap, and
812
+ // the agent can decline it if verification genuinely isn't applicable (e.g. a
813
+ // docs-only change) since it's a suggestion appended before the turn ends, not a
814
+ // blocking gate.
815
+ let filesMutatedSinceVerify = false;
816
+ let ranVerificationCmd = false;
817
+ let verificationNudgeSent = false;
818
+ // Reward-hacking guard: number of test-integrity findings already surfaced,
819
+ // so the one-shot nudge fires once per NEW batch of weakened-test signals.
820
+ let testIntegrityNudgedCount = 0;
821
+ // Flaky-test guard: commands already nudged about, so we only warn about a
822
+ // newly-detected flaky command once.
823
+ const flakyNudgedCmds = new Set();
824
+ // GAP E — deterministic progress ledger, preserved verbatim across compactions.
825
+ const ledger = createLedger();
535
826
  try {
536
827
  for (; iteration < budget; iteration++) {
537
828
  if (options.abortSignal?.aborted)
538
829
  break;
539
- // Auto-compact: if the last request's prompt crossed the threshold,
540
- // summarise older history before the next stream so we never hit the
541
- // context-window wall mid-task. Runs at a turn boundary only.
542
- if (autoCompact && !compacting && lastPromptTokens > contextWindow * AUTO_COMPACT_THRESHOLD && messages.length > COMPACT_KEEP_MIN + 2) {
830
+ // Auto-compact: summarise older history before the next stream so we never
831
+ // hit the context-window wall or the backend body-size limit mid-task.
832
+ // Runs at a turn boundary only. Two independent triggers:
833
+ // 1. TOKEN pressure the last request's prompt crossed 80% of the window.
834
+ // 2. BYTE pressure — the serialised body has grown past MAX_BODY_BYTES.
835
+ // The byte trigger is what catches tool-heavy runs whose body balloons past
836
+ // the server's 413 limit while the token count still looks fine (and it fires
837
+ // even on turn 0 of a resumed large session, where lastPromptTokens is 0).
838
+ let bodyBytes = estimateBodyBytes(messages);
839
+ const tokenPressure = lastPromptTokens > contextWindow * AUTO_COMPACT_THRESHOLD;
840
+ let bytePressure = bodyBytes > MAX_BODY_BYTES;
841
+ // Byte pressure first tries the CHEAP, structure-preserving prune (no model
842
+ // call, keeps every turn). Only if that isn't enough do we fall through to
843
+ // summarisation below. This keeps long runs coherent — summarise-of-summarise
844
+ // is the main cause of an agent "forgetting" what it did earlier.
845
+ if (autoCompact && !compacting && bytePressure && messages.length > PRUNE_KEEP_RECENT + 2) {
846
+ const reclaimed = pruneOldToolResults(messages);
847
+ if (reclaimed > 0) {
848
+ bodyBytes = estimateBodyBytes(messages);
849
+ bytePressure = bodyBytes > MAX_BODY_BYTES;
850
+ options.onText(`\n\u267b\ufe0f Trimmed ~${(reclaimed / (1024 * 1024)).toFixed(1)}MB of older tool output to conserve context.\n`);
851
+ }
852
+ }
853
+ if (autoCompact && !compacting && (tokenPressure || bytePressure) && messages.length > COMPACT_KEEP_MIN + 2) {
543
854
  compacting = true;
544
855
  try {
545
- const did = await autoCompactMessages(messages, options);
856
+ const did = await autoCompactMessages(messages, options, ledger);
546
857
  if (did) {
547
858
  lastPromptTokens = 0; // stale — next usage event refreshes it
548
- options.onText(`\n\u267b\ufe0f Auto-compacted earlier conversation to stay within the context window.\n`);
859
+ const reason = bytePressure
860
+ ? `body ~${(bodyBytes / (1024 * 1024)).toFixed(1)}MB`
861
+ : 'context window';
862
+ options.onText(`\n\u267b\ufe0f Auto-compacted earlier conversation to stay within the ${reason}.\n`);
549
863
  }
550
864
  }
551
865
  finally {
@@ -664,6 +978,69 @@ async function runAgentLoop(initialMessages, options) {
664
978
  messages.push({ role: 'user', content: [{ type: 'text', text }] });
665
979
  continue;
666
980
  }
981
+ // Reward-hacking guard (A4): the agent is about to finish, but it WEAKENED
982
+ // one or more test files this session (removed assertions, added .skip,
983
+ // introduced tautologies, commented out cases). Frontier agents let this
984
+ // pass silently and report "tests pass". We surface every NEW finding once
985
+ // and require the agent to either justify each change (legit refactor) or
986
+ // revert it and fix the real code. Deterministic — the signal comes from
987
+ // diff structure, not model self-report, so it can't be gamed away.
988
+ if (ledger.testIntegrity.length > testIntegrityNudgedCount) {
989
+ const fresh = ledger.testIntegrity.slice(testIntegrityNudgedCount);
990
+ testIntegrityNudgedCount = ledger.testIntegrity.length;
991
+ const bullet = fresh.map((t) => ` • ${t.path}: ${t.reason}`).join('\n');
992
+ messages.push({
993
+ role: 'user',
994
+ content: [{
995
+ type: 'text',
996
+ text: 'STOP — test-integrity check. Before finishing, I detected that you WEAKENED test(s) this session:\n' +
997
+ bullet +
998
+ '\n\nMaking tests pass by weakening them (skipping, deleting assertions/cases, or adding always-true checks) is not acceptable — it hides real failures. For EACH item above, either:\n' +
999
+ ' 1. Revert the weakening and fix the actual code so the ORIGINAL test passes, or\n' +
1000
+ ' 2. Justify concretely why the change is a legitimate test refactor (e.g. the behaviour was intentionally removed per the task, a duplicate test, or the assertion was genuinely wrong) — and confirm the remaining tests still meaningfully cover the behaviour.\n' +
1001
+ 'Then re-run the test suite to prove it passes for real.',
1002
+ }],
1003
+ });
1004
+ continue;
1005
+ }
1006
+ // Flaky-test guard: a command that flipped PASS↔FAIL with no edit between
1007
+ // the differing runs is non-deterministic — a green run of it proves
1008
+ // nothing, and the agent may be (consciously or not) re-running until it
1009
+ // goes green. Warn once per flaky command before letting the run end.
1010
+ {
1011
+ const flaky = (0, flaky_1.detectFlaky)(ledger.verifications).filter((f) => !flakyNudgedCmds.has(f.cmd));
1012
+ if (flaky.length) {
1013
+ for (const f of flaky)
1014
+ flakyNudgedCmds.add(f.cmd);
1015
+ const bullet = flaky.map((f) => ` • ${f.cmd} (${f.passes} pass / ${f.fails} fail on identical code)`).join('\n');
1016
+ messages.push({
1017
+ role: 'user',
1018
+ content: [{
1019
+ type: 'text',
1020
+ text: 'STOP — flaky-test check. These command(s) produced BOTH a pass and a fail with NO code change in between, so they are non-deterministic:\n' +
1021
+ bullet +
1022
+ '\n\nA green run of a flaky test proves nothing, and re-running until it passes is not a fix. Do NOT declare success on this basis. Instead: identify the source of non-determinism (unseeded RNG, timing/sleep races, test-ordering or shared-state dependence, wall-clock/timezone, network) and make the test deterministic — or, if it is out of scope, say so explicitly and flag it as pre-existing flakiness rather than treating the passing run as proof.',
1023
+ }],
1024
+ });
1025
+ continue;
1026
+ }
1027
+ }
1028
+ // One-shot verification nudge (GAP D): the agent is about to declare the task
1029
+ // done, but it edited files this run and never ran a build/test/lint command
1030
+ // to confirm the change works. Ask ONCE — if it still finishes without
1031
+ // verifying (e.g. a docs-only change, or no test suite exists), we respect
1032
+ // that and end normally rather than looping forever on the same nudge.
1033
+ if (verifyNudgeOn && filesMutatedSinceVerify && !ranVerificationCmd && !verificationNudgeSent) {
1034
+ verificationNudgeSent = true;
1035
+ messages.push({
1036
+ role: 'user',
1037
+ content: [{
1038
+ type: 'text',
1039
+ text: 'Before finishing: you edited file(s) in this task but I don\'t see a build/test/lint/typecheck command run to confirm the change works. If this project has one, please run it now and fix any failures. If verification genuinely doesn\'t apply here (e.g. docs-only change, no test suite), just say so and finish.',
1040
+ }],
1041
+ });
1042
+ continue;
1043
+ }
667
1044
  runSimpleHooks(hooks.PostMessageComplete, options.workDir);
668
1045
  completedCleanly = true;
669
1046
  break;
@@ -745,10 +1122,34 @@ async function runAgentLoop(initialMessages, options) {
745
1122
  }
746
1123
  }
747
1124
  }
1125
+ // Capture the raw output (may carry the invisible test-integrity
1126
+ // marker from a write_file overwrite) for the ledger, then strip the
1127
+ // marker so neither the UI (onToolResult) nor the model ever see it.
1128
+ let rawOutput;
1129
+ if (name === 'write_file' && result.output) {
1130
+ rawOutput = result.output;
1131
+ result.output = (0, testIntegrity_1.stripTestIntegrityMarker)(result.output);
1132
+ }
748
1133
  // Notify caller about result
749
1134
  options.onToolResult(name, result);
750
- return { block: { ...block, id }, result };
1135
+ return { block: { ...block, id }, result, rawOutput };
751
1136
  }));
1137
+ // Track whether files were mutated / verified this run, for the one-shot
1138
+ // end-of-task nudge below (GAP D — see declaration above).
1139
+ for (const { block, result, rawOutput } of toolResults) {
1140
+ const ok = result.error === undefined;
1141
+ // GAP E — feed every successful effect into the deterministic ledger.
1142
+ // rawOutput carries the (already-stripped-from-view) test-integrity marker.
1143
+ ledgerRecord(ledger, block.name, block.input, ok, rawOutput ?? result.output);
1144
+ if (!ok)
1145
+ continue; // failed calls don't count either way
1146
+ if (exports.WRITE_TOOL_NAMES.has(block.name))
1147
+ filesMutatedSinceVerify = true;
1148
+ else if (block.name === 'bash' && exports.VERIFY_CMD_RE.test(String(block.input?.command ?? ''))) {
1149
+ ranVerificationCmd = true;
1150
+ filesMutatedSinceVerify = false; // verified — reset until the next mutation
1151
+ }
1152
+ }
752
1153
  // 6. Build tool_result message and append to history
753
1154
  const toolResultBlocks = toolResults.map(({ block, result }) => ({
754
1155
  type: 'tool_result',
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Test-Integrity Guard — deterministic reward-hacking detection.
3
+ *
4
+ * THE PROBLEM (unsolved by every frontier coding agent today):
5
+ * A model asked to "make the tests pass" can satisfy that objective two ways:
6
+ * (a) fix the code so the existing tests pass ← what we want
7
+ * (b) weaken the tests so they pass regardless ← reward hacking
8
+ * (b) includes: deleting assertions, adding `.skip`/`.only`/`xit`, turning a
9
+ * real assertion into a tautology (`assert True`, `expect(true).toBe(true)`),
10
+ * or commenting out / deleting whole test cases. The agent then honestly
11
+ * reports "tests pass" and the user believes the work is done. Frontier agents
12
+ * mitigate this only with prompt instructions ("don't cheat") — there is no
13
+ * deterministic detector. METR / Anthropic sabotage evals confirm every strong
14
+ * model does this measurably under pressure.
15
+ *
16
+ * THE APPROACH:
17
+ * Analyse the EDIT PAYLOAD of any write to a test file (old_string→new_string
18
+ * for edit_file / multi_edit; content for write_file). Deterministically count
19
+ * assertion / test-case / skip-marker deltas. When a test edit's NET effect is
20
+ * "fewer or weaker tests" we flag it — with a concrete, human-readable reason.
21
+ * No model output is trusted; this is pure structural analysis of the diff, so
22
+ * it cannot itself be hallucinated or gamed.
23
+ *
24
+ * This is a heuristic signal, not a proof: legitimate refactors (renaming a test,
25
+ * splitting a file) can reduce counts. So the guard never BLOCKS — it surfaces a
26
+ * one-shot nudge asking the agent to justify the change, and records it in the
27
+ * progress ledger so it survives compaction. That converts a silent, invisible
28
+ * failure into an explicit, reviewable decision.
29
+ */
30
+ export interface TestEditFinding {
31
+ /** Human-readable reason the edit looks like test-weakening. */
32
+ reason: string;
33
+ /** Coarse signal type for aggregation. */
34
+ kind: 'skip-added' | 'assertion-removed' | 'tautology-added' | 'testcase-removed' | 'body-commented';
35
+ }
36
+ export interface TestIntegrityResult {
37
+ isTestFile: boolean;
38
+ suspicious: boolean;
39
+ findings: TestEditFinding[];
40
+ }
41
+ export declare function isTestFile(path: string): boolean;
42
+ /**
43
+ * Analyse a single test-file edit (old fragment → new fragment). For write_file
44
+ * pass ('', fullNewContent) — only additive signals (skips, tautologies) fire.
45
+ */
46
+ export declare function analyzeTestEdit(path: string, oldText: string, newText: string): TestIntegrityResult;
47
+ /**
48
+ * Extract the (old, new) text fragments to analyse from a write-tool input.
49
+ * Returns null for non-test files or tools we can't inspect.
50
+ *
51
+ * - edit_file: { old_string, new_string }
52
+ * - multi_edit: concatenate all edits' old / new
53
+ * - write_file: ('', content) — additive-only detection (no prior content here)
54
+ */
55
+ export declare function analyzeWriteToolForTestIntegrity(toolName: string, input: Record<string, unknown> | undefined): TestIntegrityResult | null;
56
+ /** Encode findings as an invisible marker to append to a tool's output string. */
57
+ export declare function encodeTestIntegrityMarker(findings: TestEditFinding[]): string;
58
+ /** Extract weakening reasons from a tool output that may carry the marker. */
59
+ export declare function decodeTestIntegrityMarker(output: string | undefined): string[];
60
+ /** Strip the (invisible) marker from output before it is shown to the model. */
61
+ export declare function stripTestIntegrityMarker(output: string | undefined): string;
62
+ //# sourceMappingURL=testIntegrity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"testIntegrity.d.ts","sourceRoot":"","sources":["../../src/agent/testIntegrity.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,MAAM,WAAW,eAAe;IAC9B,gEAAgE;IAChE,MAAM,EAAE,MAAM,CAAC;IACf,0CAA0C;IAC1C,IAAI,EAAE,YAAY,GAAG,mBAAmB,GAAG,iBAAiB,GAAG,kBAAkB,GAAG,gBAAgB,CAAC;CACtG;AAED,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,OAAO,CAAC;IACpB,UAAU,EAAE,OAAO,CAAC;IACpB,QAAQ,EAAE,eAAe,EAAE,CAAC;CAC7B;AAQD,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAGhD;AA4ED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,mBAAmB,CAmDnG;AAED;;;;;;;GAOG;AACH,wBAAgB,gCAAgC,CAC9C,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GACzC,mBAAmB,GAAG,IAAI,CA8B5B;AAcD,kFAAkF;AAClF,wBAAgB,yBAAyB,CAAC,QAAQ,EAAE,eAAe,EAAE,GAAG,MAAM,CAI7E;AAED,8EAA8E;AAC9E,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,EAAE,CAQ9E;AAED,gFAAgF;AAChF,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAQ3E"}