Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion packages/core/src/goals/goal-evidence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
50 changes: 44 additions & 6 deletions packages/core/src/goals/goal-evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -263,6 +272,7 @@ export class GoalEvidenceRecordIndexAccumulator {
this.lastPartPreviewValues,
).trim();
}
preview = capPreviewBytes(preview);
const catalogEntry =
this.provenance && this.parsedGoalContext && preview
? {
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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,
}));
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
Expand All @@ -1121,7 +1159,7 @@ function evidencePreview(
}
if (preview.length >= CATALOG_PREVIEW_LIMIT) break;
}
return preview.trim();
return capPreviewBytes(preview.trim());
}

function renderToolResponse(functionResponse: {
Expand Down
67 changes: 51 additions & 16 deletions packages/core/src/goals/goal-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1155,7 +1154,7 @@ describe('goal runtime', () => {
records = verifierEvidenceWindow(
permit,
runtime.getSnapshot().goal!.evidenceCursor.recordId!,
failurePoint === 'truncated' ? 101 : 80,
80,
);

await runtime.finishTurn(permit);
Expand All @@ -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[] = [];
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/goals/goal-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading