From 6b43e1ce40fb411da4ecbf86ec38f0a2c142acec Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 24 Aug 2026 10:30:34 +0800 Subject: [PATCH] fix(goal): count catalog previews in the unit their budget is written in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evidence catalog previews were cut to 240 characters while the budget they feed is 24,000 bytes. In UTF-8 those units differ by up to four times, so the guard held only for ASCII. A legal 32-claim checkpoint of Chinese claims serialized to roughly 29kB — over the cap on its own, before a single new record had been scanned — which marked the window truncated, and `truncated` switched compaction off. The one state compaction exists to resolve was the one state it refused to run in, so the Goal was stopped as `usage_limited`, the only status the reducer refuses to resume, with nothing left to salvage. An English Goal never reached that state; a Chinese one could not avoid it. Previews are now capped to 240 UTF-8 bytes on a code point boundary at the two points a catalog entry is built. Nothing changes for ASCII, where the two units already agreed; a CJK preview is shorter than before, which is the cost of the cap actually holding. With it, a full checkpoint is bounded well inside the catalog budget for every script, so a window can no longer start out truncated. A truncated window now compresses rather than stopping the Goal. Overflow means the budget is full and the newest evidence that did fit is exactly what a checkpoint folds into claims; the older evidence left behind is already covered by the previous checkpoint. Only a window that captured nothing at all has nothing to salvage, and that is the sole remaining path to `usage_limited` here. Co-Authored-By: Claude Opus 5 --- packages/core/src/goals/goal-evidence.test.ts | 42 +++++++++++- packages/core/src/goals/goal-evidence.ts | 50 ++++++++++++-- packages/core/src/goals/goal-runtime.test.ts | 67 ++++++++++++++----- packages/core/src/goals/goal-runtime.ts | 6 +- 4 files changed, 141 insertions(+), 24 deletions(-) diff --git a/packages/core/src/goals/goal-evidence.test.ts b/packages/core/src/goals/goal-evidence.test.ts index e291a3e7adf..54b5152705b 100644 --- a/packages/core/src/goals/goal-evidence.test.ts +++ b/packages/core/src/goals/goal-evidence.test.ts @@ -548,10 +548,50 @@ describe('Goal evidence catalog', () => { expect(small?.content).toBe('output 1'); }); + it('does not start truncated under a full checkpoint of multi-byte claims', () => { + // The failure this guards: catalog previews were cut to 240 *characters* + // while the catalog budget counts *bytes*. A legal 32-claim checkpoint of + // Chinese claims serialized to ~29kB against the 24kB cap, so the window + // was truncated before a single new record was scanned — and `truncated` + // switches `shouldCheckpoint` off, so compaction could never run again and + // the Goal was stopped as `usage_limited` with nothing to salvage. + const checkpointGoal: GoalRecord = { + ...goal('checkpoint-1'), + evidenceCheckpoint: { + checkpointId: 'checkpoint-1', + createdAt: 1, + claims: Array.from({ length: 32 }, (_, index) => ({ + id: `checkpoint-1:${index + 1}`, + proofKind: 'external_fact' as const, + claim: '\u4e2d'.repeat(2_000), + sourceRefs: ['cursor'], + })), + }, + }; + + const window = buildGoalEvidenceCheckpointWindow({ + records: [ + record('checkpoint-1', 'system'), + record('evidence-0', 'assistant', { + provenance: 'assistant_output', + turnId: 'turn-3', + text: '\u4e2d'.repeat(50), + }), + ], + goal: checkpointGoal, + permit: permit(), + }); + + expect(window.truncated).toBe(false); + }); + it('caps window content on a code point boundary for multi-byte text', () => { const records = [ record('cursor', 'system'), - ...Array.from({ length: 26 }, (_, index) => + // Sized against the byte-capped catalog entry (~364 bytes each), not the + // ~910 a 240-character CJK preview used to cost: 53 entries reach the + // 19,200-byte checkpoint threshold, 66 would reach the 24,000 cap. + ...Array.from({ length: 55 }, (_, index) => record(`evidence-${index}`, 'assistant', { provenance: 'assistant_output', turnId: 'turn-3', diff --git a/packages/core/src/goals/goal-evidence.ts b/packages/core/src/goals/goal-evidence.ts index 3fae3e8e887..a1ca93796a3 100644 --- a/packages/core/src/goals/goal-evidence.ts +++ b/packages/core/src/goals/goal-evidence.ts @@ -19,7 +19,16 @@ import { projectUserTranscriptForDisplay, } from '../utils/transcript-records.js'; +// Previews are cut to a character count while building — cheap, and it bounds +// the work — but the catalog budget they feed is denominated in bytes, so the +// guarantee has to be too. In UTF-8 the two units differ by up to 4x: 240 CJK +// characters are 720 bytes, so a full 32-claim checkpoint of Chinese evidence +// serialized to ~29kB against a 24kB catalog and marked the window truncated +// before a single new record was even scanned — which switched compaction off +// permanently. `capPreviewBytes` is what actually holds the bound; the +// character slices below only keep the intermediate strings small. const CATALOG_PREVIEW_LIMIT = 240; +const CATALOG_PREVIEW_BYTE_LIMIT = 240; const CATALOG_ENTRY_LIMIT = 100; const CATALOG_BYTE_LIMIT = 24_000; const CATALOG_LINEAGE_LIMIT = 16; @@ -263,6 +272,7 @@ export class GoalEvidenceRecordIndexAccumulator { this.lastPartPreviewValues, ).trim(); } + preview = capPreviewBytes(preview); const catalogEntry = this.provenance && this.parsedGoalContext && preview ? { @@ -462,11 +472,18 @@ export class GoalEvidenceCheckpointAccumulator { catalogBytes += entryBytes; } this.truncated = truncated; + // A truncated window is the case that most needs compressing, not the one + // that should skip it: the budget is already full, and the newest evidence + // that did fit is exactly what a checkpoint would fold into claims. Gating + // compaction on `!truncated` meant the one state compaction exists to + // resolve was the one state it refused to run in, and the Goal was stopped + // instead. Compress whatever the window did capture; the older evidence + // left behind is already covered by the previous checkpoint's claims. this.shouldCheckpoint = - !truncated && this.candidateUuids.length > 0 && - (this.checkpointEntries.length + this.candidateUuids.length >= - CHECKPOINT_ENTRY_THRESHOLD || + (truncated || + this.checkpointEntries.length + this.candidateUuids.length >= + CHECKPOINT_ENTRY_THRESHOLD || catalogBytes >= CHECKPOINT_BYTE_THRESHOLD); } @@ -942,7 +959,7 @@ function checkpointCatalogEntries( uuid: claim.id, provenance: 'goal_checkpoint', turnId: `checkpoint:${checkpoint.checkpointId}`, - preview: claim.claim.slice(0, CATALOG_PREVIEW_LIMIT), + preview: capPreviewBytes(claim.claim.slice(0, CATALOG_PREVIEW_LIMIT)), proofKind: claim.proofKind, })); } @@ -1049,6 +1066,25 @@ function legacySafeProvenance( return undefined; } +/** + * Cut `value` to at most {@link CATALOG_PREVIEW_BYTE_LIMIT} UTF-8 bytes + * without splitting a code point. + */ +function capPreviewBytes(value: string): string { + if (Buffer.byteLength(value, 'utf8') <= CATALOG_PREVIEW_BYTE_LIMIT) { + return value; + } + let byteLength = 0; + let cutoff = 0; + for (const codePoint of value) { + const codePointBytes = Buffer.byteLength(codePoint, 'utf8'); + if (byteLength + codePointBytes > CATALOG_PREVIEW_BYTE_LIMIT) break; + byteLength += codePointBytes; + cutoff += codePoint.length; + } + return value.slice(0, cutoff); +} + function capCheckpointContent(content: string): string { if (Buffer.byteLength(content, 'utf8') <= CHECKPOINT_CONTENT_BYTE_LIMIT) { return content; @@ -1101,7 +1137,9 @@ function evidencePreview( ? projectUserTranscriptForDisplay(record) : undefined; if (projection?.displayText !== undefined) { - return projection.displayText.slice(0, CATALOG_PREVIEW_LIMIT).trim(); + return capPreviewBytes( + projection.displayText.slice(0, CATALOG_PREVIEW_LIMIT).trim(), + ); } let preview = ''; const append = (value: string) => { @@ -1121,7 +1159,7 @@ function evidencePreview( } if (preview.length >= CATALOG_PREVIEW_LIMIT) break; } - return preview.trim(); + return capPreviewBytes(preview.trim()); } function renderToolResponse(functionResponse: { diff --git a/packages/core/src/goals/goal-runtime.test.ts b/packages/core/src/goals/goal-runtime.test.ts index e01b7f1e97a..98d76d0231b 100644 --- a/packages/core/src/goals/goal-runtime.test.ts +++ b/packages/core/src/goals/goal-runtime.test.ts @@ -9,7 +9,6 @@ import type { GoalEvidenceRecord } from './goal-evidence.js'; import type { GoalRecoveryRecord } from './goal-persistence.js'; import { GOAL_CHECKPOINT_REQUEST_TOO_LARGE_REASON, - GOAL_EVIDENCE_CATALOG_EXHAUSTED_REASON, GOAL_PROPOSAL_REASON_MAX_BYTES, type GoalSnapshotV2, type GoalStateCause, @@ -1120,7 +1119,7 @@ describe('goal runtime', () => { expect(host.started).toHaveLength(2); }); - it.each(['flush', 'read', 'truncated'] as const)( + it.each(['flush', 'read'] as const)( 'moves to usage_limited when checkpoint %s fails', async (failurePoint) => { const journal = fakeGoalJournal(); @@ -1155,7 +1154,7 @@ describe('goal runtime', () => { records = verifierEvidenceWindow( permit, runtime.getSnapshot().goal!.evidenceCursor.recordId!, - failurePoint === 'truncated' ? 101 : 80, + 80, ); await runtime.finishTurn(permit); @@ -1171,22 +1170,58 @@ describe('goal runtime', () => { ]); expect(host.started).toHaveLength(1); expect(checkpointVerifier).toHaveBeenCalledTimes(0); - if (failurePoint === 'truncated') { - expect(runtime.getSnapshot().goal).toMatchObject({ - lastReason: GOAL_EVIDENCE_CATALOG_EXHAUSTED_REASON, - limitKind: 'evidence_catalog', - }); - await expect( - runtime.dispatch({ - action: 'resume', - expectedGoalId: permit.goalId, - expectedRevision: permit.revision, - }), - ).rejects.toThrow('edit or replace'); - } }, ); + it('compresses a truncated window instead of stopping the Goal', async () => { + // A window that overflows its budget is the state compaction exists to + // resolve. It used to be the one state compaction refused to run in: + // `shouldCheckpoint` required `!truncated`, so an overflow went straight + // to `usage_limited` — the only Goal state the reducer refuses to resume. + // The evidence left behind is already covered by the previous checkpoint's + // claims, so folding in what did fit is strictly better than stopping. + const journal = fakeGoalJournal(); + let records: readonly RuntimeRecord[] = []; + const evidenceSource = fakeEvidenceSource(() => records); + const checkpointVerifier = vi.fn(async () => ({ + claims: [ + { + proofKind: 'delivered_output' as const, + claim: 'The implementation result was delivered.', + sourceRefs: ['assistant-evidence-100'], + }, + ], + })); + const host = fakeGoalTurnHost(); + const runtime = createGoalRuntime({ + journal, + evidenceSource, + verifier: vi.fn(), + checkpointVerifier, + }); + runtime.bindHost(host); + await runtime.dispatch({ action: 'create', objective: 'deliver result' }); + const permit = host.started[0]!; + records = verifierEvidenceWindow( + permit, + runtime.getSnapshot().goal!.evidenceCursor.recordId!, + 101, + ); + + await runtime.finishTurn(permit); + + expect(checkpointVerifier).toHaveBeenCalledTimes(1); + expect(runtime.getSnapshot()).toMatchObject({ + goal: { status: 'active' }, + }); + expect(runtime.getSnapshot().goal).toHaveProperty('evidenceCheckpoint'); + expect(journal.appended.map((payload) => payload.cause)).toEqual([ + 'create', + 'turn_finished', + 'checkpoint', + ]); + }); + it('keeps a goal active when the checkpoint verifier provider fails', async () => { const journal = fakeGoalJournal(); let records: readonly RuntimeRecord[] = []; diff --git a/packages/core/src/goals/goal-runtime.ts b/packages/core/src/goals/goal-runtime.ts index c5643484e2d..3db9de36ffa 100644 --- a/packages/core/src/goals/goal-runtime.ts +++ b/packages/core/src/goals/goal-runtime.ts @@ -888,7 +888,11 @@ export function createGoalRuntime( permit: attempt.permit, }); } - if (window.truncated) { + // A truncated window still compresses: `shouldCheckpoint` stays true + // whenever anything was captured, and folding that into claims is what + // frees the budget. Only a window that captured nothing at all has + // nothing to salvage, and that is the state this stops the Goal in. + if (window.truncated && !window.shouldCheckpoint) { await recordCheckpointFailure( attempt, GOAL_EVIDENCE_CATALOG_EXHAUSTED_REASON,