@nexrall/code-core 1.3.0 → 1.4.0

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,195 @@ 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) {
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 ti = (0, testIntegrity_1.analyzeWriteToolForTestIntegrity)(toolName, input);
569
+ if (ti?.suspicious && p) {
570
+ for (const f of ti.findings) {
571
+ ledger.testIntegrity.push({ path: p, reason: f.reason });
572
+ }
573
+ if (ledger.testIntegrity.length > LEDGER_MAX_NOTES * 2) {
574
+ ledger.testIntegrity.splice(0, ledger.testIntegrity.length - LEDGER_MAX_NOTES);
575
+ }
576
+ }
577
+ }
578
+ else if (toolName === 'bash') {
579
+ const cmd = String(input?.command ?? '').trim();
580
+ if (cmd && exports.VERIFY_CMD_RE.test(cmd)) {
581
+ // Record BOTH outcomes: a FAILED test/build is the single most important
582
+ // fact to carry across a compaction (it tells the agent work is NOT done).
583
+ // ok===true means the command exited 0 (executor sets error on non-zero).
584
+ ledger.verifications.push({ cmd: cmd.slice(0, 120), ok, epoch: ledger.epoch });
585
+ if (ledger.verifications.length > LEDGER_MAX_NOTES * 2) {
586
+ ledger.verifications.splice(0, ledger.verifications.length - LEDGER_MAX_NOTES);
587
+ }
588
+ }
589
+ }
590
+ }
591
+ /** Render the ledger as a compact, verbatim block for the compaction preamble. */
592
+ function ledgerSummary(ledger) {
593
+ const lines = [];
594
+ if (ledger.filesTouched.size) {
595
+ const files = [...ledger.filesTouched.entries()];
596
+ const shown = files.slice(0, LEDGER_MAX_FILES);
597
+ lines.push(`FILES CHANGED THIS SESSION (${ledger.filesTouched.size}):`);
598
+ for (const [p, meta] of shown) {
599
+ lines.push(` • ${p} (${meta.tool}${meta.edits > 1 ? ` ×${meta.edits}` : ''})`);
600
+ }
601
+ if (files.length > shown.length)
602
+ lines.push(` • … and ${files.length - shown.length} more`);
603
+ }
604
+ if (ledger.verifications.length) {
605
+ const recent = ledger.verifications.slice(-LEDGER_MAX_NOTES);
606
+ lines.push(`VERIFICATION RUNS (most recent ${recent.length}):`);
607
+ for (const v of recent)
608
+ lines.push(` • [${v.ok ? 'PASS' : 'FAIL'}] ${v.cmd}`);
609
+ }
610
+ if (ledger.testIntegrity.length) {
611
+ const recent = ledger.testIntegrity.slice(-LEDGER_MAX_NOTES);
612
+ lines.push(`⚠ TEST-INTEGRITY ALERTS (test files were weakened — must justify or revert):`);
613
+ for (const t of recent)
614
+ lines.push(` • ${t.path}: ${t.reason}`);
615
+ }
616
+ const flaky = (0, flaky_1.detectFlaky)(ledger.verifications);
617
+ if (flaky.length) {
618
+ lines.push(`⚠ FLAKY TESTS (same command flipped PASS↔FAIL with no edit between — a green run proves nothing):`);
619
+ for (const f of flaky.slice(0, LEDGER_MAX_NOTES)) {
620
+ lines.push(` • ${f.cmd} (${f.passes} pass / ${f.fails} fail at identical code)`);
621
+ }
622
+ }
623
+ return lines.join('\n');
624
+ }
625
+ // How many of the most-recent messages keep their tool_result content verbatim.
626
+ // Older tool_result bodies are the bulk of a large body and are the safest thing
627
+ // to shed first (the model has already acted on them), so we replace their content
628
+ // with a short stub while KEEPING the block (so tool_use/tool_result pairing and
629
+ // turn structure stay intact — unlike summarisation, which drops whole turns).
630
+ const PRUNE_KEEP_RECENT = 8;
631
+ const PRUNE_STUB_KEEP_CHARS = 400; // keep a short head of each pruned result for context
632
+ // Marker sentinel appended to a pruned tool_result's content. We detect
633
+ // "already pruned" by this suffix rather than by an out-of-schema field on the
634
+ // block, because the block object is serialised verbatim onto the request body
635
+ // and forwarded to Anthropic — any extra property (e.g. a `_pruned` flag) would
636
+ // be rejected as an unknown field on a content block (400). Encoding the state
637
+ // inside the (string) content keeps the wire payload schema-clean AND idempotent.
638
+ const PRUNE_MARKER = '\n\n[… ';
639
+ const PRUNE_MARKER_TAIL = ' pruned to conserve context. Re-run the tool if you need the full result.]';
640
+ /**
641
+ * Lossy-but-structure-preserving prune: shrink OLD, large tool_result blocks in
642
+ * place, keeping the last PRUNE_KEEP_RECENT messages untouched. This is tried
643
+ * BEFORE summarisation because it:
644
+ * • keeps every turn and every tool_use/tool_result pair (API stays valid),
645
+ * • never makes an extra model call (summarisation does — cost + latency),
646
+ * • degrades gracefully on repeat (summarise-of-summarise loses the most on
647
+ * long runs; pruning just trims already-consumed output further).
648
+ *
649
+ * IMPORTANT: pruned state is encoded in the content string (PRUNE_MARKER_TAIL
650
+ * suffix), NOT as an extra property on the block — a stray field on a content
651
+ * block is rejected by the Anthropic API as an unknown key (400). This keeps the
652
+ * serialised body schema-clean while remaining idempotent across repeat calls.
653
+ *
654
+ * Returns the number of bytes reclaimed (0 if nothing was prunable).
655
+ */
656
+ function pruneOldToolResults(messages) {
657
+ const cutoff = messages.length - PRUNE_KEEP_RECENT;
658
+ if (cutoff <= 1)
659
+ return 0;
660
+ let reclaimed = 0;
661
+ for (let i = 0; i < cutoff; i++) {
662
+ const m = messages[i];
663
+ if (!Array.isArray(m.content))
664
+ continue;
665
+ for (const b of m.content) {
666
+ if (b.type !== 'tool_result')
667
+ continue;
668
+ const text = typeof b.content === 'string' ? b.content : JSON.stringify(b.content ?? '');
669
+ if (text.endsWith(PRUNE_MARKER_TAIL))
670
+ continue; // already pruned (idempotent)
671
+ if (text.length <= PRUNE_STUB_KEEP_CHARS + 80)
672
+ continue; // already small
673
+ const head = text.slice(0, PRUNE_STUB_KEEP_CHARS);
674
+ const omitted = text.length - head.length;
675
+ b.content = `${head}${PRUNE_MARKER}${omitted} chars of earlier tool output${PRUNE_MARKER_TAIL}`;
676
+ reclaimed += omitted;
677
+ }
678
+ }
679
+ return reclaimed;
451
680
  }
