diff --git a/packages/core/package.json b/packages/core/package.json index d8a91f9ff8..5dd68a2c16 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -8,6 +8,7 @@ "private": true, "exports": { "./durable-tool-result-projection": "./dist/durable-tool-result-projection.js", + "./model-projection-transition": "./dist/model-projection-transition.js", "./canonical-runtime-event": "./dist/canonical-runtime-event.js", "./runtime-boundary": "./dist/runtime-boundary.js", "./runtime-event": "./dist/runtime-event.js", diff --git a/packages/core/src/__tests__/model-projection-transition.test.ts b/packages/core/src/__tests__/model-projection-transition.test.ts new file mode 100644 index 0000000000..3b641d1623 --- /dev/null +++ b/packages/core/src/__tests__/model-projection-transition.test.ts @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import type { DurableToolResultProjection } from '../durable-tool-result-projection.js'; +import { + buildModelProjectionTransition, + decodeModelProjectionTransition, + durableToolResultProjectionDigest, + isModelProjectionTransition, +} from '../model-projection-transition.js'; + +const SOURCE: DurableToolResultProjection = { + version: 1, + kind: 'text', + text: 'a large tool result', +}; + +const REPLACEMENT: DurableToolResultProjection = { + version: 1, + kind: 'json', + value: { kind: 'maka.archived_tool_result', artifactId: 'artifact-1' }, +}; + +function build(overrides: Partial[0]> = {}) { + return buildModelProjectionTransition({ + sessionId: 'session-1', + target: { + runtimeEventId: 'rt-result', + part: 'tool_result', + toolCallId: 'tool-1', + toolName: 'Read', + }, + sourceProjection: SOURCE, + replacement: REPLACEMENT, + now: 1_700_000_000, + ...overrides, + }); +} + +describe('model projection transition schema', () => { + test('digests the same projection identically regardless of key order', () => { + const reordered = { + kind: 'text', + text: SOURCE.text, + version: 1, + } as DurableToolResultProjection; + assert.equal( + durableToolResultProjectionDigest(reordered), + durableToolResultProjectionDigest(SOURCE), + ); + }); + + test('binds the record to the projection it may replace', () => { + const transition = build(); + assert.equal(transition.sourceProjectionDigest, durableToolResultProjectionDigest(SOURCE)); + assert.equal(transition.createdAt, 1_700_000_000); + }); + + test('derives one id from content, so a duplicated concurrent append is idempotent', () => { + assert.equal(build().transitionId, build().transitionId); + // The clock is not part of the decision, so it must not be part of the id. + assert.equal(build().transitionId, build({ now: 1_800_000_000 }).transitionId); + assert.notEqual( + build().transitionId, + build({ previousTransitionId: 'mptransition-earlier' }).transitionId, + ); + }); + + test('rejects a record that belongs to another Session', () => { + const transition = build(); + assert.ok(isModelProjectionTransition(transition, 'session-1')); + assert.equal(isModelProjectionTransition(transition, 'session-2'), false); + assert.throws(() => decodeModelProjectionTransition(transition, 'session-2')); + }); + + test('rejects an unknown field and an unrepresentable replacement', () => { + const transition = build(); + assert.throws(() => + decodeModelProjectionTransition({ ...transition, extra: true }, 'session-1'), + ); + assert.throws(() => + decodeModelProjectionTransition( + { ...transition, replacement: { version: 1, kind: 'text' } }, + 'session-1', + ), + ); + }); +}); diff --git a/packages/core/src/agent-run.ts b/packages/core/src/agent-run.ts index 5b69d3dc0e..ac0fcedb24 100644 --- a/packages/core/src/agent-run.ts +++ b/packages/core/src/agent-run.ts @@ -426,6 +426,7 @@ export const AGENT_RUN_EVENT_TYPES = [ 'provider_request_attempt_recorded', 'model_call_attempt_recorded', 'history_compact_checkpoint_recorded', + 'model_projection_transition_recorded', 'task_gate_decided', 'abort_requested', 'run_completed', diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index c5ccd3b0ff..94d57b5efa 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -794,7 +794,13 @@ export type ToolResultContent = originalEstimatedTokens: number; originalBytes: number; rewriteVersion: number; - reason: 'stale_tool_result_pruned_before_compact'; + /** + * Both prune paths now record the same durable projection transition + * (#4283), so the archived-result read model spans both reasons. + */ + reason: + | 'stale_tool_result_pruned_before_compact' + | 'active_current_turn_tool_result_pruned_before_next_step'; } | { kind: 'terminal'; diff --git a/packages/core/src/model-projection-transition.ts b/packages/core/src/model-projection-transition.ts new file mode 100644 index 0000000000..779bff2626 --- /dev/null +++ b/packages/core/src/model-projection-transition.ts @@ -0,0 +1,226 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Durable model-projection transitions (#4283). + * + * A successful model-visible history is append-only. Any lossy change to + * already-visible history — pruning a large Tool Result, omitting an image a + * provider rejected — must first become a durable successor in the append-only + * operational AgentRunEvent ledger, so no later replay, compaction, branch, or + * restart can restore the replaced form. + * + * This module owns the one typed record that expresses such a change. It is + * sparse — it names one projection part of one RuntimeEvent — and so is not a + * generalization of the contiguous-prefix `HistoryCompactCheckpoint` (#4283). + * + * Everything a deterministic reduction needs is on the record: + * + * - `target` — which RuntimeEvent projection part is replaced; + * - `sourceProjectionDigest` — the exact projection it is allowed to replace, + * so a stale concurrent writer cannot apply against content it never saw; + * - `replacement` — what the model sees instead, including where the replaced + * body still lives when it is recoverable at all; + * - `previousTransitionId` — the predecessor for this target, which is also the + * reduction's ordering authority: readers follow the chain rather than a + * cursor, so ledger order and wall-clock skew cannot change the result. + */ + +import * as nodeCrypto from 'node:crypto'; + +import { + decodeDurableToolResultProjection, + type DurableToolResultProjection, +} from './durable-tool-result-projection.js'; +import { stableJsonStringify } from './tool-args-identity.js'; +import { defineObjectShape, hasExactShape, isFiniteNumber, isRecord } from './record-schema.js'; + +export const MODEL_PROJECTION_TRANSITION_VERSION = 1 as const; + +/** The append-only operational ledger record that carries one transition. */ +export const MODEL_PROJECTION_TRANSITION_EVENT_TYPE = 'model_projection_transition_recorded'; + +/** + * The addressed projection part. `tool_result` is the whole durable Tool Result + * projection of one `function_response` RuntimeEvent — the only part kind that + * exists while the projection schema has no independently addressable segments. + */ +export interface ModelProjectionTransitionTarget { + runtimeEventId: string; + part: 'tool_result'; + toolCallId: string; + toolName: string; +} + +export interface ModelProjectionTransition { + kind: 'maka.model_projection_transition'; + version: typeof MODEL_PROJECTION_TRANSITION_VERSION; + transitionId: string; + sessionId: string; + createdAt: number; + target: ModelProjectionTransitionTarget; + /** Digest of the projection this record is allowed to replace. */ + sourceProjectionDigest: `sha256:${string}`; + replacement: DurableToolResultProjection; + /** + * The transition this one supersedes for the same target, if any. + * + * Absent means "applies to the base projection". Together with + * `sourceProjectionDigest` this is the only ordering a reducer needs. + */ + previousTransitionId?: string; +} + +const TRANSITION_SHAPE = defineObjectShape()( + [ + 'kind', + 'version', + 'transitionId', + 'sessionId', + 'createdAt', + 'target', + 'sourceProjectionDigest', + 'replacement', + ], + ['previousTransitionId'], +); + +const TARGET_SHAPE = defineObjectShape()( + ['runtimeEventId', 'part', 'toolCallId', 'toolName'], + [], +); + +/** + * The identity of one durable projection, over strict key-sorted JSON. + * + * Writer and reducer must agree byte for byte: a digest computed one way at + * write time and another at read time would silently turn every transition + * into a source mismatch, i.e. into content that quietly comes back. + */ +export function durableToolResultProjectionDigest( + projection: DurableToolResultProjection, +): `sha256:${string}` { + return `sha256:${nodeCrypto + .createHash('sha256') + .update(stableJsonStringify(projection)) + .digest('hex')}`; +} + +export interface BuildModelProjectionTransitionInput { + sessionId: string; + target: ModelProjectionTransitionTarget; + sourceProjection: DurableToolResultProjection; + replacement: DurableToolResultProjection; + previousTransitionId?: string; + now: number; +} + +/** + * Build one transition with a content-derived id. + * + * The id digests everything the record asserts and nothing about when or where + * it was written, so two writers that independently decide the same replacement + * for the same source produce the same record: a duplicate concurrent append is + * idempotent rather than a second competing successor. + */ +export function buildModelProjectionTransition( + input: BuildModelProjectionTransitionInput, +): ModelProjectionTransition { + const sourceProjectionDigest = durableToolResultProjectionDigest( + decodeDurableToolResultProjection(input.sourceProjection), + ); + const replacement = decodeDurableToolResultProjection(input.replacement); + const body = { + version: MODEL_PROJECTION_TRANSITION_VERSION, + sessionId: input.sessionId, + target: input.target, + sourceProjectionDigest, + replacement, + ...(input.previousTransitionId ? { previousTransitionId: input.previousTransitionId } : {}), + }; + const transitionId = `mptransition-${nodeCrypto + .createHash('sha256') + .update(stableJsonStringify(body)) + .digest('hex') + .slice(0, 32)}`; + return decodeModelProjectionTransition( + { + kind: 'maka.model_projection_transition', + transitionId, + createdAt: input.now, + ...body, + }, + input.sessionId, + ); +} + +export function decodeModelProjectionTransition( + value: unknown, + sessionId: string, +): ModelProjectionTransition { + if (!isModelProjectionTransition(value, sessionId)) { + throw new Error('Invalid model projection transition'); + } + return value; +} + +export function isModelProjectionTransition( + value: unknown, + sessionId: string, +): value is ModelProjectionTransition { + if ( + !isRecord(value) || + !hasExactShape(value, TRANSITION_SHAPE) || + value.kind !== 'maka.model_projection_transition' || + value.version !== MODEL_PROJECTION_TRANSITION_VERSION || + !nonEmptyString(value.transitionId) || + value.sessionId !== sessionId || + !isFiniteNumber(value.createdAt) || + !isSha256Digest(value.sourceProjectionDigest) || + (value.previousTransitionId !== undefined && !nonEmptyString(value.previousTransitionId)) || + !isTransitionTarget(value.target) + ) { + return false; + } + try { + decodeDurableToolResultProjection(value.replacement); + } catch { + return false; + } + return true; +} + +function isTransitionTarget(value: unknown): value is ModelProjectionTransitionTarget { + return ( + isRecord(value) && + hasExactShape(value, TARGET_SHAPE) && + nonEmptyString(value.runtimeEventId) && + value.part === 'tool_result' && + nonEmptyString(value.toolCallId) && + nonEmptyString(value.toolName) + ); +} + +function isSha256Digest(value: unknown): value is `sha256:${string}` { + return typeof value === 'string' && /^sha256:[a-f0-9]{64}$/.test(value); +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} diff --git a/packages/core/src/tool-result-record-schema.ts b/packages/core/src/tool-result-record-schema.ts index 5e74492a9a..dc0b090285 100644 --- a/packages/core/src/tool-result-record-schema.ts +++ b/packages/core/src/tool-result-record-schema.ts @@ -246,7 +246,8 @@ function isNonShellToolResultContent(value: unknown): value is ToolResultContent isFiniteNumber(value.originalEstimatedTokens) && isFiniteNumber(value.originalBytes) && isFiniteNumber(value.rewriteVersion) && - value.reason === 'stale_tool_result_pruned_before_compact' + (value.reason === 'stale_tool_result_pruned_before_compact' || + value.reason === 'active_current_turn_tool_result_pruned_before_next_step') ); case 'image': return ( diff --git a/packages/runtime-host/src/server/deep-research-coordinator.ts b/packages/runtime-host/src/server/deep-research-coordinator.ts index c27025ea88..ade7750dbb 100644 --- a/packages/runtime-host/src/server/deep-research-coordinator.ts +++ b/packages/runtime-host/src/server/deep-research-coordinator.ts @@ -89,7 +89,7 @@ export class HostDeepResearchCoordinator { readText: (artifactId, options) => this.#artifacts.readTextInSession(sessionId, artifactId, options), delete: (artifactId) => - this.#artifacts.deleteOwnedDeepResearchArtifactInSession(sessionId, artifactId), + this.#artifacts.deleteOwnedArtifactInSession(sessionId, artifactId, 'deep_research'), }, }); } diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index 6f83065f1f..e5dd3bd2e6 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -102,7 +102,7 @@ type HostExecutionRuntimePolicyAuthority = { type HostExecutionArtifactAuthority = Pick< InteractiveArtifactStoreWriter, - 'create' | 'readDurableAttachmentBinary' + 'create' | 'createOwned' | 'readDurableAttachmentBinary' | 'deleteOwnedArtifactInSession' >; type HostExecutionUsageAuthority = { @@ -345,7 +345,11 @@ async function buildHostAiSdkBackend( ); } : undefined; - const planProjectionImage = createReadImageSnapshotPlanner(input.artifacts); + const planProjectionImage = createReadImageSnapshotPlanner( + input.artifacts, + (sessionId, artifactId) => + input.artifacts.deleteOwnedArtifactInSession(sessionId, artifactId, 'tool_result_projection'), + ); try { return new HostAiSdkBackend( @@ -441,6 +445,8 @@ async function buildHostAiSdkBackend( summarizeHistoryCompact, historyCompactRoute, recordHistoryCompactCheckpoint: input.context.recordHistoryCompactCheckpoint, + loadModelProjectionTransitions: input.context.loadModelProjectionTransitions, + recordModelProjectionTransition: input.context.recordModelProjectionTransition, loadTurnRuntimeEvents: input.context.loadTurnRuntimeEvents, allowMidTurnHistoryCompaction: input.context.allowMidTurnHistoryCompaction, recordRunTrace: input.context.recordRunTrace, diff --git a/packages/runtime-host/src/server/session-revision-coordinator.ts b/packages/runtime-host/src/server/session-revision-coordinator.ts index ab182f54c1..29c11860fc 100644 --- a/packages/runtime-host/src/server/session-revision-coordinator.ts +++ b/packages/runtime-host/src/server/session-revision-coordinator.ts @@ -41,6 +41,12 @@ import { type ConversationRuntimeLedgerCopyPlan, } from '@maka/runtime/conversation-copy'; import { isArchivedToolResultPlaceholder } from '@maka/runtime/context-budget'; +import type { AgentRunEvent } from '@maka/core/agent-run'; +import { + decodeModelProjectionTransition, + MODEL_PROJECTION_TRANSITION_EVENT_TYPE, + type ModelProjectionTransition, +} from '@maka/core/model-projection-transition'; import { type SessionManager } from '@maka/runtime/session-manager'; import { authenticateInteractiveArtifactStoreWriter, @@ -381,6 +387,7 @@ export class HostSessionRevisionCoordinator { plan.runs.flatMap(({ runtimeEvents }) => runtimeEvents), slice.messages, copyTurnIds, + plan.runs.flatMap(({ operationalEvents }) => operationalEvents), ); if (!archivePreflight.ok) return archivePreflight.outcome; const linkedChildRequests = collectConversationCopyLinkedChildReferences({ @@ -594,6 +601,7 @@ export class HostSessionRevisionCoordinator { sourceEvents: readonly RuntimeEvent[], copiedMessages: readonly StoredMessage[], copyTurnIds: readonly string[], + operationalEvents: readonly AgentRunEvent[], ): Promise< | { readonly ok: true; @@ -609,6 +617,7 @@ export class HostSessionRevisionCoordinator { sourceEvents, copiedMessages, copyTurnIds, + operationalEvents, ); if (!archives) { return { @@ -929,6 +938,7 @@ function collectArchivedToolResultPlaceholders( events: readonly RuntimeEvent[], messages: readonly StoredMessage[], copyTurnIds: readonly string[], + operationalEvents: readonly AgentRunEvent[], ): ArchivedToolResultCopyDescriptor[] | null { const retainedTurnIds = new Set(copyTurnIds); const archives = new Map(); @@ -948,6 +958,21 @@ function collectArchivedToolResultPlaceholders( if (!add(event.content.result)) return null; } } + // A pruned result's body is now named by its durable transition rather than + // by the RuntimeEvent, so the copy must reach the ledger to find it. Missing + // this is not a cosmetic gap: the target Session would carry a placeholder + // pointing at an artifact that was never copied. + for (const event of operationalEvents) { + if (event.type !== MODEL_PROJECTION_TRANSITION_EVENT_TYPE) continue; + let transition: ModelProjectionTransition; + try { + transition = decodeModelProjectionTransition(event.data?.transition, event.sessionId); + } catch { + return null; + } + if (transition.replacement.kind !== 'json') return null; + if (!add(transition.replacement.value)) return null; + } for (const message of messages) { if (message.type !== 'tool_result') continue; if (message.content.kind === 'json') { diff --git a/packages/runtime/src/__tests__/active-tool-result-prune.test.ts b/packages/runtime/src/__tests__/active-tool-result-prune.test.ts index 2644a84ce6..394fc49dc5 100644 --- a/packages/runtime/src/__tests__/active-tool-result-prune.test.ts +++ b/packages/runtime/src/__tests__/active-tool-result-prune.test.ts @@ -22,7 +22,14 @@ import { describe, test } from 'node:test'; import { z } from 'zod'; import type { ModelMessage } from '../model-protocol.js'; -import { rewriteActiveToolResultsInMessages } from '../active-tool-result-prune.js'; +import { + rewriteActiveToolResultsInMessages as rewriteActiveToolResultsInMessagesNarrow, + type ActiveToolResultProjectionSource, + type ActiveToolResultPruneInput, + type ActiveToolResultPruneResult, +} from '../active-tool-result-prune.js'; +import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection'; +import type { ModelProjectionTransition } from '@maka/core/model-projection-transition'; import { planActiveToolResultSupersession } from '../active-tool-result-working-set.js'; import { composeRequestProjection } from '../request-projection.js'; import { ToolAvailabilityRuntime, TOOL_SEARCH_NAME } from '../tool-availability.js'; @@ -79,7 +86,7 @@ describe('active current-turn tool-result pruning', () => { assert.deepEqual(result?.activeTools, ['Read', TOOL_SEARCH_NAME]); assert.ok(result?.messages); - assert.match(JSON.stringify(result.messages), /maka\.active_archived_tool_result/); + assert.match(JSON.stringify(result.messages), /maka\.archived_tool_result/); }); test('oversized eligible current-turn tool result is archived and replaced', async () => { @@ -89,14 +96,12 @@ describe('active current-turn tool-result pruning', () => { bodySha256: string; toolCallId: string; }> = []; - const archivedPlaceholders = new Map(); const rewritten = await rewriteActiveToolResultsInMessages({ messages: [largeToolMessage('Read', 'tool-1', largeBody)], policy: { enabled: true, maxCurrentResultEstimatedTokens: 1 }, stepNumber: 1, turnId: 'turn-1', charsPerToken: 1, - archivedPlaceholders, archiveToolResult: (candidate) => { archiveRequests.push({ serializedResult: candidate.serializedResult, @@ -111,7 +116,7 @@ describe('active current-turn tool-result pruning', () => { assert.match(archiveRequests[0]?.bodySha256 ?? '', /^[a-f0-9]{64}$/); assert.equal(archiveRequests[0]?.toolCallId, 'tool-1'); const secondPrompt = JSON.stringify(rewritten.messages); - assert.match(secondPrompt, /maka\.active_archived_tool_result/); + assert.match(secondPrompt, /maka\.archived_tool_result/); assert.match(secondPrompt, /artifact-tool-1/); assert.equal(secondPrompt.includes('maka://archive/'), true); assert.match(secondPrompt, /ArchiveRead/); @@ -119,83 +124,37 @@ describe('active current-turn tool-result pruning', () => { assert.equal(secondPrompt.includes(largeBody), false); }); - test('archive failure keeps the original tool result', async () => { - const messages = [largeToolMessage('Read', 'tool-1', 'KEEP_ME'.repeat(20))]; - const rewritten = await rewriteActiveToolResultsInMessages({ - messages, - policy: { enabled: true, maxCurrentResultEstimatedTokens: 1 }, - stepNumber: 1, - turnId: 'turn-1', - charsPerToken: 1, - archiveToolResult: () => { + // Every way the archive can fail to yield one usable artifact id is the same + // fact to this code: nothing durable was written, so nothing may be replaced. + for (const [name, archiveToolResult] of [ + [ + 'throws', + () => { throw new Error('archive unavailable'); }, - }); - - assert.equal(rewritten.rewritten, 0); - assert.equal(rewritten.archiveFailures, 1); - assert.deepEqual(rewritten.messages, messages); - assert.match(JSON.stringify(rewritten.messages), /KEEP_ME/); - assert.doesNotMatch(JSON.stringify(rewritten.messages), /maka\.active_archived_tool_result/); - }); - - test('archiveRequired false still keeps original when no archive artifact is written', async () => { - const messages = [largeToolMessage('Read', 'tool-1', 'KEEP_ME'.repeat(20))]; - const rewritten = await rewriteActiveToolResultsInMessages({ - messages, - policy: { - enabled: true, - maxCurrentResultEstimatedTokens: 1, - archiveRequired: false, - } as never, - stepNumber: 1, - turnId: 'turn-1', - charsPerToken: 1, - archiveToolResult: () => undefined, - }); - - assert.equal(rewritten.rewritten, 0); - assert.equal(rewritten.archiveFailures, 1); - assert.deepEqual(rewritten.messages, messages); - assert.match(JSON.stringify(rewritten.messages), /KEEP_ME/); - assert.doesNotMatch(JSON.stringify(rewritten.messages), /maka\.active_archived_tool_result/); - }); - - test('empty archive artifact id keeps the original tool result', async () => { - const messages = [largeToolMessage('Read', 'tool-1', 'KEEP_ME'.repeat(20))]; - const rewritten = await rewriteActiveToolResultsInMessages({ - messages, - policy: { enabled: true, maxCurrentResultEstimatedTokens: 1 }, - stepNumber: 1, - turnId: 'turn-1', - charsPerToken: 1, - archiveToolResult: () => ({ artifactId: '' }), - }); - - assert.equal(rewritten.rewritten, 0); - assert.equal(rewritten.archiveFailures, 1); - assert.deepEqual(rewritten.messages, messages); - assert.match(JSON.stringify(rewritten.messages), /KEEP_ME/); - assert.doesNotMatch(JSON.stringify(rewritten.messages), /maka\.active_archived_tool_result/); - }); + ], + ['writes nothing', () => undefined], + ['returns an empty artifact id', () => ({ artifactId: '' })], + ['returns a blank artifact id', () => ({ artifactId: ' ' })], + ] as const) { + test(`keeps the original tool result when the archive ${name}`, async () => { + const messages = [largeToolMessage('Read', 'tool-1', 'KEEP_ME'.repeat(20))]; + const rewritten = await rewriteActiveToolResultsInMessages({ + messages, + policy: { enabled: true, maxCurrentResultEstimatedTokens: 1 }, + stepNumber: 1, + turnId: 'turn-1', + charsPerToken: 1, + archiveToolResult, + }); - test('blank archive artifact id keeps the original tool result', async () => { - const messages = [largeToolMessage('Read', 'tool-1', 'KEEP_ME'.repeat(20))]; - const rewritten = await rewriteActiveToolResultsInMessages({ - messages, - policy: { enabled: true, maxCurrentResultEstimatedTokens: 1 }, - stepNumber: 1, - turnId: 'turn-1', - charsPerToken: 1, - archiveToolResult: () => ({ artifactId: ' ' }), + assert.equal(rewritten.rewritten, 0); + assert.equal(rewritten.archiveFailures, 1); + assert.deepEqual(rewritten.messages, messages); + assert.match(JSON.stringify(rewritten.messages), /KEEP_ME/); + assert.doesNotMatch(JSON.stringify(rewritten.messages), /maka\.archived_tool_result/); }); - - assert.equal(rewritten.rewritten, 0); - assert.equal(rewritten.archiveFailures, 1); - assert.deepEqual(rewritten.messages, messages); - assert.match(JSON.stringify(rewritten.messages), /KEEP_ME/); - assert.doesNotMatch(JSON.stringify(rewritten.messages), /maka\.active_archived_tool_result/); - }); + } test('empty-artifact placeholders are not treated as idempotent', async () => { const placeholder = invalidActivePlaceholder(); @@ -756,6 +715,90 @@ describe('active current-turn tool-result pruning', () => { }); }); +/** + * Drive the prune with a durable ledger stand-in. + * + * The prune can no longer rewrite anything it cannot make durable, so every + * case here supplies both halves of that: a projection address for each tool + * call (derived from the message payload, so the size thresholds under test + * measure exactly what they used to) and an archive + transition recorder. + */ +async function rewriteActiveToolResultsInMessages( + input: Omit & { + archiveToolResult?: (candidate: { + sessionId: string; + runtimeEventId: string; + turnId: string; + toolCallId: string; + toolName: string; + serializedResult: string; + bodySha256: string; + }) => { artifactId: string } | void | Promise<{ artifactId: string } | void>; + recordTransition?: (transition: ModelProjectionTransition) => Promise; + }, +): Promise { + const { archiveToolResult, recordTransition, ...rest } = input; + let clock = 1000; + return rewriteActiveToolResultsInMessagesNarrow({ + ...rest, + resolveProjection: (toolCallId) => resolveTestProjection(input.messages, toolCallId), + transitions: { + sessionId: 'session-1', + archiveToolResult: (candidate) => + archiveToolResult + ? archiveToolResult(candidate) + : { artifactId: `artifact-${candidate.toolCallId}` }, + recordTransition: recordTransition ?? (() => Promise.resolve()), + now: () => (clock += 1), + }, + }); +} + +function resolveTestProjection( + messages: readonly ModelMessage[], + toolCallId: string, +): ActiveToolResultProjectionSource | undefined { + for (const message of messages) { + if (message.role !== 'tool' || !Array.isArray(message.content)) continue; + for (const part of message.content as Array>) { + if (part.type !== 'tool-result' || part.toolCallId !== toolCallId) continue; + const output = part.output as { type?: string; value?: unknown } | undefined; + const projection = testProjection(output); + if (!projection) return undefined; + return { + runtimeEventId: `event-${toolCallId}`, + turnId: 'turn-1', + toolName: String(part.toolName), + projection, + }; + } + } + return undefined; +} + +function testProjection( + output: { type?: string; value?: unknown } | undefined, +): DurableToolResultProjection | undefined { + if (!output) return undefined; + if (output.type === 'text' || output.type === 'error-text') { + return { + version: 1, + kind: 'text', + text: String(output.value), + ...(output.type === 'error-text' ? { isError: true as const } : {}), + }; + } + if (output.type === 'json' || output.type === 'error-json') { + return { + version: 1, + kind: 'json', + value: output.value as never, + ...(output.type === 'error-json' ? { isError: true as const } : {}), + }; + } + return undefined; +} + function largeToolMessage(toolName: string, toolCallId: string, body: string): ModelMessage { return { role: 'tool', @@ -804,10 +847,10 @@ function completedCall(toolName: string, toolCallId: string, input: unknown, ste function invalidActivePlaceholder(): Record { return { - kind: 'maka.active_archived_tool_result', + kind: 'maka.archived_tool_result', rewriteVersion: 1, artifactId: '', - turnId: 'turn-1', + runtimeEventId: 'event-tool-old', toolCallId: 'tool-old', toolName: 'Read', bodySha256: 'a'.repeat(64), diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 2ba463e458..4804445aa7 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -18,6 +18,7 @@ */ import assert from 'node:assert/strict'; +import type { ModelProjectionTransition } from '@maka/core/model-projection-transition'; import { Buffer } from 'node:buffer'; import { createHash } from 'node:crypto'; import { join, resolve } from 'node:path'; @@ -4454,6 +4455,7 @@ describe('AiSdkBackend model history', () => { bodySha256: string; }> = []; const oldResult = { body: 'x'.repeat(500) }; + const transitions: ModelProjectionTransition[] = []; const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), @@ -4484,6 +4486,14 @@ describe('AiSdkBackend model history', () => { return { artifactId: `artifact-${event.runtimeEventId}` }; }, }), + loadModelProjectionTransitions: async () => ({ + transitions: [...transitions], + unreadableTargets: new Set(), + unscopedUnreadable: 0, + }), + recordModelProjectionTransition: async (transition) => { + transitions.push(transition); + }, }); await drain( @@ -4533,119 +4543,6 @@ describe('AiSdkBackend model history', () => { assert.equal(prompt.includes(oldResult.body), false); }); - test('preserves existing archive refs while adding newly archived refs', async () => { - const model = completionModel(); - const existingResult = { body: 'EXISTING_ARCHIVE_REF_PAYLOAD'.repeat(20) }; - const newResult = { body: 'NEW_ARCHIVE_REF_PAYLOAD'.repeat(20) }; - const existingSerialized = JSON.stringify(existingResult); - const backend = createTestAiSdkBackend({ - sessionId: 'session-1', - header: header(), - appendMessage: async () => {}, - connection: connection(), - apiKey: 'sk-test', - modelId: 'mock-model-id', - modelFactory: () => model, - tools: [], - newId: idGenerator(), - now: monotonicClock(), - contextBudget: { - name: 'existing-archive-ref-test', - staleToolResultPrune: { - enabled: true, - maxResultEstimatedTokens: 1, - minRecentTurnsFull: 0, - archiveRefs: [ - { - runtimeEventId: 'rt-result', - toolCallId: 'tool-1', - toolName: 'Read', - artifactId: 'artifact-existing-rt-result', - bodySha256: sha256(existingSerialized), - originalEstimatedTokens: existingSerialized.length, - originalBytes: utf8Bytes(existingSerialized), - rewriteVersion: ARCHIVED_TOOL_RESULT_REWRITE_VERSION, - reason: 'stale_tool_result_pruned_before_compact', - }, - ], - }, - charsPerToken: 1, - }, - toolResultArchive: testToolResultArchive({ - archiveToolResult: async (event) => - event.runtimeEventId === 'rt-new-result' - ? { artifactId: 'artifact-new-rt-result' } - : undefined, - }), - }); - - await drain( - backend.send({ - turnId: 'turn-current', - text: 'current user', - context: [], - runtimeContext: [ - runtimeEvent({ - id: 'rt-call', - turnId: 'turn-prev', - role: 'model', - author: 'agent', - content: { - kind: 'function_call', - id: 'tool-1', - name: 'Read', - args: { path: 'package.json' }, - }, - }), - runtimeEvent({ - id: 'rt-result', - turnId: 'turn-prev', - role: 'tool', - author: 'tool', - content: { - kind: 'function_response', - id: 'tool-1', - name: 'Read', - result: existingResult, - isError: false, - }, - }), - runtimeEvent({ - id: 'rt-new-call', - turnId: 'turn-new', - role: 'model', - author: 'agent', - content: { - kind: 'function_call', - id: 'tool-2', - name: 'Read', - args: { path: 'new.txt' }, - }, - }), - runtimeEvent({ - id: 'rt-new-result', - turnId: 'turn-new', - role: 'tool', - author: 'tool', - content: { - kind: 'function_response', - id: 'tool-2', - name: 'Read', - result: newResult, - isError: false, - }, - }), - ], - }), - ); - - const prompt = JSON.stringify(compactPrompt(model)); - assert.match(prompt, /"artifactId":"artifact-existing-rt-result"/); - assert.match(prompt, /"artifactId":"artifact-new-rt-result"/); - assert.equal(prompt.includes(existingResult.body), false); - assert.equal(prompt.includes(newResult.body), false); - }); - test('manual compactHistory writes a V2 checkpoint without the legacy artifact writer', async () => { const recorded: HistoryCompactCheckpoint[] = []; let memoryDispatches = 0; @@ -8704,6 +8601,7 @@ describe('AiSdkBackend usage telemetry', () => { const messages: unknown[] = []; const events: SessionEvent[] = []; const largeBody = 'SECRET_PAYLOAD_SHOULD_BE_ARCHIVED'.repeat(200); + const archivedToolCallIds: string[] = []; let streamCalls = 0; const prompts: unknown[] = []; const model = new MockLanguageModelV4({ @@ -8747,17 +8645,35 @@ describe('AiSdkBackend usage telemetry', () => { }, }, ] - : [ - { type: 'stream-start', warnings: [] }, - { - type: 'finish', - finishReason: { unified: 'stop', raw: 'stop' }, - usage: { - inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, - outputTokens: { total: 1, text: 1, reasoning: 0 }, + : streamCalls === 3 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'tool-3', + toolName: 'Bash', + input: JSON.stringify({ cmd: 'again' }), }, - }, - ]; + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ]; return { stream: simulateReadableStream({ chunks, initialDelayInMs: null, chunkDelayInMs: null }), }; @@ -8791,7 +8707,10 @@ describe('AiSdkBackend usage telemetry', () => { activeToolResultPrune: { enabled: true, maxCurrentResultEstimatedTokens: 1 }, }, toolResultArchive: testToolResultArchive({ - archiveToolResult: async () => ({ artifactId: 'artifact-tool-1' }), + archiveToolResult: async (candidate) => { + archivedToolCallIds.push(candidate.toolCallId); + return { artifactId: `artifact-${candidate.toolCallId}` }; + }, }), loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, newId: idGenerator(), @@ -8811,7 +8730,7 @@ describe('AiSdkBackend usage telemetry', () => { contextBudget?: Record; }) | undefined; - assert.equal(streamCalls, 3); + assert.equal(streamCalls, 4); const secondPrompt = JSON.stringify(prompts[1]); assert.match(secondPrompt, /SECRET_PAYLOAD_SHOULD_BE_ARCHIVED/); assert.doesNotMatch(secondPrompt, /maka\.active_archived_tool_result/); @@ -8819,8 +8738,17 @@ describe('AiSdkBackend usage telemetry', () => { assert.doesNotMatch(thirdPrompt, /SECRET_PAYLOAD_SHOULD_BE_ARCHIVED/); assert.match(thirdPrompt, /artifact-tool-1/); assert.match(thirdPrompt, /NEWEST_RESULT_STAYS_VISIBLE/); + // Every later step rebuilds its prompt from the durable Turn ledger. The + // archive is durable, so the rebuild must fold it: a step that measured the + // raw body again would both resurrect it and archive it a second time. + const fourthPrompt = JSON.stringify(prompts[3]); + assert.doesNotMatch(fourthPrompt, /SECRET_PAYLOAD_SHOULD_BE_ARCHIVED/); + assert.match(fourthPrompt, /artifact-tool-1/); + // Each result is archived once, no matter how many later steps rebuild the + // Turn: the ledger, not a per-run memory, is what says it already happened. + assert.deepEqual(archivedToolCallIds, ['tool-1', 'tool-2']); for (const contextBudget of [usageMessage?.contextBudget, usageEvent?.contextBudget]) { - assert.equal(contextBudget?.activePrunedToolResults, 1); + assert.equal(contextBudget?.activePrunedToolResults, 2); assert.equal(contextBudget?.activeArchiveFailures, undefined); assert.ok(((contextBudget?.activeEstimatedTokensSaved as number | undefined) ?? 0) > 0); } diff --git a/packages/runtime/src/__tests__/context-budget.test.ts b/packages/runtime/src/__tests__/context-budget.test.ts index 26ada66d1b..d5ad6e5b8c 100644 --- a/packages/runtime/src/__tests__/context-budget.test.ts +++ b/packages/runtime/src/__tests__/context-budget.test.ts @@ -67,24 +67,6 @@ test('checkpoint replay uses the canonical ledger before stale tool results are const result = applyRuntimeEventContextBudget([...coveredEvents, tail], { charsPerToken: 1, - staleToolResultPrune: { - enabled: true, - maxResultEstimatedTokens: 1, - minRecentTurnsFull: 0, - archiveRefs: [ - { - runtimeEventId: 'result', - toolCallId: 'tool-call', - toolName: 'Bash', - artifactId: 'artifact-1', - bodySha256: createHash('sha256').update(serializedPayload).digest('hex'), - originalEstimatedTokens: serializedPayload.length, - originalBytes: Buffer.byteLength(serializedPayload, 'utf8'), - rewriteVersion: 1, - reason: 'stale_tool_result_pruned_before_compact', - }, - ], - }, historyCompact: { enabled: true, checkpoint }, }); diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts index ba6c5f8103..36bed08f12 100644 --- a/packages/runtime/src/__tests__/conversation-copy.test.ts +++ b/packages/runtime/src/__tests__/conversation-copy.test.ts @@ -28,6 +28,12 @@ import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; import type { StoredMessage } from '@maka/core/session'; import { decodeCanonicalToolResultContent } from '@maka/core/tool-result-record-schema'; import { decodeModelCallAttempt } from '@maka/core/model-call-attempt'; +import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection'; +import { + buildModelProjectionTransition, + MODEL_PROJECTION_TRANSITION_EVENT_TYPE, + type ModelProjectionTransition, +} from '@maka/core/model-projection-transition'; import { isSessionInlineRun } from '@maka/core/agent-run'; import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; @@ -52,6 +58,21 @@ import { isHistoryCompactContentEvent } from '../history-compaction.js'; import { RuntimeReadModel, type RuntimeReadModelSessionView } from '../runtime-read-model.js'; import { buildToolOperationId } from '../runtime-commit-sink.js'; import { buildToolResultArchiveResourceRef } from '../tool-result-archive-resource.js'; +import { sha256 } from '../context-budget-helpers.js'; +import { + baseToolResultProjection, + loadModelProjectionTransitionsFromRunLedger, + reduceEffectiveModelProjections, +} from '../model-projection-transition-ledger.js'; +import { + archivedToolResultProjection, + collectReachableArchiveArtifactIds, + serializedToolResultProjection, +} from '../tool-result-archive-transition.js'; +import { + buildArchivedToolResultPlaceholder, + isArchivedToolResultPlaceholder, +} from '../tool-result-archive.js'; test('archived tool-result copy preflight detects conversation-owned references', () => { const serialized = (value: unknown): string => JSON.stringify(value); @@ -2728,6 +2749,551 @@ test('conversation copy rebuilds a resumed child checkpoint over its child run c } }); +const TRANSITION_SECRET_BODY = 'SECRET_ARCHIVED_TOOL_RESULT_BODY'; + +function sourceProjectionTransition(input: { + event: RuntimeEvent; + sourceProjection: DurableToolResultProjection; + artifactId: string; + createdAt: number; + previousTransitionId?: string; +}): ModelProjectionTransition { + const serialized = serializedToolResultProjection(input.sourceProjection); + const placeholder = buildArchivedToolResultPlaceholder({ + artifactId: input.artifactId, + runtimeEventId: input.event.id, + toolCallId: 'tool-1', + toolName: 'Read', + bodySha256: sha256(serialized), + originalEstimatedTokens: serialized.length, + originalBytes: serialized.length, + reason: 'stale_tool_result_pruned_before_compact', + }); + return buildModelProjectionTransition({ + sessionId: 'session-source', + target: { + runtimeEventId: input.event.id, + part: 'tool_result', + toolCallId: 'tool-1', + toolName: 'Read', + }, + sourceProjection: input.sourceProjection, + replacement: archivedToolResultProjection(placeholder), + ...(input.previousTransitionId ? { previousTransitionId: input.previousTransitionId } : {}), + now: input.createdAt, + }); +} + +test('conversation copy rebuilds projection transitions against the copied events', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-conversation-transition-copy-')); + try { + const runStore = createSqliteAgentRunStore(root); + const runtimeEventStore = createWorkspaceRuntimeStore(root); + await runStore.createRun( + agentRunHeader({ + runId: 'run-source', + invocationId: 'invocation-source', + turnId: 'turn-1', + cwd: root, + }), + ); + const resultEvent = runtimeEvent({ + id: 'event-result', + ts: 2, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-1', + name: 'Read', + result: { kind: 'text', text: TRANSITION_SECRET_BODY }, + }, + }); + for (const event of [ + runtimeEvent({ + id: 'event-user', + role: 'user', + author: 'user', + content: { kind: 'text', text: 'copy this turn' }, + }), + runtimeEvent({ + id: 'event-call', + ts: 1.5, + role: 'model', + author: 'agent', + content: { kind: 'function_call', id: 'tool-1', name: 'Read', args: { path: 'notes.txt' } }, + }), + resultEvent, + runtimeEvent({ id: 'event-terminal', ts: 3, status: 'completed' }), + ]) { + await runtimeEventStore.appendRuntimeEvent('session-source', 'run-source', event); + } + // Two chained transitions on one target: the copy has to remap the target, + // both archives and the lineage link, and re-derive each source digest + // against what the previous rebuilt transition left behind. + const first = sourceProjectionTransition({ + event: resultEvent, + sourceProjection: baseToolResultProjection(resultEvent)!, + artifactId: 'artifact-source-1', + createdAt: 101, + }); + const second = sourceProjectionTransition({ + event: resultEvent, + sourceProjection: first.replacement, + artifactId: 'artifact-source-2', + createdAt: 102, + previousTransitionId: first.transitionId, + }); + for (const transition of [first, second]) { + await runStore.appendEvent('session-source', 'run-source', { + type: MODEL_PROJECTION_TRANSITION_EVENT_TYPE, + id: transition.transitionId, + runId: 'run-source', + sessionId: 'session-source', + turnId: 'turn-1', + ts: transition.createdAt, + data: { + runtimeEventId: transition.target.runtimeEventId, + part: transition.target.part, + transition, + }, + }); + } + await runStore.appendEvent('session-source', 'run-source', { + type: 'run_completed', + id: 'completed-source', + runId: 'run-source', + sessionId: 'session-source', + turnId: 'turn-1', + ts: 4, + }); + const source = await new RuntimeReadModel({ + runStore, + runtimeEventStore, + }).getSessionView('session-source'); + + await cloneConversationRuntimeLedger({ + plan: await prepareTestCopyPlan(source, source.messages, runStore, runtimeEventStore), + copiedMessages: source.messages, + referenceMap: { + mode: 'exact', + linkedChildren: { mode: 'reject' }, + sourceSessionId: 'session-source', + targetSessionId: 'session-target', + artifactIds: new Map([ + ['artifact-source-1', 'artifact-target-1'], + ['artifact-source-2', 'artifact-target-2'], + ]), + relativePaths: new Map(), + }, + runStore, + runtimeEventStore, + newId: () => crypto.randomUUID(), + }); + + const [targetRun] = await runStore.listSessionRuns('session-target'); + assert.ok(targetRun); + const targetEvents = await runtimeEventStore.readRuntimeEvents( + 'session-target', + targetRun.runId, + ); + const targetResult = targetEvents.find((event) => event.content?.kind === 'function_response'); + assert.ok(targetResult); + assert.notEqual(targetResult.id, 'event-result'); + const copiedTransitions = await loadModelProjectionTransitionsFromRunLedger( + runStore, + 'session-target', + ); + assert.equal(copiedTransitions.transitions.length, 2); + const copiedFirst = copiedTransitions.transitions.find( + (transition) => transition.previousTransitionId === undefined, + ); + assert.ok(copiedFirst); + const copiedSecond = copiedTransitions.transitions.find( + (transition) => transition.previousTransitionId === copiedFirst.transitionId, + ); + assert.ok(copiedSecond); + for (const transition of copiedTransitions.transitions) { + assert.equal(transition.sessionId, 'session-target'); + assert.equal(transition.target.runtimeEventId, targetResult.id); + } + // Lineage is preserved through the remapped ids, never through the source's. + assert.notEqual(copiedFirst.transitionId, first.transitionId); + assert.doesNotMatch( + JSON.stringify(copiedTransitions.transitions), + /artifact-source|event-result/, + ); + + // The copied ledger still carries the raw body — it is append-only — but the + // copied transitions still reduce it away, which is the only property that + // makes a copy of an archived Session safe. + assert.match(JSON.stringify(targetEvents), /SECRET_ARCHIVED_TOOL_RESULT_BODY/); + const reduced = reduceEffectiveModelProjections(targetEvents, copiedTransitions.transitions); + assert.equal(reduced.applied.length, 2); + assert.equal(reduced.rejected.length, 0); + assert.doesNotMatch(JSON.stringify(reduced.events), /SECRET_ARCHIVED_TOOL_RESULT_BODY/); + const effective = reduced.events.find((event) => event.content?.kind === 'function_response'); + assert.ok(effective?.content?.kind === 'function_response'); + assert.ok(isArchivedToolResultPlaceholder(effective.content.result)); + assert.equal(effective.content.result.artifactId, 'artifact-target-2'); + assert.equal(effective.content.result.runtimeEventId, targetResult.id); + // Only the surviving placeholder's archive is reachable; the one it + // superseded is not, and cleanup may reclaim it. + assert.deepEqual( + [...collectReachableArchiveArtifactIds(reduced.events)], + ['artifact-target-2'], + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('conversation copy carries a transition recorded by a later, uncopied run', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-conversation-transition-run-copy-')); + try { + const runStore = createSqliteAgentRunStore(root); + const runtimeEventStore = createWorkspaceRuntimeStore(root); + for (const [runId, turnId] of [ + ['run-first', 'turn-1'], + ['run-second', 'turn-2'], + ]) { + await runStore.createRun( + agentRunHeader({ + runId, + invocationId: `invocation-${runId}`, + turnId, + cwd: root, + }), + ); + } + const resultEvent = runtimeEvent({ + id: 'event-result', + runId: 'run-first', + invocationId: 'invocation-run-first', + ts: 2, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-1', + name: 'Read', + result: { kind: 'text', text: TRANSITION_SECRET_BODY }, + }, + }); + for (const event of [ + runtimeEvent({ + id: 'event-user', + runId: 'run-first', + invocationId: 'invocation-run-first', + role: 'user', + author: 'user', + content: { kind: 'text', text: 'first turn' }, + }), + runtimeEvent({ + id: 'event-call', + runId: 'run-first', + invocationId: 'invocation-run-first', + ts: 1.5, + role: 'model', + author: 'agent', + content: { kind: 'function_call', id: 'tool-1', name: 'Read', args: { path: 'notes.txt' } }, + }), + resultEvent, + runtimeEvent({ + id: 'event-terminal', + runId: 'run-first', + invocationId: 'invocation-run-first', + ts: 3, + status: 'completed', + }), + ]) { + await runtimeEventStore.appendRuntimeEvent('session-source', 'run-first', event); + } + for (const event of [ + runtimeEvent({ + id: 'event-user-2', + runId: 'run-second', + invocationId: 'invocation-run-second', + turnId: 'turn-2', + ts: 4, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'second turn' }, + }), + runtimeEvent({ + id: 'event-terminal-2', + runId: 'run-second', + invocationId: 'invocation-run-second', + turnId: 'turn-2', + ts: 5, + status: 'completed', + }), + ]) { + await runtimeEventStore.appendRuntimeEvent('session-source', 'run-second', event); + } + // The stale prune runs during Turn 2 and archives a Turn 1 result, so the + // record lives in a run that a copy of Turn 1 alone never visits. + const transition = sourceProjectionTransition({ + event: resultEvent, + sourceProjection: baseToolResultProjection(resultEvent)!, + artifactId: 'artifact-source-1', + createdAt: 401, + }); + await runStore.appendEvent('session-source', 'run-second', { + type: MODEL_PROJECTION_TRANSITION_EVENT_TYPE, + id: transition.transitionId, + runId: 'run-second', + sessionId: 'session-source', + turnId: 'turn-2', + ts: transition.createdAt, + data: { + runtimeEventId: transition.target.runtimeEventId, + part: transition.target.part, + transition, + }, + }); + for (const [runId, turnId, id] of [ + ['run-first', 'turn-1', 'completed-first'], + ['run-second', 'turn-2', 'completed-second'], + ]) { + await runStore.appendEvent('session-source', runId, { + type: 'run_completed', + id, + runId, + sessionId: 'session-source', + turnId, + ts: 6, + }); + } + const source = await new RuntimeReadModel({ + runStore, + runtimeEventStore, + }).getSessionView('session-source'); + const firstTurnMessages = source.messages.filter( + (message) => 'turnId' in message && message.turnId === 'turn-1', + ); + + await cloneConversationRuntimeLedger({ + plan: await prepareTestCopyPlan(source, firstTurnMessages, runStore, runtimeEventStore), + copiedMessages: firstTurnMessages, + referenceMap: { + mode: 'exact', + linkedChildren: { mode: 'reject' }, + sourceSessionId: 'session-source', + targetSessionId: 'session-target', + artifactIds: new Map([['artifact-source-1', 'artifact-target-1']]), + relativePaths: new Map(), + }, + runStore, + runtimeEventStore, + newId: () => crypto.randomUUID(), + }); + + const targetRuns = await runStore.listSessionRuns('session-target'); + assert.equal(targetRuns.length, 1); + const targetEvents = await runtimeEventStore.readRuntimeEvents( + 'session-target', + targetRuns[0]!.runId, + ); + const copied = await loadModelProjectionTransitionsFromRunLedger(runStore, 'session-target'); + assert.equal(copied.transitions.length, 1); + assert.equal( + copied.transitions[0]?.target.runtimeEventId, + targetEvents.find((event) => event.content?.kind === 'function_response')?.id, + ); + // Dropping the record while keeping its target would restore the archived + // body in the copy — the one thing the protocol exists to prevent. + const reduced = reduceEffectiveModelProjections(targetEvents, copied.transitions); + assert.equal(reduced.applied.length, 1); + assert.doesNotMatch(JSON.stringify(reduced.events), /SECRET_ARCHIVED_TOOL_RESULT_BODY/); + assert.deepEqual( + [...collectReachableArchiveArtifactIds(reduced.events)], + ['artifact-target-1'], + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('conversation copy reproduces the source fold rather than re-deciding it', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-conversation-transition-rival-copy-')); + try { + const runStore = createSqliteAgentRunStore(root); + const runtimeEventStore = createWorkspaceRuntimeStore(root); + for (const [runId, turnId] of [ + ['run-first', 'turn-1'], + ['run-second', 'turn-2'], + ]) { + await runStore.createRun( + agentRunHeader({ runId, invocationId: `invocation-${runId}`, turnId, cwd: root }), + ); + } + const resultEvent = runtimeEvent({ + id: 'event-result', + runId: 'run-first', + invocationId: 'invocation-run-first', + ts: 2, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-1', + name: 'Read', + result: { kind: 'text', text: TRANSITION_SECRET_BODY }, + }, + }); + for (const event of [ + runtimeEvent({ + id: 'event-user', + runId: 'run-first', + invocationId: 'invocation-run-first', + role: 'user', + author: 'user', + content: { kind: 'text', text: 'first turn' }, + }), + runtimeEvent({ + id: 'event-call', + runId: 'run-first', + invocationId: 'invocation-run-first', + ts: 1.5, + role: 'model', + author: 'agent', + content: { kind: 'function_call', id: 'tool-1', name: 'Read', args: { path: 'notes.txt' } }, + }), + resultEvent, + runtimeEvent({ + id: 'event-terminal', + runId: 'run-first', + invocationId: 'invocation-run-first', + ts: 3, + status: 'completed', + }), + ]) { + await runtimeEventStore.appendRuntimeEvent('session-source', 'run-first', event); + } + for (const event of [ + runtimeEvent({ + id: 'event-user-2', + runId: 'run-second', + invocationId: 'invocation-run-second', + turnId: 'turn-2', + ts: 4, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'second turn' }, + }), + runtimeEvent({ + id: 'event-terminal-2', + runId: 'run-second', + invocationId: 'invocation-run-second', + turnId: 'turn-2', + ts: 5, + status: 'completed', + }), + ]) { + await runtimeEventStore.appendRuntimeEvent('session-source', 'run-second', event); + } + // Two rival roots against the same source. The source fold accepts exactly + // one of them — by content-derived id, not by which run or which timestamp. + const rivals = ['artifact-source-a', 'artifact-source-b'].map((artifactId, index) => + sourceProjectionTransition({ + event: resultEvent, + sourceProjection: baseToolResultProjection(resultEvent)!, + artifactId, + createdAt: 401 + index, + }), + ); + const [inCopiedRun, inLaterRun] = [...rivals].sort((left, right) => + left.transitionId < right.transitionId ? 1 : -1, + ); + assert.ok(inCopiedRun && inLaterRun); + for (const [transition, runId, turnId] of [ + [inCopiedRun, 'run-first', 'turn-1'], + [inLaterRun, 'run-second', 'turn-2'], + ] as const) { + await runStore.appendEvent('session-source', runId, { + type: MODEL_PROJECTION_TRANSITION_EVENT_TYPE, + id: transition.transitionId, + runId, + sessionId: 'session-source', + turnId, + ts: transition.createdAt, + data: { + runtimeEventId: transition.target.runtimeEventId, + part: transition.target.part, + transition, + }, + }); + } + for (const [runId, turnId, id] of [ + ['run-first', 'turn-1', 'completed-first'], + ['run-second', 'turn-2', 'completed-second'], + ]) { + await runStore.appendEvent('session-source', runId, { + type: 'run_completed', + id, + runId, + sessionId: 'session-source', + turnId, + ts: 6, + }); + } + const source = await new RuntimeReadModel({ + runStore, + runtimeEventStore, + }).getSessionView('session-source'); + const firstTurnMessages = source.messages.filter( + (message) => 'turnId' in message && message.turnId === 'turn-1', + ); + + await cloneConversationRuntimeLedger({ + plan: await prepareTestCopyPlan(source, firstTurnMessages, runStore, runtimeEventStore), + copiedMessages: firstTurnMessages, + referenceMap: { + mode: 'exact', + linkedChildren: { mode: 'reject' }, + sourceSessionId: 'session-source', + targetSessionId: 'session-target', + artifactIds: new Map([ + ['artifact-source-a', 'artifact-target-a'], + ['artifact-source-b', 'artifact-target-b'], + ]), + relativePaths: new Map(), + }, + runStore, + runtimeEventStore, + newId: () => crypto.randomUUID(), + }); + + const [targetRun] = await runStore.listSessionRuns('session-target'); + assert.ok(targetRun); + const targetEvents = await runtimeEventStore.readRuntimeEvents( + 'session-target', + targetRun.runId, + ); + const copied = await loadModelProjectionTransitionsFromRunLedger(runStore, 'session-target'); + // Only the transition the source fold applied is rebuilt. Carrying the + // rejected rival would let the copy re-decide and show a placeholder the + // source never showed. + assert.equal(copied.transitions.length, 1); + const reduced = reduceEffectiveModelProjections(targetEvents, copied.transitions); + assert.equal(reduced.applied.length, 1); + const effective = reduced.events.find((event) => event.content?.kind === 'function_response'); + assert.ok(effective?.content?.kind === 'function_response'); + assert.ok(isArchivedToolResultPlaceholder(effective.content.result)); + const sourceWinner = reduceEffectiveModelProjections([resultEvent], rivals).applied[0]!; + assert.equal( + effective.content.result.artifactId, + sourceWinner.transitionId === inCopiedRun.transitionId + ? 'artifact-target-a' + : 'artifact-target-b', + ); + assert.doesNotMatch(JSON.stringify(reduced.events), /SECRET_ARCHIVED_TOOL_RESULT_BODY/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + function prepareTestCopyPlan( source: RuntimeReadModelSessionView, copiedMessages: readonly StoredMessage[], diff --git a/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts b/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts index 836797f49d..7a92f11878 100644 --- a/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts +++ b/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts @@ -275,6 +275,57 @@ describe('durable Tool Result projection codec', () => { assert.equal(writes, 0); }); + it('retracts already-published artifacts when a later one cannot be published', async () => { + const published: string[] = []; + const retracted: string[] = []; + let nextId = 0; + const projection = await encodeDurableToolResultOutputWithArtifacts( + twoInlineImages(), + 'session-1', + () => { + const relativePath = `artifact-${++nextId}`; + return { + ref: { kind: 'session_file' as const, sessionId: 'session-1', relativePath }, + persist: async () => { + if (relativePath === 'artifact-2') throw new Error('artifact store is unavailable'); + published.push(relativePath); + }, + retract: async () => { + retracted.push(relativePath); + }, + }; + }, + ); + + assert.equal(projection.kind, 'failure'); + assert.deepEqual(published, ['artifact-1']); + assert.deepEqual(retracted, ['artifact-1']); + }); + + it('still refuses the projection when the retraction itself fails', async () => { + let nextId = 0; + const projection = await encodeDurableToolResultOutputWithArtifacts( + twoInlineImages(), + 'session-1', + () => { + const relativePath = `artifact-${++nextId}`; + return { + ref: { kind: 'session_file' as const, sessionId: 'session-1', relativePath }, + persist: async () => { + if (relativePath === 'artifact-2') throw new Error('artifact store is unavailable'); + }, + // Reclamation may be delayed; admitting a partially published + // projection may not happen at all. + retract: async () => { + throw new Error('cleanup is unavailable too'); + }, + }; + }, + ); + + assert.equal(projection.kind, 'failure'); + }); + it('validates default image refs through the same closed schema', () => { const legacyImage = { kind: 'image', @@ -301,6 +352,24 @@ describe('durable Tool Result projection codec', () => { }); }); +function twoInlineImages() { + return { + type: 'content' as const, + value: [ + { + type: 'file' as const, + data: { type: 'data' as const, data: Buffer.from('first').toString('base64') }, + mediaType: 'image/png', + }, + { + type: 'file' as const, + data: { type: 'data' as const, data: Buffer.from('second').toString('base64') }, + mediaType: 'image/png', + }, + ], + }; +} + function artifactPlanner(onPersist: () => void) { let nextId = 0; return () => { diff --git a/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts index 7e454891fb..b715b10c73 100644 --- a/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts +++ b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts @@ -26,6 +26,7 @@ import { type ToolResultArchiveServices, } from '../tool-result-archive-capability.js'; import { ToolRuntime, type ToolRuntimeInput } from '../tool-runtime.js'; +import type { ModelProjectionTransition } from '@maka/core/model-projection-transition'; export const readExternalExecutionBoundary: AiSdkBackendInput['readExecutionBoundary'] = async () => createExternalExecutionBoundary(); @@ -39,8 +40,21 @@ export function createTestAiSdkBackend(input: TestAiSdkBackendInput): AiSdkBacke const { testProjectionArtifacts, ...backendInput } = input; const artifacts = new Map(); let nextArtifactId = 0; + // A whole transition ledger by default, for the same reason the archive + // capability above is whole: a lossy model-history rewrite is only allowed + // when it can be made durable, so a fixture without this seam would silently + // disable pruning rather than exercise it (#4283). + const transitions: ModelProjectionTransition[] = []; return new AiSdkBackend({ readExecutionBoundary: readExternalExecutionBoundary, + loadModelProjectionTransitions: async () => ({ + transitions: [...transitions], + unreadableTargets: new Set(), + unscopedUnreadable: 0, + }), + recordModelProjectionTransition: async (transition) => { + transitions.push(transition); + }, providerStateIdentity: `sha256:${'1'.repeat(64)}`, ...backendInput, ...(testProjectionArtifacts diff --git a/packages/runtime/src/__tests__/model-projection-transition-ledger.test.ts b/packages/runtime/src/__tests__/model-projection-transition-ledger.test.ts new file mode 100644 index 0000000000..c7fb260528 --- /dev/null +++ b/packages/runtime/src/__tests__/model-projection-transition-ledger.test.ts @@ -0,0 +1,440 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import type { AgentRunEvent, AgentRunHeader } from '@maka/core/agent-run'; +import { + buildModelProjectionTransition, + durableToolResultProjectionDigest, + MODEL_PROJECTION_TRANSITION_EVENT_TYPE, + type ModelProjectionTransition, +} from '@maka/core/model-projection-transition'; +import { DURABLE_TOOL_RESULT_PROJECTION_FAILURE } from '@maka/core/durable-tool-result-projection'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; + +import { + baseToolResultProjection, + loadModelProjectionTransitionsFromRunLedger, + reduceEffectiveModelProjections, +} from '../model-projection-transition-ledger.js'; +import { + archiveToolResultAsTransition, + archivedToolResultProjection, + collectReachableArchiveArtifactIds, + collectStaleToolResultArchiveCandidates, + serializedToolResultProjection, +} from '../tool-result-archive-transition.js'; +import { + buildArchivedToolResultPlaceholder, + isArchivedToolResultPlaceholder, +} from '../tool-result-archive.js'; +import { sha256 } from '../context-budget-helpers.js'; + +const SECRET = 'SECRET_TOOL_RESULT_BODY'; + +function toolResultEvent( + id: string, + turnId: string, + result: unknown, + overrides: Partial = {}, +): RuntimeEvent { + return { + id, + invocationId: 'invocation-1', + sessionId: 'session-1', + runId: 'run-1', + turnId, + ts: 1, + partial: false, + role: 'tool', + author: 'tool', + modelVisibility: 'visible', + content: { kind: 'function_response', id: 'tool-1', name: 'Read', result }, + ...overrides, + } as RuntimeEvent; +} + +function archiveTransition( + event: RuntimeEvent, + options: { + artifactId?: string; + previousTransitionId?: string; + sourceProjection?: ReturnType; + } = {}, +): ModelProjectionTransition { + const sourceProjection = options.sourceProjection ?? baseToolResultProjection(event)!; + const serialized = serializedToolResultProjection(sourceProjection); + const artifactId = options.artifactId ?? `artifact-${event.id}`; + const placeholder = buildArchivedToolResultPlaceholder({ + artifactId, + runtimeEventId: event.id, + toolCallId: 'tool-1', + toolName: 'Read', + bodySha256: sha256(serialized), + originalEstimatedTokens: serialized.length, + originalBytes: serialized.length, + reason: 'stale_tool_result_pruned_before_compact', + }); + return buildModelProjectionTransition({ + sessionId: 'session-1', + target: { + runtimeEventId: event.id, + part: 'tool_result', + toolCallId: 'tool-1', + toolName: 'Read', + }, + sourceProjection, + replacement: archivedToolResultProjection(placeholder), + ...(options.previousTransitionId ? { previousTransitionId: options.previousTransitionId } : {}), + now: 100, + }); +} + +function serializedEffective(events: readonly RuntimeEvent[]): string { + return JSON.stringify(events); +} + +describe('effective model projection reduction', () => { + test('replaces the projection and the legacy result together', () => { + const event = toolResultEvent('rt-1', 'turn-1', { body: SECRET }); + const transition = archiveTransition(event); + + const reduced = reduceEffectiveModelProjections([event], [transition]); + + assert.equal(reduced.applied.length, 1); + assert.equal(reduced.rejected.length, 0); + const [effective] = reduced.events; + assert.ok(effective?.content?.kind === 'function_response'); + assert.ok(isArchivedToolResultPlaceholder(effective.content.result)); + assert.deepEqual(effective.content.modelProjection, transition.replacement); + assert.equal(serializedEffective(reduced.events).includes(SECRET), false); + }); + + test('refuses a stale concurrent writer instead of restoring its source', () => { + const event = toolResultEvent('rt-1', 'turn-1', { body: SECRET }); + const first = archiveTransition(event, { artifactId: 'artifact-a' }); + // A second Turn that never saw `first` decides against the same source. + const stale = archiveTransition(event, { artifactId: 'artifact-b' }); + // Neither wrote later than the other in any sense a reader can trust, so the + // winner is the smaller content-derived id — the same one on every reader. + const [winner, loser] = + first.transitionId < stale.transitionId ? [first, stale] : [stale, first]; + + for (const arrival of [ + [first, stale], + [stale, first], + ]) { + const reduced = reduceEffectiveModelProjections([event], arrival); + assert.deepEqual( + reduced.applied.map((transition) => transition.transitionId), + [winner.transitionId], + ); + assert.deepEqual( + reduced.rejected.map((transition) => transition.transitionId), + [loser.transitionId], + ); + assert.equal(serializedEffective(reduced.events).includes(SECRET), false); + // The refused writer's archive is named by nothing the model can see. + assert.deepEqual( + [...collectReachableArchiveArtifactIds(reduced.events)], + [winner === first ? 'artifact-a' : 'artifact-b'], + ); + } + }); + + test('orders concurrent Turns deterministically regardless of ledger arrival order', () => { + const event = toolResultEvent('rt-1', 'turn-1', { body: SECRET }); + const first = archiveTransition(event, { artifactId: 'artifact-a' }); + const second = archiveTransition(event, { + artifactId: 'artifact-b', + previousTransitionId: first.transitionId, + sourceProjection: first.replacement, + }); + + const inOrder = reduceEffectiveModelProjections([event], [first, second]); + const reversed = reduceEffectiveModelProjections([event], [second, first]); + + assert.deepEqual(inOrder.events, reversed.events); + assert.deepEqual( + inOrder.applied.map((transition) => transition.transitionId), + [first.transitionId, second.transitionId], + ); + assert.deepEqual([...collectReachableArchiveArtifactIds(inOrder.events)], ['artifact-b']); + }); + + test('withholds a target whose record this build cannot read', () => { + const event = toolResultEvent('rt-1', 'turn-1', { body: SECRET }); + // A record written by a newer version may be the one that removed this + // content. Replaying the raw body would undo whatever it decided. + const reduced = reduceEffectiveModelProjections([event], [], new Set(['rt-1::tool_result'])); + + assert.equal(serializedEffective(reduced.events).includes(SECRET), false); + const [effective] = reduced.events; + assert.ok(effective?.content?.kind === 'function_response'); + assert.deepEqual(effective.content.modelProjection, DURABLE_TOOL_RESULT_PROJECTION_FAILURE); + }); + + test('refuses the decodable records of an unreadable target too', () => { + const event = toolResultEvent('rt-1', 'turn-1', { body: SECRET }); + const transition = archiveTransition(event); + // The unreadable record's place in the chain is unknown, so no record for + // this target can be trusted to describe the current projection. + const reduced = reduceEffectiveModelProjections( + [event], + [transition], + new Set(['rt-1::tool_result']), + ); + + assert.equal(reduced.applied.length, 0); + assert.deepEqual( + reduced.rejected.map((entry) => entry.transitionId), + [transition.transitionId], + ); + assert.equal(serializedEffective(reduced.events).includes(SECRET), false); + assert.equal(collectReachableArchiveArtifactIds(reduced.events).size, 0); + }); + + test('leaves provider-native opaque results alone', () => { + const event = toolResultEvent('rt-1', 'turn-1', undefined, { + content: { + kind: 'function_response', + id: 'tool-1', + name: 'WebSearch', + result: undefined, + providerExecuted: true, + providerOutput: { opaque: SECRET }, + }, + } as Partial); + const transition = archiveTransition(toolResultEvent('rt-1', 'turn-1', { body: SECRET })); + + const reduced = reduceEffectiveModelProjections([event], [transition]); + + assert.deepEqual(reduced.events[0], event); + assert.equal(reduced.applied.length, 0); + assert.equal(reduced.rejected.length, 1); + assert.equal(collectReachableArchiveArtifactIds(reduced.events).size, 0); + }); + + test('rolling compaction cannot re-measure or re-archive replaced content', () => { + const event = toolResultEvent('rt-1', 'turn-1', { body: SECRET.repeat(200) }); + const transition = archiveTransition(event); + const reduced = reduceEffectiveModelProjections( + [event, toolResultEvent('rt-2', 'turn-2', { body: 'tail' })], + [transition], + ); + + const rawCandidates = collectStaleToolResultArchiveCandidates( + [event, toolResultEvent('rt-2', 'turn-2', { body: 'tail' })], + { enabled: true, maxResultEstimatedTokens: 1, minRecentTurnsFull: 1 }, + 1, + ); + const effectiveCandidates = collectStaleToolResultArchiveCandidates( + reduced.events, + { enabled: true, maxResultEstimatedTokens: 4096, minRecentTurnsFull: 1 }, + 1, + ); + + assert.equal(rawCandidates.length, 1); + assert.deepEqual(effectiveCandidates, []); + }); +}); + +describe('durable transition writer', () => { + const event = toolResultEvent('rt-1', 'turn-1', { body: SECRET.repeat(20) }); + + function request() { + const sourceProjection = baseToolResultProjection(event)!; + const serializedResult = serializedToolResultProjection(sourceProjection); + return { + runtimeEventId: event.id, + turnId: 'turn-1', + toolCallId: 'tool-1', + toolName: 'Read', + sourceProjection, + serializedResult, + originalBytes: serializedResult.length, + originalEstimatedTokens: serializedResult.length, + reason: 'stale_tool_result_pruned_before_compact' as const, + }; + } + + test('commits archive then transition, and the fold applies the result', async () => { + const recorded: ModelProjectionTransition[] = []; + const outcome = await archiveToolResultAsTransition( + { + sessionId: 'session-1', + archiveToolResult: () => ({ artifactId: 'artifact-1' }), + recordTransition: async (transition) => { + recorded.push(transition); + }, + now: () => 42, + }, + request(), + ); + + assert.ok(outcome); + assert.equal(recorded.length, 1); + assert.equal( + recorded[0]?.sourceProjectionDigest, + durableToolResultProjectionDigest(baseToolResultProjection(event)!), + ); + const reduced = reduceEffectiveModelProjections([event], recorded); + assert.equal(serializedEffective(reduced.events).includes(SECRET), false); + }); + + test('a writer shows the transition the fold accepts, not the one it wrote', async () => { + // Both Turns load the same source and append rival roots. Appending + // successfully does not make either one the fold's answer, so a writer must + // return what the ledger has settled on by the time it looks. + for (const order of [ + ['artifact-a', 'artifact-b'], + ['artifact-b', 'artifact-a'], + ]) { + const ledger: ModelProjectionTransition[] = []; + const services = (artifactId: string) => ({ + sessionId: 'session-1', + archiveToolResult: () => ({ artifactId }), + recordTransition: async (transition: ModelProjectionTransition) => { + ledger.push(transition); + }, + loadTransitions: async () => ({ transitions: [...ledger] }), + now: () => 42, + }); + + await archiveToolResultAsTransition(services(order[0]!), request()); + const second = await archiveToolResultAsTransition(services(order[1]!), request()); + + assert.ok(second); + assert.equal(ledger.length, 2); + const reduced = reduceEffectiveModelProjections([event], ledger); + assert.equal(reduced.applied.length, 1); + const winner = reduced.applied[0]!; + // The later writer sees both records, so it must not show its own when + // the fold prefers the other. + assert.equal(second.transition.transitionId, winner.transitionId); + assert.deepEqual(archivedToolResultProjection(second.placeholder), winner.replacement); + const effective = reduced.events[0]; + assert.ok(effective?.content?.kind === 'function_response'); + assert.ok(isArchivedToolResultPlaceholder(effective.content.result)); + assert.equal(effective.content.result.artifactId, second.placeholder.artifactId); + assert.equal(serializedEffective(reduced.events).includes(SECRET), false); + } + }); + + test('an archive failure leaves the model-visible content untouched', async () => { + let recordCalls = 0; + const outcome = await archiveToolResultAsTransition( + { + sessionId: 'session-1', + archiveToolResult: () => { + throw new Error('artifact store is unavailable'); + }, + recordTransition: async () => { + recordCalls += 1; + }, + now: () => 42, + }, + request(), + ); + + assert.equal(outcome, undefined); + assert.equal(recordCalls, 0); + }); + + test('a ledger failure leaves the content untouched and the artifact unreachable', async () => { + const outcome = await archiveToolResultAsTransition( + { + sessionId: 'session-1', + archiveToolResult: () => ({ artifactId: 'artifact-orphan' }), + recordTransition: () => Promise.reject(new Error('ledger is unavailable')), + now: () => 42, + }, + request(), + ); + + assert.equal(outcome, undefined); + const reduced = reduceEffectiveModelProjections([event], []); + assert.equal(collectReachableArchiveArtifactIds(reduced.events).has('artifact-orphan'), false); + assert.ok(serializedEffective(reduced.events).includes(SECRET)); + }); +}); + +describe('transition ledger reads', () => { + test('collects every session transition once and ignores undecodable records', async () => { + const event = toolResultEvent('rt-1', 'turn-1', { body: SECRET }); + const transition = archiveTransition(event); + const ledgerEvent = (id: string, data: Record): AgentRunEvent => ({ + type: MODEL_PROJECTION_TRANSITION_EVENT_TYPE, + id, + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: 1, + data, + }); + const runStore = { + listSessionRuns: async () => + [{ runId: 'run-1' }, { runId: 'run-2' }] as unknown as AgentRunHeader[], + readEvents: async (_sessionId: string, runId: string): Promise => + runId === 'run-1' + ? [ + ledgerEvent(transition.transitionId, { transition }), + ledgerEvent('broken', { + runtimeEventId: 'rt-1', + part: 'tool_result', + transition: { kind: 'nonsense' }, + }), + ledgerEvent('broken-unscoped', { transition: { kind: 'nonsense' } }), + ] + : [ledgerEvent(`${transition.transitionId}-replay`, { transition })], + }; + + const loaded = await loadModelProjectionTransitionsFromRunLedger(runStore, 'session-1'); + + assert.deepEqual( + loaded.transitions.map((entry) => entry.transitionId), + [transition.transitionId], + ); + // A record of the right type this build cannot decode is confined to the + // target its envelope names, not silently treated as "no transition here". + assert.deepEqual([...loaded.unreadableTargets], ['rt-1::tool_result']); + // One that names no target cannot be confined to anything. + assert.equal(loaded.unscopedUnreadable, 1); + }); + + test('legacy retry: an event with no durable projection still folds through one codec', () => { + // A legacy `function_response` carries no `modelProjection`; the + // compatibility codec supplies one, and a transition addresses that. + const legacy = toolResultEvent('rt-legacy', 'turn-1', { body: SECRET }); + assert.equal( + legacy.content?.kind === 'function_response' && legacy.content.modelProjection, + undefined, + ); + const transition = archiveTransition(legacy); + + const first = reduceEffectiveModelProjections([legacy], [transition]); + // The retry re-reads the same raw event and the same ledger. + const retry = reduceEffectiveModelProjections([legacy], [transition]); + + assert.deepEqual(first.events, retry.events); + assert.equal(serializedEffective(retry.events).includes(SECRET), false); + }); +}); diff --git a/packages/runtime/src/active-tool-result-prune.ts b/packages/runtime/src/active-tool-result-prune.ts index 6c5a600632..11a335e41e 100644 --- a/packages/runtime/src/active-tool-result-prune.ts +++ b/packages/runtime/src/active-tool-result-prune.ts @@ -17,16 +17,26 @@ * under the License. */ +/** + * Current-Turn Tool Result pruning (#4283). + * + * The decision is still local to one request: which completed step's result is + * large enough, and superseded enough, to be worth replacing before the next + * provider step. What is no longer local is the RESULT of that decision. Every + * replacement is committed as a durable projection transition first, so the + * live continuation, the next Turn, a restart, a branch and a compaction all + * read the same replaced projection instead of the old per-Turn placeholder map + * that only the current `send()` could see. + * + * A result the durable ledger cannot address — no committed `function_response` + * for the tool call yet, or a provider-native opaque result — is left alone. A + * lossy rewrite the ledger cannot explain is exactly the state this protocol + * exists to make unrepresentable. + */ + import type { JSONValue, ModelMessage } from './model-protocol.js'; +import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection'; -import { - ARCHIVED_TOOL_RESULT_REWRITE_VERSION, - serializeToolResultForArchive, -} from './tool-result-archive.js'; -import { - buildToolResultArchiveResourceRef, - TOOL_RESULT_ARCHIVE_READ_INSTRUCTIONS, -} from './tool-result-archive-resource.js'; import { estimateTokens, finitePositive, @@ -39,11 +49,16 @@ import { type ActiveToolResultObservation, type ActiveToolResultSupersession, } from './active-tool-result-working-set.js'; - -export const ACTIVE_ARCHIVED_TOOL_RESULT_PLACEHOLDER_KIND = 'maka.active_archived_tool_result'; - -export type ActiveArchivedToolResultReason = - 'active_current_turn_tool_result_pruned_before_next_step'; +import { + archiveToolResultAsTransition, + serializedToolResultProjection, + type ToolResultArchiveTransitionServices, +} from './tool-result-archive-transition.js'; +import { + isArchivedToolResultPlaceholder, + serializeToolResultForArchive, + type ArchivedToolResultPlaceholder, +} from './tool-result-archive.js'; export interface ActiveToolResultPrunePolicy { enabled: boolean; @@ -55,46 +70,33 @@ export interface ActiveToolResultPrunePolicy { minStepNumber?: number; } -export interface ActiveToolResultArchiveCandidate { - turnId: string; - toolCallId: string; - toolName: string; - result: unknown; - serializedResult: string; - originalEstimatedTokens: number; - originalBytes: number; - rewriteVersion: typeof ARCHIVED_TOOL_RESULT_REWRITE_VERSION; - reason: ActiveArchivedToolResultReason; - runtimeEventId?: string; -} - -export interface ActiveArchivedToolResultPlaceholder { - kind: typeof ACTIVE_ARCHIVED_TOOL_RESULT_PLACEHOLDER_KIND; - rewriteVersion: typeof ARCHIVED_TOOL_RESULT_REWRITE_VERSION; - artifactId: string; - /** First-class, model-readable resource URI. Optional for persisted v1 compatibility. */ - resourceRef?: string; - /** Explicit recovery action for the provider-visible placeholder. */ - readInstructions?: string; - turnId: string; - toolCallId: string; - toolName: string; - bodySha256: string; - originalEstimatedTokens: number; - originalBytes: number; - reason: ActiveArchivedToolResultReason; - /** Why a newer completed step made this provider-visible result redundant. */ - supersession?: ActiveToolResultSupersession; -} - const DEFAULT_MAX_CURRENT_RESULT_ESTIMATED_TOKENS = 2048; const DEFAULT_MIN_SUPERSEDED_RESULT_ESTIMATED_TOKENS = 256; const DEFAULT_CHARS_PER_TOKEN = 4; -export interface ActiveToolResultPruneArchiveInput extends ActiveToolResultArchiveCandidate { - bodySha256: string; +/** + * The durable address of one in-flight tool result. + * + * The prune walks provider messages, but a transition names a RuntimeEvent, so + * the caller must be able to map a provider tool-call id onto the committed + * response event and the projection currently in effect for it. + */ +export interface ActiveToolResultProjectionSource { + runtimeEventId: string; + turnId: string; + toolName: string; + projection: DurableToolResultProjection; + /** The transition currently in effect for this target, if any. */ + previousTransitionId?: string; } +export type ActiveToolResultProjectionResolver = ( + toolCallId: string, +) => + | ActiveToolResultProjectionSource + | undefined + | PromiseLike; + export interface ActiveToolResultPruneInput { messages: readonly ModelMessage[]; policy: ActiveToolResultPrunePolicy | undefined; @@ -103,10 +105,10 @@ export interface ActiveToolResultPruneInput { charsPerToken?: number; eligibleToolCallIds?: ReadonlySet; completedToolCalls?: readonly ActiveToolResultCall[]; - archiveToolResult?: ( - input: ActiveToolResultPruneArchiveInput, - ) => Promise<{ artifactId: string } | void> | { artifactId: string } | void; - archivedPlaceholders?: Map; + /** Durable address lookup; without it no rewrite may happen. */ + resolveProjection: ActiveToolResultProjectionResolver; + /** Archive + transition writer. */ + transitions: ToolResultArchiveTransitionServices; } export interface ActiveToolResultPruneResult { @@ -189,8 +191,6 @@ export async function rewriteActiveToolResultsInMessages( finitePositive(policy.minSupersededResultEstimatedTokens) ?? DEFAULT_MIN_SUPERSEDED_RESULT_ESTIMATED_TOKENS; const charsPerToken = input.charsPerToken ?? DEFAULT_CHARS_PER_TOKEN; - const archivedPlaceholders = - input.archivedPlaceholders ?? new Map(); const supersessionDecisions = collectSupersessionDecisions(input); let rewritten = 0; @@ -218,14 +218,10 @@ export async function rewriteActiveToolResultsInMessages( const replacement = await rewriteToolResultPart({ part, - policy, - turnId: input.turnId, + input, charsPerToken, maxResultEstimatedTokens, minSupersededResultEstimatedTokens, - eligibleToolCallIds: input.eligibleToolCallIds, - archiveToolResult: input.archiveToolResult, - archivedPlaceholders, supersession: supersessionDecisions.get(part.toolCallId as string), }); @@ -270,28 +266,29 @@ export async function rewriteActiveToolResultsInMessages( async function rewriteToolResultPart(input: { part: ToolResultPartish; - policy: ActiveToolResultPrunePolicy; - turnId: string; + input: ActiveToolResultPruneInput; charsPerToken: number; maxResultEstimatedTokens: number; minSupersededResultEstimatedTokens: number; - eligibleToolCallIds?: ReadonlySet; - archiveToolResult?: ActiveToolResultPruneInput['archiveToolResult']; - archivedPlaceholders: Map; supersession?: ActiveToolResultSupersession; }): Promise { - if (typeof input.part.toolCallId !== 'string' || typeof input.part.toolName !== 'string') { - return { changed: false }; - } - if (input.eligibleToolCallIds && !input.eligibleToolCallIds.has(input.part.toolCallId)) { + const { part } = input; + if (typeof part.toolCallId !== 'string' || typeof part.toolName !== 'string') { return { changed: false }; } + const eligible = input.input.eligibleToolCallIds; + if (eligible && !eligible.has(part.toolCallId)) return { changed: false }; - const payload = extractPayload(input.part); + const payload = extractPayload(part); if (!payload) return { changed: false }; if (isArchivedPayload(payload.value)) return { changed: false }; - const serializedResult = serializeToolResultForArchive(payload.value); + // No durable address, no rewrite: the ledger must be able to explain any + // content the model stops seeing. + const address = await Promise.resolve(input.input.resolveProjection(part.toolCallId)); + if (!address || address.toolName !== part.toolName) return { changed: false }; + const sourceProjection = address.projection; + const serializedResult = serializedToolResultProjection(sourceProjection); const originalEstimatedTokens = estimateTokens(serializedResult.length, input.charsPerToken); if ( input.supersession @@ -301,75 +298,32 @@ async function rewriteToolResultPart(input: { return { changed: false }; } - const originalBytes = utf8ByteLength(serializedResult); - const bodySha256 = sha256(serializedResult); - const cacheKey = `${input.part.toolCallId}:${bodySha256}`; - let placeholder = input.archivedPlaceholders.get(cacheKey); - - if (!placeholder) { - const candidate: ActiveToolResultPruneArchiveInput = { - turnId: input.turnId, - toolCallId: input.part.toolCallId, - toolName: input.part.toolName, - result: payload.value, - serializedResult, - originalEstimatedTokens, - originalBytes, - bodySha256, - rewriteVersion: ARCHIVED_TOOL_RESULT_REWRITE_VERSION, - reason: 'active_current_turn_tool_result_pruned_before_next_step', - }; - let archived: { artifactId: string } | void; - try { - archived = await Promise.resolve(input.archiveToolResult?.(candidate)); - } catch { - archived = undefined; - } - if (!isUsableArtifactId(archived?.artifactId)) { - return { changed: false, archiveFailure: true }; - } - placeholder = { - kind: ACTIVE_ARCHIVED_TOOL_RESULT_PLACEHOLDER_KIND, - rewriteVersion: ARCHIVED_TOOL_RESULT_REWRITE_VERSION, - artifactId: archived.artifactId, - resourceRef: buildToolResultArchiveResourceRef({ - artifactId: archived.artifactId, - bodySha256, - originalBytes, - }), - readInstructions: TOOL_RESULT_ARCHIVE_READ_INSTRUCTIONS, - turnId: input.turnId, - toolCallId: input.part.toolCallId, - toolName: input.part.toolName, - bodySha256, - originalEstimatedTokens, - originalBytes, - reason: 'active_current_turn_tool_result_pruned_before_next_step', - ...(input.supersession ? { supersession: input.supersession } : {}), - }; - input.archivedPlaceholders.set(cacheKey, placeholder); - } else if (input.supersession) { - placeholder = { ...placeholder, supersession: input.supersession }; - input.archivedPlaceholders.set(cacheKey, placeholder); - } else if (placeholder.supersession) { - const { supersession: _supersession, ...genericPlaceholder } = placeholder; - placeholder = genericPlaceholder; - input.archivedPlaceholders.set(cacheKey, placeholder); - } + const outcome = await archiveToolResultAsTransition(input.input.transitions, { + runtimeEventId: address.runtimeEventId, + turnId: address.turnId, + toolCallId: part.toolCallId, + toolName: part.toolName, + sourceProjection, + serializedResult, + originalBytes: utf8ByteLength(serializedResult), + originalEstimatedTokens, + reason: 'active_current_turn_tool_result_pruned_before_next_step', + ...(address.previousTransitionId ? { previousTransitionId: address.previousTransitionId } : {}), + ...(input.supersession ? { supersession: input.supersession } : {}), + result: payload.value, + }); + if (!outcome) return { changed: false, archiveFailure: true }; const placeholderText = payload.field === 'output' && (payload.outputKind === 'text' || payload.outputKind === 'error-text') - ? activePlaceholderText(placeholder) - : serializeToolResultForArchive(placeholder); + ? JSON.stringify(outcome.placeholder) + : serializeToolResultForArchive(outcome.placeholder); const placeholderEstimatedTokens = estimateTokens(placeholderText.length, input.charsPerToken); - if (input.supersession && placeholderEstimatedTokens >= originalEstimatedTokens) { - return { changed: false }; - } return { changed: true, - part: replacePayload(input.part, payload, placeholder), + part: replacePayload(part, payload, outcome.placeholder), estimatedTokensSaved: Math.max(0, originalEstimatedTokens - placeholderEstimatedTokens), ...(input.supersession ? { supersession: input.supersession } : {}), }; @@ -407,7 +361,7 @@ function extractPayload( function replacePayload( part: ToolResultPartish, payload: { field: 'output'; outputKind: string } | { field: 'result' }, - placeholder: ActiveArchivedToolResultPlaceholder, + placeholder: ArchivedToolResultPlaceholder, ): ToolResultPartish { if (payload.field === 'result') { return { ...part, result: placeholder }; @@ -416,7 +370,7 @@ function replacePayload( const output = part.output as Record; const nextValue = payload.outputKind === 'text' || payload.outputKind === 'error-text' - ? activePlaceholderText(placeholder) + ? JSON.stringify(placeholder) : (placeholder as unknown as JSONValue); return { ...part, @@ -427,78 +381,23 @@ function replacePayload( }; } -export function isActiveArchivedToolResultPlaceholder( - value: unknown, -): value is ActiveArchivedToolResultPlaceholder { - if (!value || typeof value !== 'object') return false; - const candidate = value as Partial; - return ( - candidate.kind === ACTIVE_ARCHIVED_TOOL_RESULT_PLACEHOLDER_KIND && - candidate.rewriteVersion === ARCHIVED_TOOL_RESULT_REWRITE_VERSION && - typeof candidate.artifactId === 'string' && - isUsableArtifactId(candidate.artifactId) && - typeof candidate.turnId === 'string' && - candidate.turnId.length > 0 && - typeof candidate.toolCallId === 'string' && - candidate.toolCallId.length > 0 && - typeof candidate.toolName === 'string' && - candidate.toolName.length > 0 && - typeof candidate.bodySha256 === 'string' && - candidate.bodySha256.length > 0 && - typeof candidate.originalEstimatedTokens === 'number' && - Number.isFinite(candidate.originalEstimatedTokens) && - candidate.originalEstimatedTokens > 0 && - typeof candidate.originalBytes === 'number' && - Number.isFinite(candidate.originalBytes) && - candidate.originalBytes > 0 && - candidate.reason === 'active_current_turn_tool_result_pruned_before_next_step' && - isValidSupersession(candidate.supersession) - ); -} - -function isToolResultPartish(value: unknown): value is ToolResultPartish { - return Boolean( - value && typeof value === 'object' && (value as ToolResultPartish).type === 'tool-result', - ); -} - -function activePlaceholderText(placeholder: ActiveArchivedToolResultPlaceholder): string { - return JSON.stringify(placeholder); -} - +/** + * A payload that already IS a placeholder, in either shape the provider format + * allows: the JSON object, or the serialized text a `text` output carries. + * Re-archiving one would archive a pointer, not a body. + */ function isArchivedPayload(value: unknown): boolean { - return ( - isActiveArchivedToolResultPlaceholder(value) || - (typeof value === 'string' && isActiveArchivedToolResultPlaceholderText(value)) - ); -} - -function isValidSupersession(value: unknown): boolean { - if (value === undefined) return true; - if (!value || typeof value !== 'object') return false; - const candidate = value as Partial; - return ( - (candidate.reason === 'exact_duplicate' || - candidate.reason === 'newer_read_covers_range' || - candidate.reason === 'newer_snapshot' || - candidate.reason === 'failure_resolved') && - typeof candidate.supersededByToolCallId === 'string' && - candidate.supersededByToolCallId.length > 0 && - (candidate.reason === 'failure_resolved' - ? typeof candidate.failureBodySha256 === 'string' && - /^[a-f0-9]{64}$/.test(candidate.failureBodySha256) - : candidate.failureBodySha256 === undefined) - ); -} - -function isActiveArchivedToolResultPlaceholderText(value: string): boolean { + if (isArchivedToolResultPlaceholder(value)) return true; + if (typeof value !== 'string') return false; try { - return isActiveArchivedToolResultPlaceholder(JSON.parse(value)); + return isArchivedToolResultPlaceholder(JSON.parse(value)); } catch { return false; } } -function isUsableArtifactId(value: unknown): value is string { - return typeof value === 'string' && value.trim().length > 0; +function isToolResultPartish(value: unknown): value is ToolResultPartish { + return Boolean( + value && typeof value === 'object' && (value as ToolResultPartish).type === 'tool-result', + ); } diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index b483bb90c3..5087f93bdc 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -29,6 +29,10 @@ import type { RunCompositionSnapshot } from '@maka/core/run-composition'; import { decodeRunCompositionSnapshot } from '@maka/core/run-composition'; import { DurableStoreWriteError, RunSealedError } from '@maka/core/runtime-event-store'; import { isSessionInlineRun } from '@maka/core/agent-run'; +import { + MODEL_PROJECTION_TRANSITION_EVENT_TYPE, + type ModelProjectionTransition, +} from '@maka/core/model-projection-transition'; import { isTerminalRuntimeEvent } from '@maka/core/runtime-event'; import { ToolLedgerCorruptionError, @@ -490,6 +494,37 @@ export class AgentRun { }); } + /** + * Durable append for one model-projection transition (#4283). + * + * Rethrows like the checkpoint recorder above: the caller may only show the + * replacement once the ledger holds the record, so a failed append must be a + * failed prune, not a silent one. + */ + recordModelProjectionTransition(transition: ModelProjectionTransition): Promise { + if (!this.input.runStore) return Promise.reject(new Error('AgentRun store is not configured')); + if (!this.runStoreAvailable) return Promise.reject(new Error('AgentRun store is unavailable')); + return this.enqueueRunStore( + 'append model projection transition', + async () => { + await this.input.runStore?.appendEvent(this.sessionId, this.runId, { + type: MODEL_PROJECTION_TRANSITION_EVENT_TYPE, + id: transition.transitionId, + runId: this.runId, + sessionId: this.sessionId, + turnId: this.turnId, + ts: transition.createdAt, + data: { + runtimeEventId: transition.target.runtimeEventId, + part: transition.target.part, + transition, + }, + }); + }, + { rethrow: true }, + ); + } + recordHistoryCompactCheckpoint(checkpoint: HistoryCompactCheckpoint): Promise { if (!this.input.runStore) return Promise.reject(new Error('AgentRun store is not configured')); if (!this.runStoreAvailable) return Promise.reject(new Error('AgentRun store is unavailable')); diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 5540f6d1a0..8b13c11ebe 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -1897,7 +1897,12 @@ export class AiSdkBackend implements AgentBackend { // the active-step shaper must see that growth so it can roll the // checkpoint forward instead of resurrecting raw history. } - const replayPlan = buildRuntimeEventModelReplayPlan(replayEvents, { + // The current Turn is model-visible history like any other, so it is + // folded through the same reducer before it becomes messages. Without + // this, a result archived at step N is rebuilt in full at step N+1 and + // the ledger's account of what the model sees stops being true. + const foldedReplayEvents = await this.compaction.foldEffectiveModelHistory(replayEvents); + const replayPlan = buildRuntimeEventModelReplayPlan(foldedReplayEvents, { toolActivityTurnIds: collectToolActivityTurnIds([ ...(input.runtimeContext ?? []), ...turnEvents, @@ -3380,9 +3385,20 @@ export class AiSdkBackend implements AgentBackend { diagnostics: [], }; } - const priorRuntimeContext = input.runtimeContext.filter( + const rawPriorRuntimeContext = input.runtimeContext.filter( (event) => event.turnId !== input.turnId, ); + // Everything below reads EFFECTIVE model history: raw events folded through + // the durable projection-transition reducer (#4283). Replay, budgeting and + // compaction share one input, so no RuntimeEvent replay path can resurrect + // content a committed transition removed. The StoredMessage projection used + // by the degraded fallbacks below is a separate representation that the fold + // does not reach — see #4283 for that remaining gap. + const preparedContextBudget = await this.compaction.prepareContextBudgetPolicy( + rawPriorRuntimeContext, + input.turnId, + ); + const priorRuntimeContext = preparedContextBudget.events; const providerReasoningReplayEventIds = compatibleProviderReasoningReplayEventIds( priorRuntimeContext, input.runtimeContextRunHeaders, @@ -3394,8 +3410,6 @@ export class AiSdkBackend implements AgentBackend { priorStored, buildSteeringSidecar(priorRuntimeContext), ); - const preparedContextBudget = - await this.compaction.prepareContextBudgetPolicy(priorRuntimeContext); let contextBudget = preparedContextBudget.policy; const budgeted = applyRuntimeEventContextBudget(priorRuntimeContext, contextBudget); let runtimeContext = budgeted?.events ?? priorRuntimeContext; diff --git a/packages/runtime/src/ai-sdk-compaction-contract.ts b/packages/runtime/src/ai-sdk-compaction-contract.ts index 79fd3690ce..4f009b1e08 100644 --- a/packages/runtime/src/ai-sdk-compaction-contract.ts +++ b/packages/runtime/src/ai-sdk-compaction-contract.ts @@ -20,10 +20,11 @@ import type { RuntimeExecutionConnection } from '@maka/core/llm-connections'; import type { HistoryCompactRoute } from '@maka/core/model-call-attempt'; import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { ModelProjectionTransition } from '@maka/core/model-projection-transition'; +import type { LoadedModelProjectionTransitions } from './model-projection-transition-ledger.js'; import type { AgentRunHeader } from '@maka/core/agent-run'; import type { ProviderRequestTracker } from './provider-request-telemetry.js'; -import type { ActiveToolResultArchiveCandidate } from './active-tool-result-prune.js'; import type { ContextBudgetPolicy } from './context-budget.js'; import type { HistoryCompactCheckpoint, @@ -88,6 +89,11 @@ export type HistoryCompactCheckpointRecorder = ( checkpoint: HistoryCompactCheckpoint, turnId: string, ) => void | Promise; +export type ModelProjectionTransitionLoader = () => Promise; +export type ModelProjectionTransitionLedgerRecorder = ( + transition: ModelProjectionTransition, + turnId: string, +) => Promise; /** Provider and persistence capabilities used by the compaction collaborator. */ export interface AiSdkCompactionCapabilities { connection: RuntimeExecutionConnection; @@ -112,6 +118,14 @@ export interface AiSdkCompactionCapabilities { historyCompactRoute?: HistoryCompactRoute; /** Durable recorder for accepted checkpoints; persistence precedes projection. */ recordHistoryCompactCheckpoint?: HistoryCompactCheckpointRecorder; + /** + * Session-scoped read of every committed model-projection transition (#4283). + * Absent means this session cannot make a lossy model-history change durable, + * and therefore must not make one at all. + */ + loadModelProjectionTransitions?: ModelProjectionTransitionLoader; + /** Durable append for one transition; persistence precedes model-visible loss. */ + recordModelProjectionTransition?: ModelProjectionTransitionLedgerRecorder; /** * Durable read of the given turn's persisted RuntimeEvents from the * authoritative run ledger. Mid-turn capacity compaction derives its diff --git a/packages/runtime/src/ai-sdk-compaction.ts b/packages/runtime/src/ai-sdk-compaction.ts index 07b903e944..7747e2e273 100644 --- a/packages/runtime/src/ai-sdk-compaction.ts +++ b/packages/runtime/src/ai-sdk-compaction.ts @@ -44,13 +44,10 @@ import type { } from './ai-sdk-compaction-contract.js'; import { compactionDecisionDiagnosticPatch } from './compaction-boundary.js'; import { - ARCHIVED_TOOL_RESULT_REWRITE_VERSION, buildContextBudgetDiagnosticShell, estimateRuntimeEventsTokens, mergeContextBudgetDiagnostic, - type ActiveArchivedToolResultPlaceholder, type ContextBudgetPolicy, - type ToolResultArchiveRef, } from './context-budget.js'; import { evaluateHistoryCompactCheckpointReplay, @@ -80,10 +77,23 @@ import type { } from './request-projection.js'; import { rewriteActiveToolResultsInMessages, - type ActiveToolResultArchiveCandidate, + type ActiveToolResultProjectionSource, type ActiveToolResultPruneDiagnosticPatch, } from './active-tool-result-prune.js'; -import { collectStaleToolResultArchiveCandidates } from './tool-result-archive.js'; +import { + archiveToolResultAsTransition, + collectStaleToolResultArchiveCandidates, + serializedToolResultProjection, + type ToolResultArchiveTransitionServices, +} from './tool-result-archive-transition.js'; +import { estimateTokens } from './context-budget-helpers.js'; +import { + baseToolResultProjection, + reduceEffectiveModelProjections, + type LoadedModelProjectionTransitions, +} from './model-projection-transition-ledger.js'; +import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection'; +import type { ModelProjectionTransition } from '@maka/core/model-projection-transition'; import type { ContextBudgetExhaustedDetail, SessionEvent } from '@maka/core/events'; import type { AsyncEventQueue } from './async-queue.js'; @@ -228,6 +238,53 @@ export class AiSdkCompaction { this.canReplayProviderNative = deps.canReplayProviderNative; } + /** + * Every transition this session has committed. + * + * Read from the durable ledger rather than remembered: a Turn that pruned and + * a Turn that replays it may be different processes. A read that fails or that + * this build cannot fully decode is reported, never smoothed into "there are + * no transitions" — a caller that cannot see the whole chain may still show + * what it folded, but it may not append a successor onto a state it only + * partly knows. + */ + private async loadModelProjectionTransitions(): Promise { + const loaded = await this.input.loadModelProjectionTransitions?.(); + const resolved = { + transitions: [], + unreadableTargets: new Set(), + unscopedUnreadable: 0, + ...loaded, + }; + if (resolved.unscopedUnreadable > 0) { + // The record names no target, so nothing can be confined and nothing can + // be shown: replaying raw history here would show whatever that record + // removed. Failing is recoverable; showing it again is not. + throw new Error('model projection transition ledger contains an unscoped unreadable record'); + } + return resolved; + } + + /** + * The archive-and-commit writer, or `undefined` when this session cannot make + * a lossy model-history change durable. Without both halves — an archive to + * put the body in and a ledger to record the replacement — no prune may run. + */ + private toolResultArchiveTransitionServices( + turnId: string, + ): ToolResultArchiveTransitionServices | undefined { + const archive = this.input.toolResultArchive?.services.archiveToolResult; + const record = this.input.recordModelProjectionTransition; + if (!archive || !record) return undefined; + return { + sessionId: this.sessionId, + archiveToolResult: (candidate) => archive(candidate), + recordTransition: (transition) => record(transition, turnId), + loadTransitions: () => this.loadModelProjectionTransitions(), + now: this.now, + }; + } + /** Abort an in-flight manual history compaction (called by AiSdkBackend.stop). */ public abortHistoryCompact(): void { this.historyCompactAbortController?.abort(); @@ -508,58 +565,116 @@ export class AiSdkCompaction { } } - public async prepareContextBudgetPolicy(runtimeContext: readonly RuntimeEvent[]): Promise<{ + /** + * Fold the durable transition ledger onto any slice of model-visible history. + * + * The current Turn's own events go through here on every provider step, for + * the same reason prior Turns do: what the model sees is the folded ledger, + * not the raw one. A ledger this build cannot read in full leaves the slice + * untouched — the content is then merely unpruned, never wrongly replaced. + */ + public async foldEffectiveModelHistory(events: readonly RuntimeEvent[]): Promise { + const loaded = await this.loadModelProjectionTransitions(); + if (loaded.transitions.length === 0) return [...events]; + return reduceEffectiveModelProjections(events, loaded.transitions).events; + } + + /** + * Fold the durable transition ledger onto this session's prior history, and + * commit any new stale-result transition the prune policy calls for. + * + * This is the one seam where raw RuntimeEvents become effective model + * history: the caller uses the returned events for replay, budgeting and + * compaction alike, so no later stage can read content a transition removed. + */ + public async prepareContextBudgetPolicy( + runtimeContext: readonly RuntimeEvent[], + turnId: string, + ): Promise<{ policy: ContextBudgetPolicy | undefined; + events: RuntimeEvent[]; diagnosticPatch?: Partial; }> { const policy = this.input.contextBudget; - if (!policy) return { policy }; + const loaded = await this.loadModelProjectionTransitions(); + let transitions = loaded.transitions; + let effective = reduceEffectiveModelProjections( + runtimeContext, + transitions, + loaded.unreadableTargets, + ); + if (!policy) return { policy, events: effective.events }; let nextPolicy = policy; - - if (policy.staleToolResultPrune?.enabled === true) { + let diagnosticPatch: Partial | undefined; + + // A chain this reader cannot see in full is a chain it must not extend: a + // successor built on a partly known state would name the wrong predecessor + // and be permanently inert, losing the content it archived. + const services = + loaded.unreadableTargets.size === 0 + ? this.toolResultArchiveTransitionServices(turnId) + : undefined; + if (policy.staleToolResultPrune?.enabled === true && services) { + // The decision is taken over EFFECTIVE history, so a result an earlier + // Turn already replaced is never re-measured — or re-archived — at the + // size it used to have. const candidates = collectStaleToolResultArchiveCandidates( - runtimeContext, - policy?.staleToolResultPrune, - policy?.charsPerToken ?? 4, + effective.events, + policy.staleToolResultPrune, + policy.charsPerToken ?? 4, ); - if (candidates.length > 0) { - const archiveRefs = new Map(); - const existingArchiveRefs = nextPolicy.staleToolResultPrune?.archiveRefs; - if (Array.isArray(existingArchiveRefs)) { - for (const ref of existingArchiveRefs) archiveRefs.set(ref.runtimeEventId, ref); - } else if (existingArchiveRefs) { - for (const ref of Object.values(existingArchiveRefs)) - archiveRefs.set(ref.runtimeEventId, ref); - } - for (const candidate of candidates) { - const bodySha256 = sha256(candidate.serializedResult); - const archived = await Promise.resolve( - this.input.toolResultArchive?.services.archiveToolResult({ - ...candidate, - sessionId: this.sessionId, - bodySha256, - }), - ).catch(() => undefined); - if (!archived?.artifactId) continue; - archiveRefs.set(candidate.runtimeEventId, { - runtimeEventId: candidate.runtimeEventId, - toolCallId: candidate.toolCallId, - toolName: candidate.toolName, - artifactId: archived.artifactId, - bodySha256, - originalEstimatedTokens: candidate.originalEstimatedTokens, - originalBytes: candidate.originalBytes, - rewriteVersion: ARCHIVED_TOOL_RESULT_REWRITE_VERSION, - reason: candidate.reason, - }); + const committed: ModelProjectionTransition[] = []; + let archiveFailures = 0; + let estimatedTokensBefore = 0; + let estimatedTokensAfter = 0; + for (const candidate of candidates) { + const outcome = await archiveToolResultAsTransition(services, { + runtimeEventId: candidate.runtimeEventId, + turnId: candidate.turnId, + toolCallId: candidate.toolCallId, + toolName: candidate.toolName, + sourceProjection: candidate.sourceProjection, + serializedResult: candidate.serializedResult, + originalBytes: candidate.originalBytes, + originalEstimatedTokens: candidate.originalEstimatedTokens, + reason: candidate.reason, + result: candidate.result, + }); + if (!outcome) { + archiveFailures += 1; + continue; } - - nextPolicy = { - ...nextPolicy, - staleToolResultPrune: { - ...nextPolicy.staleToolResultPrune!, - archiveRefs: [...archiveRefs.values()], - }, + committed.push(outcome.transition); + estimatedTokensBefore += candidate.originalEstimatedTokens; + estimatedTokensAfter += estimateTokens( + serializedToolResultProjection(outcome.transition.replacement).length, + policy.charsPerToken ?? 4, + ); + } + if (committed.length > 0) { + transitions = [...transitions, ...committed]; + effective = reduceEffectiveModelProjections( + runtimeContext, + transitions, + loaded.unreadableTargets, + ); + } + if (committed.length > 0 || archiveFailures > 0) { + diagnosticPatch = { + ...(committed.length > 0 + ? { + prunedToolResults: committed.length, + prunedToolResultEstimatedTokensBefore: estimatedTokensBefore, + prunedToolResultEstimatedTokensAfter: estimatedTokensAfter, + archivePlaceholders: committed.length, + archivePlaceholderReasonCounts: { + stale_tool_result_pruned_before_compact: committed.length, + }, + } + : {}), + ...(archiveFailures > 0 + ? { archiveWriteFailures: archiveFailures, unarchivedToolResults: archiveFailures } + : {}), }; } } @@ -584,7 +699,11 @@ export class AiSdkCompaction { historyCompact: { ...nextPolicy.historyCompact!, checkpoint: loadedCheckpoint }, }; } - return { policy: nextPolicy }; + return { + policy: nextPolicy, + events: effective.events, + ...(diagnosticPatch ? { diagnosticPatch } : {}), + }; } public buildActiveToolResultPruneProjection( @@ -594,14 +713,67 @@ export class AiSdkCompaction { ): RequestProjectionStage | undefined { const policy = this.input.contextBudget?.activeToolResultPrune; if (policy?.enabled !== true) return undefined; + const services = this.toolResultArchiveTransitionServices(turnId); + // No durable ledger, no lossy rewrite. The old per-Turn placeholder map let + // this run prune content that the NEXT request would have shown again. + if (!services || !this.input.loadTurnRuntimeEvents) return undefined; + + // The current Turn reads the same folded history as every other consumer. + // Nothing here remembers what this run already archived: the ledger says it, + // and a step that measures the raw body again would archive it again. + let effective: { events: RuntimeEvent[]; lastApplied: Map } | undefined; + const loadEffectiveTurnEvents = async (): Promise => { + if (effective) return effective; + const loaded = await this.loadModelProjectionTransitions(); + if (loaded.unreadableTargets.size > 0) return undefined; + let turnEvents: RuntimeEvent[]; + try { + turnEvents = await this.input.loadTurnRuntimeEvents!(turnId); + } catch { + return undefined; + } + const reduction = reduceEffectiveModelProjections(turnEvents, loaded.transitions); + const lastApplied = new Map(); + for (const transition of reduction.applied) { + lastApplied.set(transition.target.runtimeEventId, transition.transitionId); + } + effective = { events: reduction.events, lastApplied }; + return effective; + }; + + const resolveProjection = async ( + toolCallId: string, + ): Promise => { + const current = await loadEffectiveTurnEvents(); + if (!current) return undefined; + const event = current.events.find( + (candidate) => + candidate.partial !== true && + candidate.content?.kind === 'function_response' && + candidate.content.id === toolCallId, + ); + if (!event || event.content?.kind !== 'function_response') return undefined; + const projection = baseToolResultProjection(event); + if (!projection) return undefined; + const previousTransitionId = current.lastApplied.get(event.id); + return { + runtimeEventId: event.id, + turnId: event.turnId, + toolName: event.content.name, + projection, + ...(previousTransitionId ? { previousTransitionId } : {}), + }; + }; - const archivedPlaceholders = new Map(); return async (options) => { const eligibleToolCallIds = collectPrunableCompletedStepToolCallIds( options.completedSteps, includeNewestStep, ); if (eligibleToolCallIds.size === 0) return undefined; + // Each provider step rebuilds its messages from the durable Turn ledger, + // so each step must re-fold it too. + effective = undefined; const rewritten = await rewriteActiveToolResultsInMessages({ messages: options.messages, policy, @@ -617,16 +789,8 @@ export class AiSdkCompaction { stepNumber, })), ), - archivedPlaceholders, - archiveToolResult: async (candidate) => { - return await Promise.resolve( - this.input.toolResultArchive?.services.archiveToolResult({ - ...candidate, - sessionId: this.sessionId, - runtimeEventId: candidate.runtimeEventId ?? activeToolResultArchiveKey(candidate), - }), - ); - }, + resolveProjection, + transitions: services, }); if (hasActiveToolResultPruneDiagnosticPatch(rewritten.diagnosticPatch)) { onDiagnosticPatch?.(rewritten.diagnosticPatch); @@ -1442,12 +1606,6 @@ function mergeCountsInto( // -- moved helpers (prepare-step / signature / prune) ------------------------ -function activeToolResultArchiveKey( - candidate: ActiveToolResultArchiveCandidate & { bodySha256: string }, -): string { - return `active:${candidate.turnId}:${candidate.toolCallId}:${candidate.bodySha256}`; -} - /** * Tool results from the newest completed step have not crossed the provider * boundary yet: projection is invoked immediately before the first request diff --git a/packages/runtime/src/context-budget.ts b/packages/runtime/src/context-budget.ts index 9c79880e70..1657d47f9d 100644 --- a/packages/runtime/src/context-budget.ts +++ b/packages/runtime/src/context-budget.ts @@ -38,7 +38,6 @@ export type { ToolResultArchiveReaderInput, ToolResultArchiveReadFailureReason, ToolResultArchiveReadResult, - ToolResultArchiveRef, ArchivedToolResultPlaceholder, } from './tool-result-archive.js'; export type { ArchivedToolResultReason } from './tool-result-archive.js'; @@ -46,15 +45,7 @@ export type { HistoryCompactionPolicy, HistoryCompactionReplayResult, } from './history-compaction.js'; -export { ACTIVE_ARCHIVED_TOOL_RESULT_PLACEHOLDER_KIND } from './active-tool-result-prune.js'; -export type { ActiveArchivedToolResultPlaceholder } from './active-tool-result-prune.js'; - -import { - collectStaleToolResultArchiveCandidates as collectStaleToolResultArchiveCandidatesNarrow, - pruneStaleToolResultsBeforeCompact, - type StaleToolResultPrunePolicy, - type StaleToolResultArchiveCandidate, -} from './tool-result-archive.js'; +import type { StaleToolResultPrunePolicy } from './tool-result-archive.js'; import { type ActiveToolResultPrunePolicy } from './active-tool-result-prune.js'; import { applyRuntimeEventHistoryCompact as applyRuntimeEventHistoryCompactNarrow, @@ -130,12 +121,12 @@ export function applyRuntimeEventContextBudget( policy?.maxHistoryEstimatedTokens, { charsPerToken }, ); - const pruned = pruneStaleToolResultsBeforeCompact( - compacted.events, - policy?.staleToolResultPrune, - charsPerToken, - ); - const keptEvents = pruned.events; + // Stale Tool Result pruning is no longer a step of the budget: it is a + // durable projection transition committed before this projection runs, and + // the events arriving here have already been folded through the reducer + // (#4283). A second rewrite here could only disagree with the ledger about + // what the model is allowed to see. + const keptEvents = compacted.events; const keptTurnIds = new Set(keptEvents.map((event) => runtimeEventTurnKey(event))); const originalTurnIds = new Set(events.map((event) => runtimeEventTurnKey(event))); @@ -152,23 +143,6 @@ export function applyRuntimeEventContextBudget( keptEvents: keptEvents.length, droppedEvents: Math.max(0, events.length - keptEvents.length), ...compacted.diagnosticPatch, - ...(pruned.prunedToolResults > 0 - ? { - prunedToolResults: pruned.prunedToolResults, - prunedToolResultEstimatedTokensBefore: pruned.estimatedTokensBefore, - prunedToolResultEstimatedTokensAfter: pruned.estimatedTokensAfter, - archivePlaceholders: pruned.prunedToolResults, - archivePlaceholderReasonCounts: { - stale_tool_result_pruned_before_compact: pruned.prunedToolResults, - }, - } - : {}), - ...(pruned.archiveWriteFailures > 0 - ? { - archiveWriteFailures: pruned.archiveWriteFailures, - unarchivedToolResults: pruned.archiveWriteFailures, - } - : {}), }; return { events: keptEvents, @@ -312,17 +286,6 @@ function mergeCompactionDecisionDiagnostics( // Public compat wrappers: preserve the pre-split `(events, policy, options)` // signature for @maka/runtime consumers. Internal callers (this module and // ai-sdk-backend) import the narrow leaf API directly from the leaf modules. -export function collectStaleToolResultArchiveCandidates( - events: readonly RuntimeEvent[], - policy: ContextBudgetPolicy | undefined, -): StaleToolResultArchiveCandidate[] { - return collectStaleToolResultArchiveCandidatesNarrow( - events, - policy?.staleToolResultPrune, - policy?.charsPerToken ?? 4, - ); -} - export function applyRuntimeEventHistoryCompact( events: readonly RuntimeEvent[], policy: ContextBudgetPolicy | undefined, diff --git a/packages/runtime/src/conversation-copy.ts b/packages/runtime/src/conversation-copy.ts index 6437e3aa78..f013c89302 100644 --- a/packages/runtime/src/conversation-copy.ts +++ b/packages/runtime/src/conversation-copy.ts @@ -59,6 +59,19 @@ import { type ArchivedToolResultPlaceholder, } from './tool-result-archive.js'; import { rewriteDurableToolResultProjectionArtifactRefs } from './durable-tool-result-projection.js'; +import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection'; +import { + buildModelProjectionTransition, + decodeModelProjectionTransition, + MODEL_PROJECTION_TRANSITION_EVENT_TYPE, + type ModelProjectionTransition, +} from '@maka/core/model-projection-transition'; +import { + baseToolResultProjection, + decodeLedgerTransition, + reduceEffectiveModelProjections, +} from './model-projection-transition-ledger.js'; +import { archivedToolResultProjection } from './tool-result-archive-transition.js'; export interface ConversationCopySlice { readonly messages: readonly StoredMessage[]; @@ -276,6 +289,7 @@ export async function prepareConversationRuntimeLedgerCopy(input: { return { run, runtimeEvents: events, operationalEvents }; }), ); + await rebuildCopiedProjectionTransitions(input.sourceSessionId, sourceRuns, runs, input.runStore); const plan = { sourceSessionId: input.sourceSessionId, copyTurnIds, @@ -286,6 +300,95 @@ export async function prepareConversationRuntimeLedgerCopy(input: { return plan; } +/** + * Rebuild the copied slice's transition records from the source fold. + * + * Two things make "copy the records you happen to hold" wrong. A transition is + * recorded by the run that decided it, which for a prior-Turn archive is a + * LATER run than the one holding its target — so copying by run keeps the + * target and drops the record that replaced it. And a ledger holds records the + * source fold refused: rival roots resolved by content-derived id, stale + * writers. Carrying those lets the copy re-decide and make a source-rejected + * transition model-visible. + * + * So the source reduction is the authority here too: every record for a copied + * target is gathered, folded, and only the applied chain is kept, in the order + * the fold applied it. + */ +async function rebuildCopiedProjectionTransitions( + sessionId: string, + sourceRuns: readonly AgentRunHeader[], + runs: readonly { + readonly run: AgentRunHeader; + readonly runtimeEvents: readonly RuntimeEvent[]; + readonly operationalEvents: AgentRunEvent[]; + }[], + runStore: Pick, +): Promise { + const owningRun = new Map(); + const copiedRuntimeEvents: RuntimeEvent[] = []; + for (const { run, runtimeEvents, operationalEvents } of runs) { + for (const event of runtimeEvents) { + owningRun.set(event.id, { run, operationalEvents }); + copiedRuntimeEvents.push(event); + } + } + + const ledgerEvents = new Map(); + const transitions: ModelProjectionTransition[] = []; + const collect = (events: readonly AgentRunEvent[]): void => { + for (const event of events) { + const transition = decodeLedgerTransition(event, sessionId); + if (!transition || !owningRun.has(transition.target.runtimeEventId)) continue; + if (ledgerEvents.has(transition.transitionId)) continue; + ledgerEvents.set(transition.transitionId, event); + transitions.push(transition); + } + }; + const copiedRunIds = new Set(runs.map(({ run }) => run.runId)); + for (const { operationalEvents } of runs) collect(operationalEvents); + for (const run of sourceRuns) { + if (copiedRunIds.has(run.runId)) continue; + collect(await runStore.readEvents(sessionId, run.runId)); + } + if (transitions.length === 0) return; + + // Records this build cannot decode are not gathered above, so the copy would + // silently lose whatever they removed. Refuse instead. + for (const run of sourceRuns) { + for (const event of copiedRunIds.has(run.runId) + ? runs.find(({ run: copied }) => copied.runId === run.runId)!.operationalEvents + : await runStore.readEvents(sessionId, run.runId)) { + if ( + event.type === MODEL_PROJECTION_TRANSITION_EVENT_TYPE && + !decodeLedgerTransition(event, sessionId) && + typeof event.data?.runtimeEventId === 'string' && + owningRun.has(event.data.runtimeEventId) + ) { + throw new Error( + `Cannot copy a conversation whose projection transition ${event.id} is unreadable`, + ); + } + } + } + + for (const { operationalEvents } of runs) { + for (let index = operationalEvents.length - 1; index >= 0; index -= 1) { + if (operationalEvents[index]!.type === MODEL_PROJECTION_TRANSITION_EVENT_TYPE) { + operationalEvents.splice(index, 1); + } + } + } + for (const transition of reduceEffectiveModelProjections(copiedRuntimeEvents, transitions) + .applied) { + const owner = owningRun.get(transition.target.runtimeEventId)!; + const event = ledgerEvents.get(transition.transitionId)!; + // The record moves to the run that owns its target, so the copy keeps one + // rule for every operational event: an event belongs to the run it is in. + owner.operationalEvents.push({ ...event, runId: owner.run.runId, turnId: owner.run.turnId }); + } +} + function assertConversationRuntimeLedgerCopySupported( plan: ConversationRuntimeLedgerCopyPlan, ): void { @@ -376,6 +479,14 @@ export async function cloneConversationRuntimeLedger( } } const checkpointIds = new Map(); + // Transition lineage across the copy boundary: source transition id -> target + // id, plus the effective projection each target has reached, so a chained + // successor is rebuilt against the projection it actually replaces. + const transitionIds = new Map(); + const transitionState = new Map< + string, + { projection: DurableToolResultProjection; transitionId: string } + >(); const preparedPlans = flattenedPlans.map((plan) => { const runId = runIds.get(plan.run.runId)!; const invocationId = targetInvocationIds.get(plan.run.runId)!; @@ -391,6 +502,8 @@ export async function cloneConversationRuntimeLedger( sourceCompactableEvents.get(plan.run.runId) ?? [], clonedEventBySourceId, checkpointIds, + transitionIds, + transitionState, operationalEventIds, providerTraceIds, logicalCallIds, @@ -705,6 +818,8 @@ function cloneAgentRunEvent( sourceCompactableEvents: readonly RuntimeEvent[], clonedRuntimeEvents: ReadonlyMap, checkpointIds: Map, + transitionIds: Map, + transitionState: Map, operationalEventIds: ReadonlyMap, providerTraceIds: ReadonlyMap, logicalCallIds: ReadonlyMap, @@ -805,6 +920,20 @@ function cloneAgentRunEvent( checkpointId: checkpoint.checkpointId, checkpoint, }; + } else if (event.type === MODEL_PROJECTION_TRANSITION_EVENT_TYPE) { + const cloned = cloneModelProjectionTransition( + event, + references, + clonedRuntimeEvents, + transitionIds, + transitionState, + ); + // Every transition whose target is in the copied slice was gathered into + // this run's ledger, wherever it was recorded. So a transition that finds no + // cloned target has genuinely lost its target as well, and dropping it + // cannot bring replaced content back. + if (!cloned) return null; + data = { ...event.data, transition: cloned, runtimeEventId: cloned.target.runtimeEventId }; } return { @@ -816,6 +945,73 @@ function cloneAgentRunEvent( }; } +/** + * Rebuild one projection transition inside the target Session. + * + * A transition is a claim about a specific projection of a specific event, so a + * copy cannot carry it verbatim: the target's RuntimeEvent id, artifact ids and + * therefore its projection digest are all different. Rebuilding it re-derives + * the digest from the CLONED event, which also means a copy that failed to + * remap something cannot silently produce an inert transition — the replaced + * content would come back, so the mismatch throws instead. + */ +function cloneModelProjectionTransition( + event: AgentRunEvent, + references: ConversationCopyReferenceMap, + clonedRuntimeEvents: ReadonlyMap, + transitionIds: Map, + transitionState: Map, +): ModelProjectionTransition | null { + let source: ModelProjectionTransition; + try { + source = decodeModelProjectionTransition(event.data?.transition, event.sessionId); + } catch { + throw new Error(`Cannot copy invalid model projection transition ${event.id}`); + } + const clonedTarget = clonedRuntimeEvents.get(source.target.runtimeEventId); + if (!clonedTarget) return null; + const placeholder = source.replacement.kind === 'json' ? source.replacement.value : undefined; + if (!isArchivedToolResultPlaceholder(placeholder)) { + throw new Error(`Cannot copy unsupported model projection transition ${event.id}`); + } + const existing = transitionState.get(clonedTarget.id); + const sourceProjection = existing?.projection ?? baseToolResultProjection(clonedTarget); + if (!sourceProjection) { + throw new Error(`Cannot copy model projection transition ${event.id} onto its target`); + } + const rewritten = rewriteArchivedToolResult(placeholder, references); + const transition = buildModelProjectionTransition({ + sessionId: references.targetSessionId, + target: { + runtimeEventId: clonedTarget.id, + part: 'tool_result', + toolCallId: source.target.toolCallId, + toolName: source.target.toolName, + }, + sourceProjection, + replacement: archivedToolResultProjection(rewritten), + // The applied chain is copied in fold order, so a predecessor is always + // rebuilt before its successor. An unmapped one means the chain broke, and + // rooting the successor instead would change what the fold decides. + ...(source.previousTransitionId + ? { + previousTransitionId: requiredMappedId( + transitionIds, + source.previousTransitionId, + 'model projection transition', + ), + } + : {}), + now: source.createdAt, + }); + transitionIds.set(source.transitionId, transition.transitionId); + transitionState.set(clonedTarget.id, { + projection: transition.replacement, + transitionId: transition.transitionId, + }); + return transition; +} + function rewriteProviderRequestCapture( event: AgentRunEvent, eventId: string, diff --git a/packages/runtime/src/durable-tool-result-projection.ts b/packages/runtime/src/durable-tool-result-projection.ts index ad58bbebd1..dd52899777 100644 --- a/packages/runtime/src/durable-tool-result-projection.ts +++ b/packages/runtime/src/durable-tool-result-projection.ts @@ -70,6 +70,8 @@ export function encodeDurableToolResultOutput( interface DurableProjectionArtifactPlan { ref: Extract; persist(): Promise; + /** Undo a publication whose projection is not going to be admitted. */ + retract?(): Promise; } type DurableProjectionArtifactPlanner = (input: { @@ -86,6 +88,7 @@ export function encodeDurableToolResultOutputWithArtifacts( return encodeDurableToolResultOutput(output, sessionId); } return (async () => { + const publishedPlans: DurableProjectionArtifactPlan[] = []; try { const prepared = prepareContentProjection(output, sessionId, planArtifact); const persisted = new Set(); @@ -93,9 +96,16 @@ export function encodeDurableToolResultOutputWithArtifacts( if (persisted.has(artifact.ref.relativePath)) continue; await artifact.persist(); persisted.add(artifact.ref.relativePath); + publishedPlans.push(artifact); } return prepared.projection; } catch { + // Publication is all-or-nothing: a projection this codec refuses must not + // leave images behind that no durable record will ever reference (#4283). + // Retraction is best effort — a failed retraction only delays reclamation. + for (const artifact of publishedPlans) { + await artifact.retract?.().catch(() => undefined); + } return DURABLE_TOOL_RESULT_PROJECTION_FAILURE; } })(); diff --git a/packages/runtime/src/model-projection-transition-ledger.ts b/packages/runtime/src/model-projection-transition-ledger.ts new file mode 100644 index 0000000000..93cd9aec2e --- /dev/null +++ b/packages/runtime/src/model-projection-transition-ledger.ts @@ -0,0 +1,284 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * The Session-scoped reducer over durable model-projection transitions (#4283). + * + * One authority, one direction: the append-only operational AgentRunEvent + * ledger holds the transitions, this module folds them onto the canonical + * RuntimeEvent stream, and every consumer of model-facing history — the live + * continuation, the next Turn, a cold restart, rolling compaction, a branch — + * reads the result of that fold rather than the raw events. + * + * Two rules make the fold deterministic regardless of who wrote what when: + * + * 1. Source-digest validation. A transition may only replace the exact + * projection it names. A writer that decided against a projection some other + * writer has already replaced is stale, and its record is inert forever — + * not applied later, not applied on another machine, not applied after a + * restart. This is what stops replaced content from coming back. + * 2. Predecessor chaining. Within one target, a transition applies only when + * the transition it names as predecessor is the one currently in effect. The + * chain is also the fold's ordering authority: successors are followed, never + * sorted, so ledger order, arrival order, run order and clock skew cannot + * disagree about the result. + */ + +import type { AgentRunEvent, AgentRunStore } from '@maka/core/agent-run'; +import { + decodeModelProjectionTransition, + durableToolResultProjectionDigest, + MODEL_PROJECTION_TRANSITION_EVENT_TYPE, + type ModelProjectionTransition, +} from '@maka/core/model-projection-transition'; +import { + DURABLE_TOOL_RESULT_PROJECTION_FAILURE, + type DurableToolResultProjection, +} from '@maka/core/durable-tool-result-projection'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; + +import { + compatibilityToolResultProjection, + durableProjectionToToolResultOutput, +} from './durable-tool-result-projection.js'; + +export interface EffectiveModelProjectionReduction { + /** The events every model-history consumer must read instead of the raw ledger. */ + events: RuntimeEvent[]; + /** Transitions that took effect, in reduction order. */ + applied: ModelProjectionTransition[]; + /** + * Transitions the fold refused: a stale source digest or a broken predecessor + * chain. They stay durable and stay inert — a refusal is not a retry. + */ + rejected: ModelProjectionTransition[]; + /** + * Targets that carry a record this build could not decode. + * + * The record may be the one that removed this content, so the fold withholds + * the target's projection rather than replaying the raw body — the same + * outcome the codec produces for anything it cannot represent safely. + */ + unreadableTargets: Set; +} + +/** + * Read every transition this session has committed. + * + * Sparse per-event records have no max-coverage lineage to select from, so — + * unlike the compaction checkpoint — there is no single-row projection to read + * here: the whole set is the state. + */ +export async function loadModelProjectionTransitionsFromRunLedger( + runStore: Pick, + sessionId: string, +): Promise { + const byId = new Map(); + const unreadableTargets = new Set(); + let unscopedUnreadable = 0; + for (const run of await runStore.listSessionRuns(sessionId)) { + for (const event of await runStore.readEvents(sessionId, run.runId)) { + if (event.type !== MODEL_PROJECTION_TRANSITION_EVENT_TYPE) continue; + const transition = decodeLedgerTransition(event, sessionId); + if (!transition) { + // The envelope names the target outside the payload, so a record whose + // body this build cannot read can still be confined to the one event it + // concerns. A record that does not even name a target cannot be + // confined, and leaves this session's model history unreadable. + const target = unreadableTargetKey(event); + if (target) unreadableTargets.add(target); + else unscopedUnreadable += 1; + continue; + } + // A content-derived id makes a duplicated concurrent append idempotent. + if (!byId.has(transition.transitionId)) byId.set(transition.transitionId, transition); + } + } + return { transitions: [...byId.values()], unreadableTargets, unscopedUnreadable }; +} + +export interface LoadedModelProjectionTransitions { + transitions: ModelProjectionTransition[]; + /** Target keys carrying a record of the right type this build cannot decode. */ + unreadableTargets: Set; + /** Undecodable records that do not name a target, so nothing can be confined. */ + unscopedUnreadable: number; +} + +function unreadableTargetKey(event: AgentRunEvent): string | undefined { + const runtimeEventId = event.data?.runtimeEventId; + const part = event.data?.part; + return typeof runtimeEventId === 'string' && + runtimeEventId.length > 0 && + typeof part === 'string' && + part.length > 0 + ? targetKey(runtimeEventId, part) + : undefined; +} + +export function decodeLedgerTransition( + event: AgentRunEvent, + sessionId: string, +): ModelProjectionTransition | undefined { + if (event.type !== MODEL_PROJECTION_TRANSITION_EVENT_TYPE) return undefined; + try { + return decodeModelProjectionTransition(event.data?.transition, sessionId); + } catch { + // A record this build cannot decode is not a licence to show the replaced + // content again — but neither can it be applied. It stays out of the fold. + return undefined; + } +} + +/** + * The effective projection of one `function_response` event, before any + * transition: what the durable schema holds, or what the single compatibility + * codec makes of a legacy event. `undefined` means provider-native opaque + * state, which no transition may address. + */ +export function baseToolResultProjection( + event: RuntimeEvent, +): DurableToolResultProjection | undefined { + const content = event.content; + if (content?.kind !== 'function_response') return undefined; + if (content.providerExecuted === true && content.providerOutput !== undefined) return undefined; + return compatibilityToolResultProjection(content, event.sessionId); +} + +export function reduceEffectiveModelProjections( + events: readonly RuntimeEvent[], + transitions: readonly ModelProjectionTransition[], + unreadableTargets: ReadonlySet = new Set(), +): EffectiveModelProjectionReduction { + const applied: ModelProjectionTransition[] = []; + const rejected: ModelProjectionTransition[] = []; + if (transitions.length === 0 && unreadableTargets.size === 0) { + return { events: [...events], applied, rejected, unreadableTargets: new Set() }; + } + + const byTarget = new Map(); + for (const transition of transitions) { + const key = targetKey(transition.target.runtimeEventId, transition.target.part); + const group = byTarget.get(key); + if (group) group.push(transition); + else byTarget.set(key, [transition]); + } + + const nextEvents = events.map((event) => { + const key = targetKey(event.id, 'tool_result'); + const group = byTarget.get(key); + if (unreadableTargets.has(key)) { + // One of this target's records is unreadable, so its position in the + // chain is unknown and every record for it is untrustworthy. Withholding + // is the only answer that cannot show content a record removed. + if (group) for (const transition of group) rejected.push(transition); + const content = event.content; + if (content?.kind !== 'function_response') return event; + return { + ...event, + content: { + ...content, + result: legacyResultForProjection(DURABLE_TOOL_RESULT_PROJECTION_FAILURE), + modelProjection: DURABLE_TOOL_RESULT_PROJECTION_FAILURE, + }, + } satisfies RuntimeEvent; + } + if (!group) return event; + const base = baseToolResultProjection(event); + if (!base) { + for (const transition of group) rejected.push(transition); + return event; + } + const content = event.content; + if (content?.kind !== 'function_response') return event; + + let current = base; + let currentDigest = durableToolResultProjectionDigest(current); + let previousTransitionId: string | undefined; + let changed = false; + const remaining = new Set(group); + // Follow the chain instead of sorting it. Only the successor of what is + // currently in effect can apply, so the fold needs no cursor and no + // tie-break on anything the writer's clock or run decided. + for (;;) { + const next = nextInChain(remaining, previousTransitionId, currentDigest, content); + if (!next) break; + remaining.delete(next); + current = next.replacement; + currentDigest = durableToolResultProjectionDigest(current); + previousTransitionId = next.transitionId; + changed = true; + applied.push(next); + } + for (const transition of remaining) rejected.push(transition); + if (!changed) return event; + return { + ...event, + content: { + ...content, + // `result` is rewritten alongside the projection so a consumer that + // still reads the legacy field cannot resurrect the replaced body. + result: legacyResultForProjection(current), + modelProjection: current, + }, + } satisfies RuntimeEvent; + }); + + return { events: nextEvents, applied, rejected, unreadableTargets: new Set(unreadableTargets) }; +} + +/** + * The one transition that may apply next to this target. + * + * Two writers can name the same predecessor — a concurrent append that lost the + * race, or a retry of the same decision. Both are refused unless they also match + * the digest currently in effect, and among equals the smallest content-derived + * id wins, so every reader picks the same successor without consulting a clock. + */ +export function nextInChain( + remaining: Iterable, + previousTransitionId: string | undefined, + currentDigest: string, + content: { id: string; name: string }, +): ModelProjectionTransition | undefined { + let best: ModelProjectionTransition | undefined; + for (const transition of remaining) { + if ( + transition.previousTransitionId !== previousTransitionId || + transition.sourceProjectionDigest !== currentDigest || + transition.target.toolCallId !== content.id || + transition.target.toolName !== content.name + ) { + continue; + } + if (!best || transition.transitionId < best.transitionId) best = transition; + } + return best; +} + +function legacyResultForProjection(projection: DurableToolResultProjection): unknown { + const output = durableProjectionToToolResultOutput(projection); + return output.type === 'execution-denied' + ? { kind: 'text', text: output.reason ?? '' } + : output.value; +} + +function targetKey(runtimeEventId: string, part: string): string { + return `${runtimeEventId}::${part}`; +} diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 2a285ef792..1da029c273 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -100,6 +100,8 @@ import { } from './session-projection-helpers.js'; import { buildToolsForAgentDefinition } from './agent-catalog.js'; import { loadLatestHistoryCompactCheckpointFromRunLedger } from './history-compact-ledger.js'; +import { loadModelProjectionTransitionsFromRunLedger } from './model-projection-transition-ledger.js'; +import type { ModelProjectionTransition } from '@maka/core/model-projection-transition'; import { canReplaceHistoryCompactCheckpoint, type HistoryCompactCheckpoint, @@ -2273,6 +2275,8 @@ export class RuntimeKernel implements RuntimeKernelLike { | 'recordRunComposition' | 'loadHistoryCompactCheckpoint' | 'recordHistoryCompactCheckpoint' + | 'loadModelProjectionTransitions' + | 'recordModelProjectionTransition' | 'loadTurnRuntimeEvents' > { const { sessionId } = input; @@ -2305,6 +2309,20 @@ export class RuntimeKernel implements RuntimeKernelLike { checkpoint: HistoryCompactCheckpoint, turnId: string, ) => this.historyCompactCoordinator.record(sessionId, checkpoint, runFor(turnId)), + loadModelProjectionTransitions: () => + loadModelProjectionTransitionsFromRunLedger(this.deps.runStore!, sessionId), + recordModelProjectionTransition: ( + transition: ModelProjectionTransition, + turnId: string, + ) => { + const run = runFor(turnId); + if (!run) { + return Promise.reject( + new Error('No active AgentRun for model projection transition'), + ); + } + return run.recordModelProjectionTransition(transition); + }, } : {}), ...(this.deps.runtimeEventStore diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index ddb8c59ea9..fe98e096f7 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -159,6 +159,8 @@ import { readLatestContextDiagnostics, type ContextDiagnostics } from './context import type { ModelCallCommit } from '@maka/core/agent-run'; import type { ShellRunProcessManager } from './shell-run-manager.js'; import type { HistoryCompactCheckpoint } from './history-compact-checkpoint.js'; +import type { ModelProjectionTransition } from '@maka/core/model-projection-transition'; +import type { LoadedModelProjectionTransitions } from './model-projection-transition-ledger.js'; import type { AgentRunLineage, RuntimeContinuationFailpoint } from './agent-run.js'; import type { RuntimeCommitResult, RuntimeCommitSink } from './runtime-commit-sink.js'; import { @@ -680,6 +682,17 @@ export interface BackendFactoryContext { checkpoint: HistoryCompactCheckpoint, turnId: string, ) => Promise; + /** + * Session-scoped read of every committed model-projection transition (#4283). + * The reducer folds these onto the RuntimeEvent ledger, so a lossy rewrite + * survives the Turn that made it. + */ + loadModelProjectionTransitions?: () => Promise; + /** Durable append for one transition; persistence precedes any model-visible loss. */ + recordModelProjectionTransition?: ( + transition: ModelProjectionTransition, + turnId: string, + ) => Promise; /** * Durable read of the given turn's persisted RuntimeEvents from the * authoritative run ledger. The Runtime reloads this projection between diff --git a/packages/runtime/src/tool-result-archive-capability.ts b/packages/runtime/src/tool-result-archive-capability.ts index ce447f2a8e..6004c830a1 100644 --- a/packages/runtime/src/tool-result-archive-capability.ts +++ b/packages/runtime/src/tool-result-archive-capability.ts @@ -34,8 +34,7 @@ */ import { ARCHIVE_READ_TOOL_NAME, buildArchiveReadTool } from './archive-read-tool.js'; -import type { ActiveToolResultArchiveCandidate } from './active-tool-result-prune.js'; -import type { StaleToolResultArchiveCandidate } from './context-budget.js'; +import type { ArchivedToolResultReason } from './tool-result-archive.js'; import type { ToolResultArchiveReader } from './tool-result-archive.js'; import type { ToolResultArchiveResourceReader } from './tool-result-archive-resource.js'; import type { MakaTool } from './tool-runtime.js'; @@ -43,17 +42,28 @@ import type { MakaTool } from './tool-runtime.js'; export { ARCHIVE_READ_TOOL_NAME }; /** - * What the writer is handed for one pruned body. The union spans both prune - * paths — a stale prior-turn result and an active current-turn one — because - * the archive is one authority over both. + * What the writer is handed for one pruned body. + * + * One shape, not a union over the two prune paths: since both now commit the + * same durable projection transition (#4283), both address the same + * `function_response` RuntimeEvent and hand over the same serialized body, and + * a union would only preserve the shape of the authorities they replaced. */ -export type ToolResultArchiveRecorderInput = ( - | StaleToolResultArchiveCandidate - | (ActiveToolResultArchiveCandidate & { runtimeEventId: string }) -) & { +export interface ToolResultArchiveRecorderInput { sessionId: string; + runtimeEventId: string; + turnId: string; + toolCallId: string; + toolName: string; + /** The raw execution fact, for writers that name the artifact after it. */ + result?: unknown; + serializedResult: string; bodySha256: string; -}; + originalBytes: number; + originalEstimatedTokens: number; + rewriteVersion: number; + reason: ArchivedToolResultReason; +} export type ToolResultArchiveRecorder = ( input: ToolResultArchiveRecorderInput, ) => Promise<{ artifactId: string } | void> | { artifactId: string } | void; diff --git a/packages/runtime/src/tool-result-archive-transition.ts b/packages/runtime/src/tool-result-archive-transition.ts new file mode 100644 index 0000000000..f96588a4e8 --- /dev/null +++ b/packages/runtime/src/tool-result-archive-transition.ts @@ -0,0 +1,346 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * The one writer that turns a Tool Result prune decision into durable truth + * (#4283). + * + * Both prune paths — the current Turn's active prune before the next provider + * step, and the prior Turn's stale prune before compaction — come through here. + * They used to keep their replacement in a Turn-local map and a policy-carried + * ref table respectively, so each owned a private recovery contract and neither + * survived a restart. Now each records one `ModelProjectionTransition`, and the + * Session reducer is the only thing that decides what the model sees. + * + * Write order is the whole safety argument: + * + * 1. Archive the replaced body. A failure here leaves the projection untouched. + * 2. Append the transition. A failure here leaves an artifact nothing points + * at — unreachable by the reducer, so safe to reclaim once something does — + * and again leaves the projection untouched. + * 3. Only then may a caller show the replacement. + * + * There is no state in which the model has lost content the ledger cannot + * explain, and none in which a completed tool effect is repeated. + */ + +import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection'; +import { DURABLE_TOOL_RESULT_PROJECTION_VERSION } from '@maka/core/durable-tool-result-projection'; +import { + buildModelProjectionTransition, + type ModelProjectionTransition, +} from '@maka/core/model-projection-transition'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; + +import type { ActiveToolResultSupersession } from './active-tool-result-working-set.js'; +import { + estimateTokens, + finitePositive, + sha256, + turnKey, + utf8ByteLength, +} from './context-budget-helpers.js'; +import { durableProjectionToToolResultOutput } from './durable-tool-result-projection.js'; +import { baseToolResultProjection, nextInChain } from './model-projection-transition-ledger.js'; +import { + ARCHIVED_TOOL_RESULT_REWRITE_VERSION, + buildArchivedToolResultPlaceholder, + isArchivedToolResultPlaceholder, + serializeToolResultForArchive, + type ArchivedToolResultPlaceholder, + type ArchivedToolResultReason, + type StaleToolResultArchiveCandidate, + type StaleToolResultPrunePolicy, +} from './tool-result-archive.js'; + +const DEFAULT_MAX_TOOL_RESULT_ESTIMATED_TOKENS = 2048; + +export type ModelProjectionTransitionRecorder = ( + transition: ModelProjectionTransition, +) => Promise; + +/** + * What the model actually reads for one Tool Result, as bytes. + * + * Both the prune thresholds and the archived body are measured over the + * effective durable projection rather than the raw execution fact: the + * projection is what costs context, and archiving anything else would store a + * body that is not the one removed from the model's view. + */ +export function serializedToolResultProjection(projection: DurableToolResultProjection): string { + const output = durableProjectionToToolResultOutput(projection); + return serializeToolResultForArchive( + output.type === 'execution-denied' ? { kind: 'text', text: output.reason ?? '' } : output.value, + ); +} + +/** The replacement a pruned Tool Result projects to. */ +export function archivedToolResultProjection( + placeholder: ArchivedToolResultPlaceholder, +): DurableToolResultProjection { + return { + version: DURABLE_TOOL_RESULT_PROJECTION_VERSION, + kind: 'json', + value: placeholder as unknown as Record, + }; +} + +export interface ToolResultArchiveTransitionServices { + sessionId: string; + archiveToolResult: (input: { + sessionId: string; + runtimeEventId: string; + turnId: string; + toolCallId: string; + toolName: string; + result: unknown; + serializedResult: string; + bodySha256: string; + originalBytes: number; + originalEstimatedTokens: number; + rewriteVersion: typeof ARCHIVED_TOOL_RESULT_REWRITE_VERSION; + reason: ArchivedToolResultReason; + }) => Promise<{ artifactId: string } | void> | { artifactId: string } | void; + recordTransition: ModelProjectionTransitionRecorder; + /** + * Re-read the durable ledger after an append. + * + * A successful append does not make this transition the fold's answer: a + * concurrent Turn can append a rival successor to the same source, and the + * fold accepts exactly one of them. Without this seam the caller would show a + * replacement that the next read replaces with the other writer's. + */ + loadTransitions?: () => Promise<{ transitions: ModelProjectionTransition[] }>; + now: () => number; +} + +export interface ToolResultArchiveTransitionRequest { + runtimeEventId: string; + turnId: string; + toolCallId: string; + toolName: string; + /** The projection this transition is allowed to replace. */ + sourceProjection: DurableToolResultProjection; + serializedResult: string; + originalBytes: number; + originalEstimatedTokens: number; + reason: ArchivedToolResultReason; + previousTransitionId?: string; + supersession?: ActiveToolResultSupersession; + /** Raw execution fact kept only so the archive writer can name the artifact. */ + result?: unknown; +} + +export interface ToolResultArchiveTransitionOutcome { + placeholder: ArchivedToolResultPlaceholder; + transition: ModelProjectionTransition; +} + +/** + * Archive one body and commit the transition that replaces its projection. + * + * Returns `undefined` when either durable step fails: the caller then leaves + * the model-visible content exactly as it was, which is the only outcome that + * keeps "visible history is append-only" true under partial failure. + */ +export async function archiveToolResultAsTransition( + services: ToolResultArchiveTransitionServices, + request: ToolResultArchiveTransitionRequest, +): Promise { + const bodySha256 = sha256(request.serializedResult); + let archived: { artifactId: string } | void; + try { + archived = await Promise.resolve( + services.archiveToolResult({ + sessionId: services.sessionId, + runtimeEventId: request.runtimeEventId, + turnId: request.turnId, + toolCallId: request.toolCallId, + toolName: request.toolName, + result: request.result, + serializedResult: request.serializedResult, + bodySha256, + originalBytes: request.originalBytes, + originalEstimatedTokens: request.originalEstimatedTokens, + rewriteVersion: ARCHIVED_TOOL_RESULT_REWRITE_VERSION, + reason: request.reason, + }), + ); + } catch { + return undefined; + } + const artifactId = archived?.artifactId; + if (typeof artifactId !== 'string' || artifactId.trim().length === 0) return undefined; + + const placeholder = buildArchivedToolResultPlaceholder({ + artifactId, + runtimeEventId: request.runtimeEventId, + toolCallId: request.toolCallId, + toolName: request.toolName, + bodySha256, + originalEstimatedTokens: request.originalEstimatedTokens, + originalBytes: request.originalBytes, + reason: request.reason, + ...(request.supersession ? { supersession: request.supersession } : {}), + }); + + let transition: ModelProjectionTransition; + try { + transition = buildModelProjectionTransition({ + sessionId: services.sessionId, + target: { + runtimeEventId: request.runtimeEventId, + part: 'tool_result', + toolCallId: request.toolCallId, + toolName: request.toolName, + }, + sourceProjection: request.sourceProjection, + // The placeholder inside the replacement is the whole archive record: + // artifact id, body digest and original size. The transition does not + // repeat them — one fact, one place. + replacement: archivedToolResultProjection(placeholder), + ...(request.previousTransitionId + ? { previousTransitionId: request.previousTransitionId } + : {}), + now: services.now(), + }); + await services.recordTransition(transition); + const winner = await winningTransition(services, transition); + if (winner && winner.transitionId !== transition.transitionId) { + // The rival won. Show what the ledger says, not what this writer wrote; + // its own record stays durable and inert, and the body it archived is + // unreachable exactly as a refused transition's archive should be. + const replaced = winner.replacement.kind === 'json' ? winner.replacement.value : undefined; + if (!isArchivedToolResultPlaceholder(replaced)) return undefined; + return { placeholder: replaced, transition: winner }; + } + } catch { + // The archive artifact is now unreferenced: nothing in the effective + // history names it, which is what reducer-derived reachability reports. It + // is content-addressed, so a retry of the same decision reuses it rather + // than publishing a second one. No cleanup pass consumes that reachability + // yet, so such an artifact is retained until one does — it cannot break + // replay, but it is not reclaimed either (#4283). + return undefined; + } + return { placeholder, transition }; +} + +/** The transition the durable fold accepts for this target, after an append. */ +async function winningTransition( + services: ToolResultArchiveTransitionServices, + appended: ModelProjectionTransition, +): Promise { + if (!services.loadTransitions) return appended; + const { transitions } = await services.loadTransitions(); + return nextInChain(transitions, appended.previousTransitionId, appended.sourceProjectionDigest, { + id: appended.target.toolCallId, + name: appended.target.toolName, + }); +} + +/** + * Prior-Turn results large enough to archive before compaction. + * + * Collection reads events the transition reducer has already folded, so a + * result an earlier transition replaced is measured at its replacement size and + * simply falls below the threshold — there is no second "already pruned?" + * predicate to keep in step with the fold. + */ +export function collectStaleToolResultArchiveCandidates( + events: readonly RuntimeEvent[], + prunePolicy: StaleToolResultPrunePolicy | undefined, + charsPerToken: number, +): StaleToolResultArchiveCandidate[] { + if (prunePolicy?.enabled !== true) return []; + const maxResultEstimatedTokens = + finitePositive(prunePolicy.maxResultEstimatedTokens) ?? + DEFAULT_MAX_TOOL_RESULT_ESTIMATED_TOKENS; + const minRecentTurnsFull = Math.max(0, Math.floor(prunePolicy.minRecentTurnsFull ?? 1)); + const protectedTurnIds = recentTurnIds(events, minRecentTurnsFull); + const candidates: StaleToolResultArchiveCandidate[] = []; + for (const event of events) { + const content = event.content; + if ( + event.partial || + event.modelVisibility === 'hidden' || + content?.kind !== 'function_response' || + protectedTurnIds.has(turnKey(event)) + ) { + continue; + } + const sourceProjection = baseToolResultProjection(event); + if (!sourceProjection) continue; + const serializedResult = serializedToolResultProjection(sourceProjection); + const originalBytes = utf8ByteLength(serializedResult); + const originalEstimatedTokens = estimateTokens(serializedResult.length, charsPerToken); + if (originalEstimatedTokens <= maxResultEstimatedTokens) continue; + candidates.push({ + runtimeEventId: event.id, + turnId: event.turnId, + toolCallId: content.id, + toolName: content.name, + result: content.result, + sourceProjection, + serializedResult, + originalEstimatedTokens, + originalBytes, + rewriteVersion: ARCHIVED_TOOL_RESULT_REWRITE_VERSION, + reason: 'stale_tool_result_pruned_before_compact', + }); + } + return candidates; +} + +/** + * Archive artifacts the effective history still needs. + * + * Derived from the folded events, never from a parallel bookkeeping table: an + * artifact is reachable exactly when a placeholder the model can still see names + * it. An archive whose transition was refused is therefore unreachable by + * construction. + * + * This is the reachability authority a reclaiming pass must ask; no such pass + * exists yet, so nothing here is reclaimed today (#4283). Adding one is what + * makes an unreferenced archive temporary rather than retained. + */ +export function collectReachableArchiveArtifactIds(events: readonly RuntimeEvent[]): Set { + const reachable = new Set(); + for (const event of events) { + const content = event.content; + if (content?.kind !== 'function_response') continue; + if (isArchivedToolResultPlaceholder(content.result)) { + reachable.add(content.result.artifactId); + } + } + return reachable; +} + +function recentTurnIds(events: readonly RuntimeEvent[], count: number): Set { + if (count <= 0) return new Set(); + const order: string[] = []; + const seen = new Set(); + for (const event of events) { + const key = turnKey(event); + if (seen.has(key)) continue; + seen.add(key); + order.push(key); + } + return new Set(order.slice(Math.max(0, order.length - count))); +} diff --git a/packages/runtime/src/tool-result-archive.ts b/packages/runtime/src/tool-result-archive.ts index f5e3c96e4f..8290aaca5b 100644 --- a/packages/runtime/src/tool-result-archive.ts +++ b/packages/runtime/src/tool-result-archive.ts @@ -17,20 +17,13 @@ * under the License. */ -import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection'; import { createHash } from 'node:crypto'; -import { - estimateTokens, - finitePositive, - sha256, - stableJsonLength, - turnKey, - utf8ByteLength, -} from './context-budget-helpers.js'; import { buildToolResultArchiveResourceRef, TOOL_RESULT_ARCHIVE_READ_INSTRUCTIONS, } from './tool-result-archive-resource.js'; +import type { ActiveToolResultSupersession } from './active-tool-result-working-set.js'; export interface StaleToolResultPrunePolicy { enabled: boolean; @@ -38,21 +31,25 @@ export interface StaleToolResultPrunePolicy { maxResultEstimatedTokens?: number; /** Keep this many newest turns' tool results full. Defaults to 1. */ minRecentTurnsFull?: number; - /** - * Archive refs keyed by RuntimeEvent id. Rewrites only happen when a - * matching ref exists, so archive-write failure keeps original content. - */ - archiveRefs?: readonly ToolResultArchiveRef[] | Readonly>; } -export type ArchivedToolResultReason = 'stale_tool_result_pruned_before_compact'; +/** + * Why a model-visible Tool Result was replaced by its archive placeholder. + * + * One placeholder kind covers both prune paths. They differ only in when the + * decision is taken — before the next step of the current Turn, or before a + * prior Turn is compacted — and both now record the same durable transition, so + * a second placeholder protocol would only be a second way to spell the same + * fact (#4283). + */ +export type ArchivedToolResultReason = + | 'stale_tool_result_pruned_before_compact' + | 'active_current_turn_tool_result_pruned_before_next_step'; export const ARCHIVED_TOOL_RESULT_PLACEHOLDER_KIND = 'maka.archived_tool_result'; export const ARCHIVED_TOOL_RESULT_REWRITE_VERSION = 1; -const DEFAULT_MAX_TOOL_RESULT_ESTIMATED_TOKENS = 2048; - export interface ArchivedToolResultPlaceholder { kind: typeof ARCHIVED_TOOL_RESULT_PLACEHOLDER_KIND; rewriteVersion: typeof ARCHIVED_TOOL_RESULT_REWRITE_VERSION; @@ -68,6 +65,8 @@ export interface ArchivedToolResultPlaceholder { originalEstimatedTokens: number; originalBytes: number; reason: ArchivedToolResultReason; + /** Why a newer completed step made this provider-visible result redundant. */ + supersession?: ActiveToolResultSupersession; } export interface StaleToolResultArchiveCandidate { @@ -76,23 +75,13 @@ export interface StaleToolResultArchiveCandidate { toolCallId: string; toolName: string; result: unknown; + /** The exact projection the transition is allowed to replace. */ + sourceProjection: DurableToolResultProjection; serializedResult: string; originalEstimatedTokens: number; originalBytes: number; rewriteVersion: typeof ARCHIVED_TOOL_RESULT_REWRITE_VERSION; - reason: ArchivedToolResultReason; -} - -export interface ToolResultArchiveRef { - runtimeEventId: string; - toolCallId: string; - toolName: string; - artifactId: string; - bodySha256: string; - originalEstimatedTokens: number; - originalBytes: number; - rewriteVersion: typeof ARCHIVED_TOOL_RESULT_REWRITE_VERSION; - reason: ArchivedToolResultReason; + reason: 'stale_tool_result_pruned_before_compact'; } export type ToolResultArchiveReadFailureReason = @@ -151,157 +140,6 @@ export function deserializeToolResultArchive(serialized: string): unknown { } } -export function pruneStaleToolResultsBeforeCompact( - events: readonly RuntimeEvent[], - prunePolicy: StaleToolResultPrunePolicy | undefined, - charsPerToken: number, -): { - events: RuntimeEvent[]; - prunedToolResults: number; - archiveWriteFailures: number; - estimatedTokensBefore: number; - estimatedTokensAfter: number; -} { - if (prunePolicy?.enabled !== true) { - return { - events: [...events], - prunedToolResults: 0, - archiveWriteFailures: 0, - estimatedTokensBefore: 0, - estimatedTokensAfter: 0, - }; - } - - const maxResultEstimatedTokens = - finitePositive(prunePolicy.maxResultEstimatedTokens) ?? - DEFAULT_MAX_TOOL_RESULT_ESTIMATED_TOKENS; - const minRecentTurnsFull = Math.max(0, Math.floor(prunePolicy.minRecentTurnsFull ?? 1)); - const protectedTurnIds = recentTurnIds(events, minRecentTurnsFull); - const archiveRefs = normalizeArchiveRefs(prunePolicy.archiveRefs); - - let prunedToolResults = 0; - let archiveWriteFailures = 0; - let estimatedTokensBefore = 0; - let estimatedTokensAfter = 0; - const prunedEvents = events.map((event) => { - const content = event.content; - if ( - event.partial || - event.modelVisibility === 'hidden' || - content?.kind !== 'function_response' || - (content.providerExecuted === true && content.providerOutput !== undefined) || - protectedTurnIds.has(turnKey(event)) - ) { - return event; - } - - if (isArchivedToolResultPlaceholder(content.result)) return event; - - const serializedResult = serializeToolResultForArchive(content.result); - const resultBytes = utf8ByteLength(serializedResult); - const resultEstimatedTokens = estimateTokens(serializedResult.length, charsPerToken); - if (resultEstimatedTokens <= maxResultEstimatedTokens) return event; - - const archiveRef = archiveRefs.get(event.id); - if ( - !archiveRef || - !archiveRefMatches(archiveRef, { - runtimeEventId: event.id, - toolCallId: content.id, - toolName: content.name, - bodySha256: sha256(serializedResult), - originalBytes: resultBytes, - originalEstimatedTokens: resultEstimatedTokens, - }) - ) { - archiveWriteFailures += 1; - return event; - } - - const placeholder: ArchivedToolResultPlaceholder = { - kind: ARCHIVED_TOOL_RESULT_PLACEHOLDER_KIND, - rewriteVersion: ARCHIVED_TOOL_RESULT_REWRITE_VERSION, - artifactId: archiveRef.artifactId, - resourceRef: buildToolResultArchiveResourceRef({ - artifactId: archiveRef.artifactId, - bodySha256: archiveRef.bodySha256, - originalBytes: resultBytes, - }), - readInstructions: TOOL_RESULT_ARCHIVE_READ_INSTRUCTIONS, - runtimeEventId: event.id, - toolCallId: content.id, - toolName: content.name, - bodySha256: archiveRef.bodySha256, - originalEstimatedTokens: resultEstimatedTokens, - originalBytes: resultBytes, - reason: 'stale_tool_result_pruned_before_compact', - }; - const placeholderEstimatedTokens = estimateTokens(stableJsonLength(placeholder), charsPerToken); - prunedToolResults += 1; - estimatedTokensBefore += resultEstimatedTokens; - estimatedTokensAfter += placeholderEstimatedTokens; - return { - ...event, - content: { - ...content, - result: placeholder, - }, - }; - }); - - return { - events: prunedEvents, - prunedToolResults, - archiveWriteFailures, - estimatedTokensBefore, - estimatedTokensAfter, - }; -} - -export function collectStaleToolResultArchiveCandidates( - events: readonly RuntimeEvent[], - prunePolicy: StaleToolResultPrunePolicy | undefined, - charsPerToken: number, -): StaleToolResultArchiveCandidate[] { - if (prunePolicy?.enabled !== true) return []; - const maxResultEstimatedTokens = - finitePositive(prunePolicy.maxResultEstimatedTokens) ?? - DEFAULT_MAX_TOOL_RESULT_ESTIMATED_TOKENS; - const minRecentTurnsFull = Math.max(0, Math.floor(prunePolicy.minRecentTurnsFull ?? 1)); - const protectedTurnIds = recentTurnIds(events, minRecentTurnsFull); - const candidates: StaleToolResultArchiveCandidate[] = []; - for (const event of events) { - const content = event.content; - if ( - event.partial || - event.modelVisibility === 'hidden' || - content?.kind !== 'function_response' || - (content.providerExecuted === true && content.providerOutput !== undefined) || - protectedTurnIds.has(turnKey(event)) || - isArchivedToolResultPlaceholder(content.result) - ) { - continue; - } - const serializedResult = serializeToolResultForArchive(content.result); - const originalBytes = utf8ByteLength(serializedResult); - const originalEstimatedTokens = estimateTokens(serializedResult.length, charsPerToken); - if (originalEstimatedTokens <= maxResultEstimatedTokens) continue; - candidates.push({ - runtimeEventId: event.id, - turnId: event.turnId, - toolCallId: content.id, - toolName: content.name, - result: content.result, - serializedResult, - originalEstimatedTokens, - originalBytes, - rewriteVersion: ARCHIVED_TOOL_RESULT_REWRITE_VERSION, - reason: 'stale_tool_result_pruned_before_compact', - }); - } - return candidates; -} - export function serializeToolResultForArchive(result: unknown): string { if (result === undefined) return 'undefined'; try { @@ -335,7 +173,27 @@ export function isArchivedToolResultPlaceholder( typeof candidate.originalBytes === 'number' && Number.isFinite(candidate.originalBytes) && candidate.originalBytes > 0 && - candidate.reason === 'stale_tool_result_pruned_before_compact' + (candidate.reason === 'stale_tool_result_pruned_before_compact' || + candidate.reason === 'active_current_turn_tool_result_pruned_before_next_step') && + isValidSupersession(candidate.supersession) + ); +} + +function isValidSupersession(value: unknown): boolean { + if (value === undefined) return true; + if (!value || typeof value !== 'object') return false; + const candidate = value as Partial; + return ( + (candidate.reason === 'exact_duplicate' || + candidate.reason === 'newer_read_covers_range' || + candidate.reason === 'newer_snapshot' || + candidate.reason === 'failure_resolved') && + typeof candidate.supersededByToolCallId === 'string' && + candidate.supersededByToolCallId.length > 0 && + (candidate.reason === 'failure_resolved' + ? typeof candidate.failureBodySha256 === 'string' && + /^[a-f0-9]{64}$/.test(candidate.failureBodySha256) + : candidate.failureBodySha256 === undefined) ); } @@ -353,57 +211,34 @@ export function withToolResultArchiveResourceRef(value: unknown): unknown { } satisfies ArchivedToolResultPlaceholder; } -function normalizeArchiveRefs( - refs: StaleToolResultPrunePolicy['archiveRefs'], -): Map { - const map = new Map(); - if (!refs) return map; - if (Array.isArray(refs)) { - for (const ref of refs) map.set(ref.runtimeEventId, ref); - return map; - } - for (const [runtimeEventId, ref] of Object.entries(refs)) { - map.set(runtimeEventId, ref); - } - return map; -} - -function archiveRefMatches( - ref: ToolResultArchiveRef, - candidate: { - runtimeEventId: string; - toolCallId: string; - toolName: string; - bodySha256: string; - originalEstimatedTokens: number; - originalBytes: number; - }, -): boolean { - return ( - ref.runtimeEventId === candidate.runtimeEventId && - ref.toolCallId === candidate.toolCallId && - ref.toolName === candidate.toolName && - ref.rewriteVersion === ARCHIVED_TOOL_RESULT_REWRITE_VERSION && - ref.reason === 'stale_tool_result_pruned_before_compact' && - typeof ref.artifactId === 'string' && - ref.artifactId.length > 0 && - typeof ref.bodySha256 === 'string' && - ref.bodySha256.length > 0 && - ref.bodySha256 === candidate.bodySha256 && - ref.originalEstimatedTokens === candidate.originalEstimatedTokens && - ref.originalBytes === candidate.originalBytes - ); -} - -function recentTurnIds(events: readonly RuntimeEvent[], count: number): Set { - if (count <= 0) return new Set(); - const order: string[] = []; - const seen = new Set(); - for (const event of events) { - const key = turnKey(event); - if (seen.has(key)) continue; - seen.add(key); - order.push(key); - } - return new Set(order.slice(Math.max(0, order.length - count))); +export function buildArchivedToolResultPlaceholder(input: { + artifactId: string; + runtimeEventId: string; + toolCallId: string; + toolName: string; + bodySha256: string; + originalEstimatedTokens: number; + originalBytes: number; + reason: ArchivedToolResultReason; + supersession?: ActiveToolResultSupersession; +}): ArchivedToolResultPlaceholder { + return { + kind: ARCHIVED_TOOL_RESULT_PLACEHOLDER_KIND, + rewriteVersion: ARCHIVED_TOOL_RESULT_REWRITE_VERSION, + artifactId: input.artifactId, + resourceRef: buildToolResultArchiveResourceRef({ + artifactId: input.artifactId, + bodySha256: input.bodySha256, + originalBytes: input.originalBytes, + }), + readInstructions: TOOL_RESULT_ARCHIVE_READ_INSTRUCTIONS, + runtimeEventId: input.runtimeEventId, + toolCallId: input.toolCallId, + toolName: input.toolName, + bodySha256: input.bodySha256, + originalEstimatedTokens: input.originalEstimatedTokens, + originalBytes: input.originalBytes, + reason: input.reason, + ...(input.supersession ? { supersession: input.supersession } : {}), + }; } diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 66d2797313..5fe9493be9 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -361,6 +361,8 @@ export interface ToolRuntimeInput { }) => { ref: Extract; persist(): Promise; + /** Reclaim this publication when the projection is rejected (#4283). */ + retract?(): Promise; }; spawnChildSession?: (input: { parentRunId: string; diff --git a/packages/storage/src/__tests__/artifact-attachments.test.ts b/packages/storage/src/__tests__/artifact-attachments.test.ts index ddc63dd389..d62ce889ca 100644 --- a/packages/storage/src/__tests__/artifact-attachments.test.ts +++ b/packages/storage/src/__tests__/artifact-attachments.test.ts @@ -294,6 +294,51 @@ describe('artifact attachment authority', () => { }); }); }); + test('retraction reclaims only what the retracting plan published', async () => { + await withStore(async (store) => { + const input = { + sessionId: 'session-1', + turnId: 'turn-1', + name: 'Tool Result image', + bytes: png.slice(), + mimeType: 'image/png', + }; + const deleted: string[] = []; + // The id is derived from the bytes, so a second projection for the same + // image in the same Turn gets the first one's record back. Retracting the + // second must not delete the artifact the first one committed. + const owned = { + create: (createInput: Parameters[0]) => store.create(createInput), + createOwned: async (createInput: Parameters[0]) => { + const existing = createInput.id + ? await store.getInSession(createInput.sessionId, createInput.id) + : undefined; + const record = await store.create(createInput); + return { record, publishedByThisCall: !existing?.record }; + }, + }; + const planner = createReadImageSnapshotPlanner(owned, async (_sessionId, artifactId) => { + deleted.push(artifactId); + await store.delete(artifactId); + }); + + const first = planner(input); + await first.persist(); + const second = planner(input); + await second.persist(); + assert.equal(second.ref.relativePath, first.ref.relativePath); + + await second.retract(); + + assert.deepEqual(deleted, []); + assert.equal((await store.readBinary(first.ref.relativePath)).ok, true); + + await first.retract(); + + assert.deepEqual(deleted, [first.ref.relativePath]); + assert.equal((await store.readBinary(first.ref.relativePath)).ok, false); + }); + }); }); function sessionFileRef(relativePath: string, sessionId = 'session-1'): StorageRef { diff --git a/packages/storage/src/artifact-attachments.ts b/packages/storage/src/artifact-attachments.ts index d281fe8206..07d06c66c2 100644 --- a/packages/storage/src/artifact-attachments.ts +++ b/packages/storage/src/artifact-attachments.ts @@ -137,9 +137,36 @@ interface ReadImageSnapshotInput { export interface ReadImageSnapshotPlan { ref: Extract; persist(): Promise; + /** + * Undo a publication whose projection was never admitted (#4283). + * + * A Tool Result projection carrying several images publishes them one at a + * time; if a later one fails, the projection is rejected and the earlier + * publications become artifacts no durable record will ever name. Retracting + * them is best-effort by design: failing to retract only delays reclamation, + * while failing to reject would put an unreferenced artifact in front of the + * user as if the tool had produced it. + */ + retract(): Promise; } -export function createReadImageSnapshotPlanner(artifactStore: Pick) { +export interface ReadImageSnapshotArtifactStore extends Pick { + /** Create with a receipt saying whether this call published the artifact. */ + createOwned?: (input: Parameters[0]) => Promise<{ + record: Awaited>; + publishedByThisCall: boolean; + }>; +} + +export function createReadImageSnapshotPlanner( + artifactStore: ReadImageSnapshotArtifactStore, + /** + * Narrow reclaim for a `tool_result_projection` artifact this planner + * published. Optional so callers that cannot reclaim still get the planner; + * without it `retract()` is a no-op and reclamation waits for reachability. + */ + retractPublished?: (sessionId: string, artifactId: string) => Promise, +) { return (input: ReadImageSnapshotInput): ReadImageSnapshotPlan => { if (input.bytes.byteLength > MAX_READ_IMAGE_BYTES) { throw new Error(READ_IMAGE_TOO_LARGE_MESSAGE); @@ -172,6 +199,11 @@ export function createReadImageSnapshotPlanner(artifactStore: Pick | undefined; + // Ownership, not success. The id is derived from the bytes, so a create for + // an image an earlier projection already published succeeds by replaying + // that record — and reclaiming it would delete content that is still in + // use. Only a create that actually published may be retracted. + let owned = false; const ref = Object.freeze({ kind: 'session_file' as const, sessionId: accepted.sessionId, @@ -180,22 +212,35 @@ export function createReadImageSnapshotPlanner(artifactStore: Pick { - if (artifact.id !== id) throw new Error('Artifact publication changed its planned id'); - }); + const input = { + id, + sessionId: accepted.sessionId, + turnId: accepted.turnId, + name: accepted.name, + kind: 'image' as const, + content: accepted.bytes, + mimeType: accepted.mimeType, + source: 'tool_result_projection' as const, + }; + publication ??= ( + artifactStore.createOwned + ? artifactStore.createOwned(input) + : artifactStore.create(input).then((record) => ({ + record, + publishedByThisCall: false, + })) + ).then(({ record, publishedByThisCall }) => { + if (record.id !== id) throw new Error('Artifact publication changed its planned id'); + owned = publishedByThisCall; + }); return publication; }, + async retract() { + if (!owned || !retractPublished) return; + owned = false; + publication = undefined; + await retractPublished(accepted.sessionId, id).catch(() => undefined); + }, }); }; } diff --git a/packages/storage/src/artifact-stores.ts b/packages/storage/src/artifact-stores.ts index 9fbfa807da..1a7a46f3a4 100644 --- a/packages/storage/src/artifact-stores.ts +++ b/packages/storage/src/artifact-stores.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { ArtifactRecord } from '@maka/core/artifacts'; +import type { ArtifactRecord, ArtifactSource } from '@maka/core/artifacts'; import { createSqliteArtifactStoreWriteAuthority, type ArtifactAuthorityStore, @@ -59,7 +59,31 @@ export interface InteractiveArtifactStoreWriter extends DurableArtifactAttachmen readonly [writerBrand]: true; recover(): Promise; create(input: CreateArtifactInput): Promise; - deleteOwnedDeepResearchArtifactInSession(sessionId: string, artifactId: string): Promise; + /** + * Create, reporting whether THIS call is the one that published the artifact. + * + * `create` is idempotent on a content-derived id: a second caller for the same + * bytes gets the existing record back. A caller that may later reclaim what it + * published cannot infer ownership from that success — it would reclaim an + * artifact an earlier, already-committed projection still references. The + * probe and the create share one write lease, so the receipt is exact. + */ + createOwned( + input: CreateArtifactInput, + ): Promise<{ record: ArtifactRecord; publishedByThisCall: boolean }>; + /** + * Narrow system delete for one Session-owned artifact of a declared source. + * + * Not a user delete: the sources this serves are `userDeletable: false` + * precisely because durable replay may depend on them. The caller must name + * the source it believes it owns, and a mismatch throws — so a caller that is + * wrong about what it is reclaiming reclaims nothing. + */ + deleteOwnedArtifactInSession( + sessionId: string, + artifactId: string, + source: ArtifactSource, + ): Promise; copyConversationArtifacts( input: ConversationArtifactCopyInput, ): Promise; @@ -142,10 +166,21 @@ function createWriterFacade( const acceptedInput = snapshotCreateInput(input); return run(() => store.create(acceptedInput)); }, - deleteOwnedDeepResearchArtifactInSession: (sessionId, artifactId) => + createOwned: (input) => { + const acceptedInput = snapshotCreateInput(input); + return run(async () => { + const plannedId = acceptedInput.id; + const existing = plannedId + ? await store.getInSession(acceptedInput.sessionId, plannedId) + : undefined; + const record = await store.create(acceptedInput); + return { record, publishedByThisCall: !existing?.record }; + }); + }, + deleteOwnedArtifactInSession: (sessionId, artifactId, source) => run(async () => { const entry = await store.getInSession(sessionId, artifactId); - if (!entry.record || entry.record.source !== 'deep_research') { + if (!entry.record || entry.record.source !== source) { throw new Error('Artifact does not belong to the expected Session authority'); } await store.delete(artifactId);