diff --git a/packages/runtime/src/__tests__/history-compact-checkpoint.test.ts b/packages/runtime/src/__tests__/history-compact-checkpoint.test.ts index 05f615b6e3..062ba351d8 100644 --- a/packages/runtime/src/__tests__/history-compact-checkpoint.test.ts +++ b/packages/runtime/src/__tests__/history-compact-checkpoint.test.ts @@ -26,12 +26,22 @@ import { canContinueHistoryCompactCheckpointForModel, canReplayHistoryCompactCheckpointForModel, canReplaceHistoryCompactCheckpoint, + checkHistoryCompactCheckpointCurrency, historyCompactCheckpointToModelMessage, historyCompactCheckpointToRuntimeEvent, + historyCompactSourceDigest, isProviderHistoryCompactCheckpoint, matchHistoryCompactCheckpointPrefix, validateHistoryCompactCheckpointShape, + type HistoryCompactCheckpoint, } from '../history-compact-checkpoint.js'; +import { buildModelProjectionTransition } from '@maka/core/model-projection-transition'; +import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection'; +import { compatibilityToolResultProjection } from '../durable-tool-result-projection.js'; +import { + reduceEffectiveModelProjections, + type LoadedModelProjectionTransitions, +} from '../model-projection-transition-ledger.js'; import { loadHistoryCompactCheckpointsFromRunLedger, loadLatestHistoryCompactCheckpointFromRunLedger, @@ -987,6 +997,230 @@ describe('history compact checkpoint', () => { }); }); +/** + * The shared checkpoint-currency decision (#5930): raw prefix identity first, + * then the covered span folded through the CALLER-SUPPLIED projection snapshot + * and compared against coverage.effectiveSourceDigest. The function does not + * load transitions — the same event list judged under two different snapshots + * must be able to disagree. + */ +describe('checkHistoryCompactCheckpointCurrency', () => { + const EMPTY_SNAPSHOT: LoadedModelProjectionTransitions = { + transitions: [], + unreadableTargets: new Set(), + unscopedUnreadable: 0, + }; + + function checkpointOver( + covered: readonly RuntimeEvent[], + effectiveCovered?: readonly RuntimeEvent[], + ): HistoryCompactCheckpoint { + return buildHistoryCompactCheckpoint({ + sessionId: 'session-1', + coveredRuntimeEvents: covered, + ...(effectiveCovered ? { effectiveCoveredRuntimeEvents: effectiveCovered } : {}), + summary: sectionedSummary('checkpoint summary'), + }); + } + + test('reports the raw-mismatch reason without touching the projection', () => { + const covered = [textEvent(0), textEvent(1), textEvent(2)]; + const checkpoint = checkpointOver(covered); + + for (const [name, events, reason] of [ + ['shorter event list', [textEvent(0), textEvent(1)], 'coverage_miss'], + [ + 'different covered boundary identity', + [textEvent(0), textEvent(1), { ...textEvent(2), id: 'event-elsewhere' }], + 'coverage_miss', + ], + [ + 'same identity but mutated covered content', + [textEvent(0), { ...textEvent(1), ts: textEvent(1).ts + 1 }, textEvent(2)], + 'source_hash_mismatch', + ], + ] as const) { + const result = checkHistoryCompactCheckpointCurrency(checkpoint, events, EMPTY_SNAPSHOT); + assert.deepEqual(result, { status: 'raw_mismatch', reason }, name); + } + }); + + test('returns the prefix match when the effective view is unchanged', () => { + const events = Array.from({ length: 6 }, (_, index) => textEvent(index)); + const checkpoint = checkpointOver(events.slice(0, 4)); + + const result = checkHistoryCompactCheckpointCurrency(checkpoint, events, EMPTY_SNAPSHOT); + + assert.equal(result.status, 'current'); + if (result.status !== 'current') return; + assert.equal(result.match.coveredEventCount, 4); + assert.deepEqual(result.match.coveredRuntimeEvents, events.slice(0, 4)); + assert.deepEqual(result.match.successorRuntimeEvents, events.slice(4)); + }); + + test('judges the covered span against the supplied snapshot, not the latest ledger', () => { + const result = toolResultEvent('event-result', 'RAW_COVERED_BODY'); + const covered = [textEvent(0), result]; + const transition = archiveTransitionFor(result, 'EFFECTIVE_REPLACEMENT_BODY'); + // The checkpoint was minted AFTER the transition: its pinned digest + // describes the folded view. + const checkpoint = checkpointOver( + covered, + reduceEffectiveModelProjections(covered, [transition]).events, + ); + + // A snapshot taken before the transition still sees the raw body: the + // checkpoint is stale against it, even though the newest ledger state + // would reproduce the pin. The function consults only what it is given. + assert.deepEqual(checkHistoryCompactCheckpointCurrency(checkpoint, covered, EMPTY_SNAPSHOT), { + status: 'effective_history_changed', + }); + const currentSnapshot: LoadedModelProjectionTransitions = { + ...EMPTY_SNAPSHOT, + transitions: [transition], + }; + assert.equal( + checkHistoryCompactCheckpointCurrency(checkpoint, covered, currentSnapshot).status, + 'current', + ); + }); + + test('reports effective_history_changed when a transition drifted the covered view', () => { + const result = toolResultEvent('event-result', 'RAW_COVERED_BODY'); + const covered = [textEvent(0), result]; + // Minted before the transition: the pin describes the un-folded view. + const checkpoint = checkpointOver(covered); + const snapshot: LoadedModelProjectionTransitions = { + ...EMPTY_SNAPSHOT, + transitions: [archiveTransitionFor(result, 'EFFECTIVE_REPLACEMENT_BODY')], + }; + + assert.deepEqual(checkHistoryCompactCheckpointCurrency(checkpoint, covered, snapshot), { + status: 'effective_history_changed', + }); + }); + + test('reports effective_history_changed when no effective digest is pinned', () => { + const covered = [textEvent(0), textEvent(1)]; + const checkpoint = checkpointOver(covered); + // A legacy record without the source block may carry no + // effectiveSourceDigest at all: raw identity still matches but there is + // nothing to judge content currency against. + const legacy = { + ...checkpoint, + source: undefined, + coverage: { ...checkpoint.coverage, effectiveSourceDigest: undefined }, + } as HistoryCompactCheckpoint; + + assert.equal(validateHistoryCompactCheckpointShape(legacy), true); + assert.deepEqual(checkHistoryCompactCheckpointCurrency(legacy, covered, EMPTY_SNAPSHOT), { + status: 'effective_history_changed', + }); + }); + + test('reports effective_history_changed when too few covered effective events remain', () => { + // The covered span as recorded includes an event the compact projection + // does not count (model-hidden), so after selecting the covered effective + // prefix fewer than eventCount events remain. + const hidden = { ...textEvent(1), modelVisibility: 'hidden' as const }; + const covered = [textEvent(0), hidden]; + const checkpoint = checkpointOver(covered); + + assert.equal( + checkHistoryCompactCheckpointCurrency(checkpoint, covered, EMPTY_SNAPSHOT).status, + 'effective_history_changed', + ); + }); + + test('reports effective_history_changed when the covered prefix does not reach the through event', () => { + // The checkpoint names the hidden tail as its through event; the effective + // prefix stops one content event earlier, so it never reaches it. + const hiddenTail = { ...textEvent(1), modelVisibility: 'hidden' as const }; + const covered = [textEvent(0), hiddenTail]; + const checkpoint = checkpointOver(covered); + assert.equal( + checkpoint.coverage.through.runtimeEventId, + hiddenTail.id, + 'the raw match pins the hidden tail as through', + ); + + const currency = checkHistoryCompactCheckpointCurrency(checkpoint, covered, EMPTY_SNAPSHOT); + assert.equal(currency.status, 'effective_history_changed'); + }); + + test('an unreadable transition target withholds the covered body from the digest', () => { + const result = toolResultEvent('event-result', 'RAW_COVERED_BODY'); + const covered = [textEvent(0), result]; + const checkpoint = checkpointOver(covered); + const withholding: LoadedModelProjectionTransitions = { + ...EMPTY_SNAPSHOT, + unreadableTargets: new Set(['event-result::tool_result']), + }; + + // The pinned digest described the readable body; withheld content can no + // longer be judged current, and the raw body must not come back. + assert.equal( + checkHistoryCompactCheckpointCurrency(checkpoint, covered, withholding).status, + 'effective_history_changed', + ); + + // A checkpoint minted over the withheld view does stay current under the + // same snapshot — the fold really applies the snapshot's unreadable set. + const withheldCheckpoint = checkpointOver( + covered, + reduceEffectiveModelProjections(covered, [], withholding.unreadableTargets).events, + ); + assert.equal( + checkHistoryCompactCheckpointCurrency(withheldCheckpoint, covered, withholding).status, + 'current', + ); + }); + + function toolResultEvent(id: string, body: string): RuntimeEvent { + return { + ...textEvent(1), + id, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'call-1', + name: 'Read', + result: { body }, + }, + }; + } + + function archiveTransitionFor( + event: RuntimeEvent, + replacementText: string, + ): ReturnType { + const content = event.content as Extract< + RuntimeEvent['content'], + { kind: 'function_response' } + >; + const sourceProjection = compatibilityToolResultProjection(content, event.sessionId); + assert.ok(sourceProjection); + const replacement: DurableToolResultProjection = { + version: 1, + kind: 'text', + text: replacementText, + }; + return buildModelProjectionTransition({ + sessionId: event.sessionId, + target: { + runtimeEventId: event.id, + part: 'tool_result', + toolCallId: content.id, + toolName: content.name, + }, + sourceProjection, + replacement, + now: 1, + }); + } +}); + function textEvent(index: number): RuntimeEvent { return { id: `event-${index}`, diff --git a/packages/runtime/src/__tests__/history-compaction.test.ts b/packages/runtime/src/__tests__/history-compaction.test.ts index 86a45ab10e..4f6335c63c 100644 --- a/packages/runtime/src/__tests__/history-compaction.test.ts +++ b/packages/runtime/src/__tests__/history-compaction.test.ts @@ -29,6 +29,37 @@ import { import { HistoryCompactSummarizerError } from '../history-compact-summarizer.js'; import { testInvocationRecord } from './invocation-fixture.js'; import { matchHistoryCompactCheckpointPrefix } from '../history-compact-checkpoint.js'; +import { buildModelProjectionTransition } from '@maka/core/model-projection-transition'; +import { compatibilityToolResultProjection } from '../durable-tool-result-projection.js'; +import type { LoadedModelProjectionTransitions } from '../model-projection-transition-ledger.js'; + +/** The empty transition view: folding through it is the identity. */ +const EMPTY_PROJECTION_SNAPSHOT: LoadedModelProjectionTransitions = { + transitions: [], + unreadableTargets: new Set(), + unscopedUnreadable: 0, +}; + +function archiveTransitionFor( + event: RuntimeEvent, + replacementText: string, +): ReturnType { + const content = event.content as Extract; + const sourceProjection = compatibilityToolResultProjection(content, event.sessionId); + assert.ok(sourceProjection); + return buildModelProjectionTransition({ + sessionId: event.sessionId, + target: { + runtimeEventId: event.id, + part: 'tool_result', + toolCallId: content.id, + toolName: content.name, + }, + sourceProjection, + replacement: { version: 1, kind: 'text', text: replacementText }, + now: 1, + }); +} describe('safe compaction prefix selection', () => { test('folds the largest immutable non-partial prefix, leaving the reserved tail', () => { @@ -609,10 +640,12 @@ describe('plan context compaction', () => { call('call-c', 'cc', 'turn-1'), result('res-c', 'cc', 'turn-1'), ]; - const identityFold = async (covered: readonly RuntimeEvent[]) => [...covered]; // Coverage ends at `res-a`: the first fold's covered span contains it. const first = await planHistoryCompaction( - planInput({ orderedEvents: events, projectEffectiveCoverage: identityFold }), + planInput({ + orderedEvents: events, + loadProjectionSnapshot: async () => EMPTY_PROJECTION_SNAPSHOT, + }), ); assert.equal(first.decision, 'compacted'); if (first.decision !== 'compacted') return; @@ -622,7 +655,7 @@ describe('plan context compaction', () => { planInput({ orderedEvents: longerEvents, previousCheckpoint: first.checkpoint, - projectEffectiveCoverage: identityFold, + loadProjectionSnapshot: async () => EMPTY_PROJECTION_SNAPSHOT, summarize: ({ newlyFoldedRuntimeEvents, previousCheckpoint }) => { seenNewlyFolded = newlyFoldedRuntimeEvents.map((event) => event.id); assert.equal(previousCheckpoint?.checkpointId, first.checkpoint.checkpointId); @@ -643,11 +676,13 @@ describe('plan context compaction', () => { call('call-c', 'cc', 'turn-1'), result('res-c', 'cc', 'turn-1'), ]; - const identityFold = async (covered: readonly RuntimeEvent[]) => [...covered]; // First fold: no transition exists, so the effective view IS the raw view // and the checkpoint (covering through `res-a`) pins that digest. const first = await planHistoryCompaction( - planInput({ orderedEvents: events, projectEffectiveCoverage: identityFold }), + planInput({ + orderedEvents: events, + loadProjectionSnapshot: async () => EMPTY_PROJECTION_SNAPSHOT, + }), ); assert.equal(first.decision, 'compacted'); if (first.decision !== 'compacted') return; @@ -656,25 +691,15 @@ describe('plan context compaction', () => { // first checkpoint's coverage — leaving the raw prefix untouched. The // inherited summary still quotes the raw body, but the view it describes // no longer exists. - const foldWithArchive = async (covered: readonly RuntimeEvent[]): Promise => - covered.map((event) => { - if (event.id !== 'res-a') return event; - const content = event.content as Extract< - RuntimeEvent['content'], - { kind: 'function_response' } - >; - return { - ...event, - content: { - ...content, - modelProjection: { - version: 1 as const, - kind: 'text' as const, - text: '[archived: artifact-res-a]', - }, - }, - }; - }); + const archiveSnapshot: LoadedModelProjectionTransitions = { + ...EMPTY_PROJECTION_SNAPSHOT, + transitions: [ + archiveTransitionFor( + events.find((event) => event.id === 'res-a')!, + '[archived: artifact-res-a]', + ), + ], + }; let summarizeSawPrevious: string | undefined; let seenCovered: string[] = []; @@ -683,7 +708,7 @@ describe('plan context compaction', () => { planInput({ orderedEvents: longerEvents, previousCheckpoint: first.checkpoint, - projectEffectiveCoverage: foldWithArchive, + loadProjectionSnapshot: async () => archiveSnapshot, summarize: ({ coveredRuntimeEvents, newlyFoldedRuntimeEvents, previousCheckpoint }) => { summarizeSawPrevious = previousCheckpoint?.checkpointId; seenCovered = coveredRuntimeEvents.map((event) => event.id); diff --git a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts index add55fad4b..443dd45eca 100644 --- a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts +++ b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts @@ -41,6 +41,9 @@ import type { HistoryCompactCheckpoint, HistoryCompactProviderState, } from '../history-compact-checkpoint.js'; +import { buildModelProjectionTransition } from '@maka/core/model-projection-transition'; +import { compatibilityToolResultProjection } from '../durable-tool-result-projection.js'; +import type { LoadedModelProjectionTransitions } from '../model-projection-transition-ledger.js'; import type { ContextBudgetDiagnostic } from '@maka/core/usage-stats/types'; import { HistoryCompactSummarizerError } from '../history-compact-error.js'; import { buildLlmHistorySummarizer } from '../history-compact-summarizer.js'; @@ -173,6 +176,12 @@ interface MidTurnFixtureOptions { systemPromptChars?: number; /** An always-active tool whose schema dominates the request payload. */ bigActiveTool?: boolean; + /** + * Replace the transition-ledger read. Evaluated on every load, so a test can + * return a record only once some later fact (e.g. a recorded checkpoint) + * exists — that is how a transition committed mid-send is modeled. + */ + loadTransitions?: () => Promise; /** Enable and capture automatic Memory extraction without allowing it to settle. */ captureMemoryExtraction?: boolean; memoryGate?: @@ -642,6 +651,7 @@ function buildFixture(options: MidTurnFixtureOptions = {}): MidTurnFixture { if (options.record) return options.record(checkpoint); recorded.push(checkpoint); }, + ...(options.loadTransitions ? { loadModelProjectionTransitions: options.loadTransitions } : {}), loadTurnRuntimeEvents: async (turnId) => { fixture.ledgerReads += 1; // Emulate the durable read: let the event consumer's pending microtask @@ -1196,6 +1206,84 @@ function defineMidTurnSuite(consumer: ConsumerMode): void { assert.match(thirdPrompt, /tool_result_pruned/); }); + test('a covered-span transition committed after the fold drops the mid-turn block', async () => { + // The durable turn projection re-validates the checkpoint it replays + // against the SAME transition snapshot the rest of the request was built + // from. A record committed after the fold rewrites the covered span's + // effective view without touching the raw ledger, so the request after it + // must replay the effective events WITHOUT the stale block — its summary + // still quotes the removed body (#4845 review). + let coveredResult: RuntimeEvent | undefined; + const fixture = buildFixture({ + // Three tool steps so one more request is projected after the fold. + toolSteps: 3, + // Keep the post-fold baseline inside the window so the last step does + // not fold again and re-mask the drift check. + usageByCall: { 3: { input: 60, output: 10 } }, + loadTransitions: async () => { + // The record exists only once the checkpoint does: committed after + // the fold whose digest it invalidates. + const checkpoint = fixture.recorded[0]; + if (!checkpoint) { + return { + transitions: [], + unreadableTargets: new Set(), + unscopedUnreadable: 0, + }; + } + coveredResult ??= fixture.ledger.find( + (event) => event.content?.kind === 'function_response', + ); + const content = coveredResult!.content as Extract< + RuntimeEvent['content'], + { kind: 'function_response' } + >; + const sourceProjection = compatibilityToolResultProjection(content, 'session-1'); + assert.ok(sourceProjection); + return { + transitions: [ + buildModelProjectionTransition({ + sessionId: 'session-1', + target: { + runtimeEventId: coveredResult!.id, + part: 'tool_result', + toolCallId: content.id, + toolName: content.name, + }, + sourceProjection, + replacement: { + version: 1, + kind: 'text', + text: 'POST_FOLD_TRANSITIONED_RESULT', + }, + now: 1, + }), + ], + unreadableTargets: new Set(), + unscopedUnreadable: 0, + }; + }, + }); + await runFixtureTurn(fixture, consumer); + + assert.equal(fixture.model.doStreamCalls.length, 4); + // The fold ran once — during the third request's shaping — and persisted + // its checkpoint before the transition landed. + assert.equal(fixture.recorded.length, 1); + assert.match(promptJson(fixture, 2), /MID_TURN_SUMMARY_SENTINEL/); + assert.ok(coveredResult); + const fourthPrompt = promptJson(fixture, 3); + // The next durable projection judged the checkpoint against the same + // snapshot the request was built from: stale block dropped, raw body the + // transition removed stays removed, the verbatim anchor and uncovered tail + // replay from the effective view. + assert.equal(fourthPrompt.includes('maka_history_compact_checkpoint'), false); + assert.equal(fourthPrompt.includes('MID_TURN_SUMMARY_SENTINEL'), false); + assert.equal(fourthPrompt.includes('RAW_SPAN_ONE_'), false); + assert.match(fourthPrompt, /POST_FOLD_TRANSITIONED_RESULT/); + assert.equal(fourthPrompt.includes(ANCHOR_TEXT), true); + }); + test('compacts at most once per step, and again once a step is accepted', async () => { // The budget is one fold per logical step, not one per send. The first // fold covers everything except the live head, so re-entering on that same diff --git a/packages/runtime/src/ai-sdk-compaction.ts b/packages/runtime/src/ai-sdk-compaction.ts index f6967f2456..ca93b29a81 100644 --- a/packages/runtime/src/ai-sdk-compaction.ts +++ b/packages/runtime/src/ai-sdk-compaction.ts @@ -56,7 +56,7 @@ import { isHistoryCompactContentEvent } from './history-compaction.js'; import { canContinueHistoryCompactCheckpointForModel, canReplayHistoryCompactCheckpointForModel, - historyCompactSourceDigest, + checkHistoryCompactCheckpointCurrency, matchHistoryCompactCheckpointPrefix, projectHistoryCompactCheckpointReplay, type HistoryCompactCheckpoint, @@ -314,6 +314,9 @@ export class AiSdkCompaction { } if (previousCheckpoint) { + // Load the transition view only when the raw prefix can replay at all: + // a checkpoint that cannot match never reaches for the ledger, the + // same as before (#5930 keeps the load point, not the check). const match = matchHistoryCompactCheckpointPrefix(previousCheckpoint, runtimeContext); if (!match.reason && match.successorRuntimeEvents.length === 0) { // Raw identity is not enough: a projection transition committed @@ -322,11 +325,15 @@ export class AiSdkCompaction { // digest to still match — the same gate the pre-send path applies // (#5929). On drift, fall through to the planner, whose roll-forward // currency check discards the stale checkpoint and re-summarizes. - const effectiveCovered = await this.foldEffectiveModelHistory(match.coveredRuntimeEvents); - if (this.checkpointEffectiveCoverageMatches(previousCheckpoint, effectiveCovered)) { + const currency = checkHistoryCompactCheckpointCurrency( + previousCheckpoint, + runtimeContext, + await this.loadModelProjectionTransitions(), + ); + if (currency.status === 'current') { const projectedEvents = projectHistoryCompactCheckpointReplay( previousCheckpoint, - match.coveredRuntimeEvents, + currency.match.coveredRuntimeEvents, [], ); return { @@ -380,11 +387,11 @@ export class AiSdkCompaction { : {}), ...(automaticMemoryBoundary ? { memoryExtractionBoundary: automaticMemoryBoundary } : {}), ...(previousCheckpoint ? { previousCheckpoint } : {}), - // The planner projects the covered span to its effective view before + // The planner folds the covered span through this snapshot before // summarizing and pins its digest as coverage.effectiveSourceDigest: // the summary can never quote a body a durable transition removed, and // a later transition invalidates the checkpoint at replay (#4845). - projectEffectiveCoverage: (covered) => this.foldEffectiveModelHistory(covered), + loadProjectionSnapshot: () => this.loadModelProjectionTransitions(), summarize: async ({ coveredRuntimeEvents, newlyFoldedRuntimeEvents, @@ -565,28 +572,6 @@ export class AiSdkCompaction { .events; } - /** - * Whether the checkpoint's pinned effective view still is the covered - * prefix's effective view. The raw identity match happens later in the - * replay authority; this gate is about content currency: a projection - * transition committed after the fold changes what the model may see of the - * covered span without touching the raw ledger, and the checkpoint's summary - * or provider state must not survive that drift (#4845 review). - */ - private checkpointEffectiveCoverageMatches( - checkpoint: HistoryCompactCheckpoint, - effectiveEvents: readonly RuntimeEvent[], - ): boolean { - const pinned = checkpoint.coverage.effectiveSourceDigest; - if (pinned === undefined) return false; - const covered = effectiveEvents - .filter(isHistoryCompactContentEvent) - .slice(0, checkpoint.coverage.eventCount); - if (covered.length !== checkpoint.coverage.eventCount) return false; - if (covered.at(-1)?.id !== checkpoint.coverage.through.runtimeEventId) return false; - return historyCompactSourceDigest(covered) === pinned; - } - /** * Apply the same bounded-result rule to current and prior events. * @@ -716,16 +701,12 @@ export class AiSdkCompaction { // This gate is only for the case where the raw identity matches but a // projection transition committed after the fold changed the effective // view the summary (or provider state) was built from (#4845 review). - const rawIdentityMatch = matchHistoryCompactCheckpointPrefix( + const currency = checkHistoryCompactCheckpointCurrency( loadedCheckpoint, runtimeContext.filter(isHistoryCompactContentEvent), + prepared.projectionSnapshot, ); - if (rawIdentityMatch.reason) { - nextPolicy = { - ...nextPolicy, - historyCompact: { ...nextPolicy.historyCompact!, checkpoint: loadedCheckpoint }, - }; - } else if (this.checkpointEffectiveCoverageMatches(loadedCheckpoint, effective.events)) { + if (currency.status !== 'effective_history_changed') { nextPolicy = { ...nextPolicy, historyCompact: { ...nextPolicy.historyCompact!, checkpoint: loadedCheckpoint }, @@ -1093,7 +1074,7 @@ export class AiSdkCompaction { }, } : {}), - projectEffectiveCoverage: (covered) => this.foldEffectiveModelHistory(covered), + loadProjectionSnapshot: () => this.loadModelProjectionTransitions(), summarize: async ({ coveredRuntimeEvents, newlyFoldedRuntimeEvents, previousCheckpoint }) => { // Same contract as the standalone path: the planner hands the // effective (transition-folded) view to the summarizer and pins its diff --git a/packages/runtime/src/ai-sdk-turn.ts b/packages/runtime/src/ai-sdk-turn.ts index 09a9a769b7..d66cb696f0 100644 --- a/packages/runtime/src/ai-sdk-turn.ts +++ b/packages/runtime/src/ai-sdk-turn.ts @@ -169,9 +169,8 @@ import { import { isHistoryCompactContentEvent } from './history-compaction.js'; import { canContinueHistoryCompactCheckpointForModel, - historyCompactSourceDigest, + checkHistoryCompactCheckpointCurrency, isProviderHistoryCompactCheckpoint, - matchHistoryCompactCheckpointPrefix, projectHistoryCompactCheckpointReplay, type HistoryCompactCheckpoint, } from './history-compact-checkpoint.js'; @@ -1333,33 +1332,26 @@ export class AiSdkTurn { let replayEvents = rawProjectionEvents; let effectiveProjectionCheckpoint = projectionCheckpoint; if (projectionCheckpoint) { - const checkpointMatch = matchHistoryCompactCheckpointPrefix( + const currency = checkHistoryCompactCheckpointCurrency( projectionCheckpoint, rawProjectionEvents, + pruned.projectionSnapshot, ); - if (checkpointMatch.reason) { - throw new Error(`durable checkpoint projection mismatch: ${checkpointMatch.reason}`); + if (currency.status === 'raw_mismatch') { + throw new Error(`durable checkpoint projection mismatch: ${currency.reason}`); } // Content-currency guard: the raw identity still matches, but a // transition committed after this fold (e.g. an active-turn prune // in this very send) changed the effective view the block was // built from. Replay without the stale block — the provider // decides fit and overflow recovery re-folds (#4845 review). - const pinnedEffectiveDigest = projectionCheckpoint.coverage.effectiveSourceDigest; - const coveredEffective = await this.deps.compaction.foldEffectiveModelHistory( - checkpointMatch.coveredRuntimeEvents, - pruned.projectionSnapshot, - ); - if ( - pinnedEffectiveDigest === undefined || - historyCompactSourceDigest(coveredEffective) !== pinnedEffectiveDigest - ) { + if (currency.status === 'effective_history_changed') { effectiveProjectionCheckpoint = undefined; } else { replayEvents = projectHistoryCompactCheckpointReplay( projectionCheckpoint, - checkpointMatch.coveredRuntimeEvents, - checkpointMatch.successorRuntimeEvents, + currency.match.coveredRuntimeEvents, + currency.match.successorRuntimeEvents, ); } // The checkpoint was capacity-validated before it was persisted. diff --git a/packages/runtime/src/conversation-copy.ts b/packages/runtime/src/conversation-copy.ts index b1d88ec166..7b6bbbe234 100644 --- a/packages/runtime/src/conversation-copy.ts +++ b/packages/runtime/src/conversation-copy.ts @@ -43,7 +43,7 @@ import { TOOL_RECOVERY_DECISION_FACT_KIND } from '@maka/core/tool-recovery-fact' import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; import { buildHistoryCompactCheckpoint, - historyCompactSourceDigest, + checkHistoryCompactCheckpointCurrency, matchHistoryCompactCheckpointPrefix, validateHistoryCompactCheckpointShape, } from './history-compact-checkpoint.js'; @@ -1144,17 +1144,18 @@ function cloneAgentRunEvent( const match = matchHistoryCompactCheckpointPrefix(sourceCheckpoint, sourceCompactableEvents); if (match.reason) return null; // Rebinding a digest must not make an already stale source summary valid. - const sourceEffective = reduceEffectiveModelProjections( - match.coveredRuntimeEvents, - [...clonedTransitions.keys()].map((record) => - decodeModelProjectionTransition(record.data?.transition, record.sessionId), - ), - ).events; - if ( - sourceCheckpoint.coverage.effectiveSourceDigest !== - historyCompactSourceDigest(sourceEffective) - ) - return null; + const currency = checkHistoryCompactCheckpointCurrency( + sourceCheckpoint, + sourceCompactableEvents, + { + transitions: [...clonedTransitions.keys()].map((record) => + decodeModelProjectionTransition(record.data?.transition, record.sessionId), + ), + unreadableTargets: new Set(), + unscopedUnreadable: 0, + }, + ); + if (currency.status !== 'current') return null; // Copy is an admission seam for the sectioned summary contract: a marked // checkpoint whose summary no longer satisfies the COMPLETE predicate — // re-runnable here on structure and truncation (the size floor needs the diff --git a/packages/runtime/src/history-compact-checkpoint.ts b/packages/runtime/src/history-compact-checkpoint.ts index ab28f8ed88..c8b3676fa0 100644 --- a/packages/runtime/src/history-compact-checkpoint.ts +++ b/packages/runtime/src/history-compact-checkpoint.ts @@ -22,6 +22,11 @@ import { Buffer } from 'node:buffer'; import type { ExecutionLogCoverage } from '@maka/core/execution-log-coverage'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { nonEmpty, sha256 } from './context-budget-helpers.js'; +import { estimateRuntimeEventChars } from './model-history.js'; +import { + reduceEffectiveModelProjections, + type LoadedModelProjectionTransitions, +} from './model-projection-transition-ledger.js'; import type { ModelMessage } from './model-protocol.js'; import { stableStringify } from './request-shape.js'; import { @@ -650,6 +655,61 @@ export function matchHistoryCompactCheckpointPrefix( }; } +/** + * The two-layer currency decision every checkpoint consumer shares (#5930): a + * checkpoint replays only when its RAW identity still matches the current + * RuntimeEvent prefix AND the EFFECTIVE, transition-folded view of the covered + * span still matches the digest pinned at creation. `raw_mismatch` carries the + * raw-match failure; `effective_history_changed` covers every way the folded + * view can drift — missing pin, too few surviving content events, a covered + * through-event the effective prefix never reaches, or a digest difference. + * Callers keep their own policy for each outcome; this function only decides. + */ +export type HistoryCompactCheckpointCurrency = + | { + status: 'current'; + match: Extract; + } + | { + status: 'raw_mismatch'; + reason: 'invalid_checkpoint' | 'coverage_miss' | 'source_hash_mismatch'; + } + | { status: 'effective_history_changed' }; + +/** + * Judges a checkpoint against `events` under `projectionSnapshot` — the SAME + * loaded transition view the caller used to build the request being judged. + * The function never loads transitions: a checkpoint is only current for the + * snapshot it is checked with, and a record committed after that snapshot + * belongs to the next request's decision. + */ +export function checkHistoryCompactCheckpointCurrency( + checkpoint: HistoryCompactCheckpoint, + events: readonly RuntimeEvent[], + projectionSnapshot: LoadedModelProjectionTransitions, +): HistoryCompactCheckpointCurrency { + const match = matchHistoryCompactCheckpointPrefix(checkpoint, events); + if (match.reason !== undefined) { + return { status: 'raw_mismatch', reason: match.reason }; + } + const effectiveCovered = reduceEffectiveModelProjections( + match.coveredRuntimeEvents, + projectionSnapshot.transitions, + projectionSnapshot.unreadableTargets, + ).events.filter(isHistoryCompactContentEvent); + const pinned = checkpoint.coverage.effectiveSourceDigest; + if ( + pinned === undefined || + effectiveCovered.length < checkpoint.coverage.eventCount || + effectiveCovered[checkpoint.coverage.eventCount - 1]?.id !== + checkpoint.coverage.through.runtimeEventId || + historyCompactSourceDigest(effectiveCovered.slice(0, checkpoint.coverage.eventCount)) !== pinned + ) { + return { status: 'effective_history_changed' }; + } + return { status: 'current', match }; +} + /** * Deterministic RuntimeEvent projection for a checkpoint. V2 prepends its * text block; V3 stays out of the event list and is materialized directly at @@ -799,6 +859,11 @@ function effectiveDigestEvent(event: RuntimeEvent): unknown { return { ...event, content: { ...identity, result } }; } +/** True when the event carries model-visible content the compact projection counts. */ +export function isHistoryCompactContentEvent(event: RuntimeEvent): boolean { + return event.modelVisibility !== 'hidden' && estimateRuntimeEventChars(event) > 0; +} + function validHistoryCompactProviderState(value: unknown): value is HistoryCompactProviderState { if (!value || typeof value !== 'object') return false; const state = value as Partial; diff --git a/packages/runtime/src/history-compaction.ts b/packages/runtime/src/history-compaction.ts index b33dd38865..a89ee83bfe 100644 --- a/packages/runtime/src/history-compaction.ts +++ b/packages/runtime/src/history-compaction.ts @@ -21,7 +21,7 @@ import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { ContextBudgetDiagnostic } from '@maka/core/usage-stats/types'; import { finitePositive } from './context-budget-helpers.js'; -import { estimateRuntimeEventChars, estimateRuntimeEventsTokens } from './model-history.js'; +import { estimateRuntimeEventsTokens } from './model-history.js'; import { compactionDecisionDiagnosticPatch } from './compaction-boundary.js'; import { HistoryCompactSummarizerError, @@ -30,8 +30,9 @@ import { import { findCheckpointSummaryDefect } from './history-compact-summary-validation.js'; import { buildHistoryCompactCheckpoint, + checkHistoryCompactCheckpointCurrency, historyCompactCheckpointToRuntimeEvent, - historyCompactSourceDigest, + isHistoryCompactContentEvent, matchHistoryCompactCheckpointPrefix, midTurnHeadAnchorEvent, projectHistoryCompactCheckpointReplay, @@ -39,6 +40,10 @@ import { type HistoryCompactMemoryExtractionBoundary, type HistoryCompactProviderState, } from './history-compact-checkpoint.js'; +import { + reduceEffectiveModelProjections, + type LoadedModelProjectionTransitions, +} from './model-projection-transition-ledger.js'; /** * Context compaction: the pure measurement + safe-boundary engine. @@ -198,14 +203,16 @@ export interface PlanHistoryCompactionInput { /** Present only when this automatic Compaction should create a Memory task. */ memoryExtractionBoundary?: HistoryCompactMemoryExtractionBoundary; /** - * Projects the covered span to its effective (transition-folded) view. When - * present, the summary is written from that view and its digest is pinned as - * `coverage.effectiveSourceDigest`, so a later projection transition - * invalidates the checkpoint instead of being restored by it (#4845 review). + * Loads the model-projection transition view this plan is judged and built + * under. Called only after a safe covered boundary exists — the ledger is + * never read for a fold that cannot run — and the loaded snapshot then + * covers the whole pass: the covered span is folded through it before + * summarizing, its digest is pinned as `coverage.effectiveSourceDigest` so + * a later projection transition invalidates the checkpoint instead of being + * restored by it (#4845 review), and the same view gates checkpoint + * roll-forward. */ - projectEffectiveCoverage?: ( - coveredRuntimeEvents: readonly RuntimeEvent[], - ) => Promise; + loadProjectionSnapshot?: () => Promise; summarize: HistoryCompactionSummarizer; } @@ -326,6 +333,13 @@ export async function planHistoryCompaction( const coveredRuntimeEvents = input.orderedEvents.slice(0, boundary.coveredCount); const tailRuntimeEvents = input.orderedEvents.slice(boundary.coveredCount); + // One transition view per pass: the same loaded snapshot judges the + // previous checkpoint's currency and produces the effective view the + // summary is written from. + const projectionSnapshot = input.loadProjectionSnapshot + ? await input.loadProjectionSnapshot() + : undefined; + // Roll forward from a previous checkpoint when it is an exact prefix of the // covered events, so the summary only re-reads the newly folded span. const checkpointMatch = input.previousCheckpoint @@ -340,12 +354,13 @@ export async function planHistoryCompaction( // would launder it into the new checkpoint under the current effective // digest — later replay guards would then pass it (#4845 review). On // drift, discard the checkpoint and re-summarize the whole effective span. - if (previousCheckpoint && checkpointMatch && input.projectEffectiveCoverage) { - const pinned = previousCheckpoint.coverage.effectiveSourceDigest; - const previousEffectiveCovered = await input.projectEffectiveCoverage( - checkpointMatch.coveredRuntimeEvents, + if (previousCheckpoint && projectionSnapshot) { + const currency = checkHistoryCompactCheckpointCurrency( + previousCheckpoint, + coveredRuntimeEvents, + projectionSnapshot, ); - if (pinned === undefined || historyCompactSourceDigest(previousEffectiveCovered) !== pinned) { + if (currency.status !== 'current') { previousCheckpoint = undefined; } } @@ -355,8 +370,12 @@ export async function planHistoryCompaction( // The model-visible summary reads the effective (transition-folded) view // of the covered span; the raw events keep the coverage identity. - const effectiveCoveredRuntimeEvents = input.projectEffectiveCoverage - ? [...(await input.projectEffectiveCoverage(coveredRuntimeEvents))] + const effectiveCoveredRuntimeEvents = projectionSnapshot + ? reduceEffectiveModelProjections( + coveredRuntimeEvents, + projectionSnapshot.transitions, + projectionSnapshot.unreadableTargets, + ).events : undefined; const effectiveNewlyFoldedRuntimeEvents = effectiveCoveredRuntimeEvents ? newlyFoldedRuntimeEvents.length === coveredRuntimeEvents.length @@ -544,7 +563,6 @@ export function applyRuntimeEventHistoryCompact( }; } -/** True when the event carries model-visible content the compact projection counts. */ -export function isHistoryCompactContentEvent(event: RuntimeEvent): boolean { - return event.modelVisibility !== 'hidden' && estimateRuntimeEventChars(event) > 0; -} +// Re-exported so existing consumers keep one import surface; the predicate +// lives beside the checkpoint currency decision that now also needs it (#5930). +export { isHistoryCompactContentEvent } from './history-compact-checkpoint.js';