452
681
  /**
453
682
  * Compact `messages` in place: summarise everything before a safe cut point and
454
683
  * replace it with a summary preamble. Returns true if compaction happened.
455
684
  */
456
- async function autoCompactMessages(messages, options) {
685
+ /** Extract the first user turn's plain text — the ORIGINAL task/goal. */
686
+ function originalTaskText(messages) {
687
+ const first = messages.find((m) => m.role === 'user');
688
+ if (!first || !Array.isArray(first.content))
689
+ return '';
690
+ return first.content
691
+ .filter((b) => b.type === 'text' && b.text)
692
+ .map((b) => b.text)
693
+ .join('\n')
694
+ .trim();
695
+ }
696
+ async function autoCompactMessages(messages, options, ledger) {
457
697
  const cut = findSafeCutIndex(messages, messages.length - COMPACT_KEEP_MIN);
458
698
  if (cut < 2)
459
699
  return false; // nothing meaningful to fold
460
700
  const toSummarize = messages.slice(0, cut);
461
701
  const kept = messages.slice(cut);
702
+ // Pin the ORIGINAL task verbatim. findSafeCutIndex can (and on a long single
703
+ // run usually does) cut PAST the first user turn, folding the user's actual
704
+ // goal into the lossy summary — after a few compactions the agent drifts off
705
+ // what it was asked to do. We re-inject the first user turn's text verbatim
706
+ // into the replacement preamble so the objective survives every compaction.
707
+ // (We cannot keep it as a separate user message: the API requires alternating
708
+ // roles and kept[0] is already an assistant turn — two user turns would 400.)
709
+ const originalTask = originalTaskText(toSummarize);
462
710
  const summaryPrompt = `Summarize this coding-session transcript into concise bullet points the assistant needs to continue the work: ` +
463
711
  `key decisions, files changed (and how), commands run, unresolved problems, and user preferences. Max 400 words.\n\n` +
464
712
  transcriptOf(toSummarize);
@@ -488,7 +736,17 @@ async function autoCompactMessages(messages, options) {
488
736
  // and no orphaned tool_result is left behind. We intentionally do NOT insert
489
737
  // an assistant-ack here: that would put two assistant messages back-to-back
490
738
  // (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.` }] });
739
+ const taskBlock = originalTask
740
+ ? `ORIGINAL TASK (verbatim — keep working toward this, do not lose sight of it):\n${originalTask}\n\n`
741
+ : '';
742
+ // GAP E — the deterministic ledger (files changed + verification pass/fail) is
743
+ // injected VERBATIM, so these concrete facts never decay through repeated
744
+ // summary-of-summary compactions the way the prose summary does.
745
+ const ledgerText = ledger ? ledgerSummary(ledger) : '';
746
+ const ledgerBlock = ledgerText
747
+ ? `PROGRESS LEDGER (authoritative, machine-tracked — trust this over the prose summary for what changed/verified):\n${ledgerText}\n\n`
748
+ : '';
749
+ 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
750
  // `kept` follows automatically since splice only replaced the head.
493
751
  void kept;
494
752
  return true;
@@ -511,6 +769,7 @@ async function runAgentLoop(initialMessages, options) {
511
769
  const maxIterations = resolveMaxIterations(options.maxIterations, settings.raw);
512
770
  const autoContinue = resolveAutoContinue(options.autoContinue, settings.raw);
513
771
  const autoCompact = resolveAutoCompact(options.autoCompact, settings.raw);
772
+ const verifyNudgeOn = resolveVerificationNudge(settings.raw);
514
773
  const contextWindow = MODEL_CONTEXT_TOKENS[model] ?? 200000;
515
774
  // Live prompt-size estimate, updated from usage events after every stream.
516
775
  let lastPromptTokens = 0;
@@ -532,20 +791,63 @@ async function runAgentLoop(initialMessages, options) {
532
791
  let consecutiveErrorRounds = 0; // rounds where every tool call errored
533
792
  let budget = maxIterations; // extended by auto-continue, capped at hardCap
534
793
  let iteration = 0;
794
+ // ─── Verification nudge (GAP D) ───────────────────────────────────────────────
795
+ // Coding agents commonly claim "done" after editing files without ever running a
796
+ // build/test/lint command to confirm the change actually works (the industry's
797
+ // unsolved "verification problem" — we can't guarantee correctness, but we CAN
798
+ // make the agent check its own work when it visibly skipped that step). This is a
799
+ // single one-shot text nudge, NOT a forced extra model/sub-agent call: cheap, and
800
+ // the agent can decline it if verification genuinely isn't applicable (e.g. a
801
+ // docs-only change) since it's a suggestion appended before the turn ends, not a
802
+ // blocking gate.
803
+ let filesMutatedSinceVerify = false;
804
+ let ranVerificationCmd = false;
805
+ let verificationNudgeSent = false;
806
+ // Reward-hacking guard: number of test-integrity findings already surfaced,
807
+ // so the one-shot nudge fires once per NEW batch of weakened-test signals.
808
+ let testIntegrityNudgedCount = 0;
809
+ // Flaky-test guard: commands already nudged about, so we only warn about a
810
+ // newly-detected flaky command once.
811
+ const flakyNudgedCmds = new Set();
812
+ // GAP E — deterministic progress ledger, preserved verbatim across compactions.
813
+ const ledger = createLedger();
535
814
  try {
536
815
  for (; iteration < budget; iteration++) {
537
816
  if (options.abortSignal?.aborted)
538
817
  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) {
818
+ // Auto-compact: summarise older history before the next stream so we never
819
+ // hit the context-window wall or the backend body-size limit mid-task.
820
+ // Runs at a turn boundary only. Two independent triggers:
821
+ // 1. TOKEN pressure the last request's prompt crossed 80% of the window.
822
+ // 2. BYTE pressure — the serialised body has grown past MAX_BODY_BYTES.
823
+ // The byte trigger is what catches tool-heavy runs whose body balloons past
824
+ // the server's 413 limit while the token count still looks fine (and it fires
825
+ // even on turn 0 of a resumed large session, where lastPromptTokens is 0).
826
+ let bodyBytes = estimateBodyBytes(messages);
827
+ const tokenPressure = lastPromptTokens > contextWindow * AUTO_COMPACT_THRESHOLD;
828
+ let bytePressure = bodyBytes > MAX_BODY_BYTES;
829
+ // Byte pressure first tries the CHEAP, structure-preserving prune (no model
830
+ // call, keeps every turn). Only if that isn't enough do we fall through to
831
+ // summarisation below. This keeps long runs coherent — summarise-of-summarise
832
+ // is the main cause of an agent "forgetting" what it did earlier.
833
+ if (autoCompact && !compacting && bytePressure && messages.length > PRUNE_KEEP_RECENT + 2) {
834
+ const reclaimed = pruneOldToolResults(messages);
835
+ if (reclaimed > 0) {
836
+ bodyBytes = estimateBodyBytes(messages);
837
+ bytePressure = bodyBytes > MAX_BODY_BYTES;
838
+ options.onText(`\n\u267b\ufe0f Trimmed ~${(reclaimed / (1024 * 1024)).toFixed(1)}MB of older tool output to conserve context.\n`);
839
+ }
840
+ }
841
+ if (autoCompact && !compacting && (tokenPressure || bytePressure) && messages.length > COMPACT_KEEP_MIN + 2) {
543
842
  compacting = true;
544
843
  try {
545
- const did = await autoCompactMessages(messages, options);
844
+ const did = await autoCompactMessages(messages, options, ledger);
546
845
  if (did) {
547
846
  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`);
847
+ const reason = bytePressure
848
+ ? `body ~${(bodyBytes / (1024 * 1024)).toFixed(1)}MB`
849
+ : 'context window';
850
+ options.onText(`\n\u267b\ufe0f Auto-compacted earlier conversation to stay within the ${reason}.\n`);
549
851
  }
550
852
  }
551
853
  finally {
@@ -664,6 +966,69 @@ async function runAgentLoop(initialMessages, options) {
664
966
  messages.push({ role: 'user', content: [{ type: 'text', text }] });
665
967
  continue;
666
968
  }
969
+ // Reward-hacking guard (A4): the agent is about to finish, but it WEAKENED
970
+ // one or more test files this session (removed assertions, added .skip,
971
+ // introduced tautologies, commented out cases). Frontier agents let this
972
+ // pass silently and report "tests pass". We surface every NEW finding once
973
+ // and require the agent to either justify each change (legit refactor) or
974
+ // revert it and fix the real code. Deterministic — the signal comes from
975
+ // diff structure, not model self-report, so it can't be gamed away.
976
+ if (ledger.testIntegrity.length > testIntegrityNudgedCount) {
977
+ const fresh = ledger.testIntegrity.slice(testIntegrityNudgedCount);
978
+ testIntegrityNudgedCount = ledger.testIntegrity.length;
979
+ const bullet = fresh.map((t) => ` • ${t.path}: ${t.reason}`).join('\n');
980
+ messages.push({
981
+ role: 'user',
982
+ content: [{
983
+ type: 'text',
984
+ text: 'STOP — test-integrity check. Before finishing, I detected that you WEAKENED test(s) this session:\n' +
985
+ bullet +
986
+ '\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' +
987
+ ' 1. Revert the weakening and fix the actual code so the ORIGINAL test passes, or\n' +
988
+ ' 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' +
989
+ 'Then re-run the test suite to prove it passes for real.',
990
+ }],
991
+ });
992
+ continue;
993
+ }
994
+ // Flaky-test guard: a command that flipped PASS↔FAIL with no edit between
995
+ // the differing runs is non-deterministic — a green run of it proves
996
+ // nothing, and the agent may be (consciously or not) re-running until it
997
+ // goes green. Warn once per flaky command before letting the run end.
998
+ {
999
+ const flaky = (0, flaky_1.detectFlaky)(ledger.verifications).filter((f) => !flakyNudgedCmds.has(f.cmd));
1000
+ if (flaky.length) {
1001
+ for (const f of flaky)
1002
+ flakyNudgedCmds.add(f.cmd);
1003
+ const bullet = flaky.map((f) => ` • ${f.cmd} (${f.passes} pass / ${f.fails} fail on identical code)`).join('\n');
1004
+ messages.push({
1005
+ role: 'user',
1006
+ content: [{
1007
+ type: 'text',
1008
+ 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' +
1009
+ bullet +
1010
+ '\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.',
1011
+ }],
1012
+ });
1013
+ continue;
1014
+ }
1015
+ }
1016
+ // One-shot verification nudge (GAP D): the agent is about to declare the task
1017
+ // done, but it edited files this run and never ran a build/test/lint command
1018
+ // to confirm the change works. Ask ONCE — if it still finishes without
1019
+ // verifying (e.g. a docs-only change, or no test suite exists), we respect
1020
+ // that and end normally rather than looping forever on the same nudge.
1021
+ if (verifyNudgeOn && filesMutatedSinceVerify && !ranVerificationCmd && !verificationNudgeSent) {
1022
+ verificationNudgeSent = true;
1023
+ messages.push({
1024
+ role: 'user',
1025
+ content: [{
1026
+ type: 'text',
1027
+ 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.',
1028
+ }],
1029
+ });
1030
+ continue;
1031
+ }
667
1032
  runSimpleHooks(hooks.PostMessageComplete, options.workDir);
668
1033
  completedCleanly = true;
669
1034
  break;
@@ -749,6 +1114,21 @@ async function runAgentLoop(initialMessages, options) {
749
1114
  options.onToolResult(name, result);
750
1115
  return { block: { ...block, id }, result };
751
1116
  }));
1117
+ // Track whether files were mutated / verified this run, for the one-shot
1118
+ // end-of-task nudge below (GAP D — see declaration above).
1119
+ for (const { block, result } of toolResults) {
1120
+ const ok = result.error === undefined;
1121
+ // GAP E — feed every successful effect into the deterministic ledger.
1122
+ ledgerRecord(ledger, block.name, block.input, ok);
1123
+ if (!ok)
1124
+ continue; // failed calls don't count either way
1125
+ if (exports.WRITE_TOOL_NAMES.has(block.name))
1126
+ filesMutatedSinceVerify = true;
1127
+ else if (block.name === 'bash' && exports.VERIFY_CMD_RE.test(String(block.input?.command ?? ''))) {
1128
+ ranVerificationCmd = true;
1129
+ filesMutatedSinceVerify = false; // verified — reset until the next mutation
1130
+ }
1131
+ }
752
1132
  // 6. Build tool_result message and append to history
753
1133
  const toolResultBlocks = toolResults.map(({ block, result }) => ({
754
1134
  type: 'tool_result',
@@ -0,0 +1,56 @@
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
+ //# 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,CA2B5B"}