@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.
- package/dist/agent/crossFile.d.ts +42 -0
- package/dist/agent/crossFile.d.ts.map +1 -0
- package/dist/agent/crossFile.js +119 -0
- package/dist/agent/editCompleteness.d.ts +47 -0
- package/dist/agent/editCompleteness.d.ts.map +1 -0
- package/dist/agent/editCompleteness.js +109 -0
- package/dist/agent/flaky.d.ts +47 -0
- package/dist/agent/flaky.d.ts.map +1 -0
- package/dist/agent/flaky.js +79 -0
- package/dist/agent/loop.d.ts +59 -0
- package/dist/agent/loop.d.ts.map +1 -1
- package/dist/agent/loop.js +392 -12
- package/dist/agent/testIntegrity.d.ts +56 -0
- package/dist/agent/testIntegrity.d.ts.map +1 -0
- package/dist/agent/testIntegrity.js +199 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +4 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/permissions/destructive.d.ts +43 -0
- package/dist/permissions/destructive.d.ts.map +1 -0
- package/dist/permissions/destructive.js +244 -0
- package/dist/tools/executor.d.ts.map +1 -1
- package/dist/tools/executor.js +351 -18
- package/dist/tools/tsLangService.d.ts +21 -0
- package/dist/tools/tsLangService.d.ts.map +1 -0
- package/dist/tools/tsLangService.js +282 -0
- package/package.json +1 -1
package/dist/agent/loop.js
CHANGED
|
@@ -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:
|
|
396
|
-
pro:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
|
|
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
|
-
|
|
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"}
|