From 60a78a4e6e72a31a43287cd05a26f2b61e3b9337 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 19:01:15 +0800 Subject: [PATCH 1/9] feat(core): add the model projection transition protocol A Tool Result body that has been archived must stop being model-visible, and until now each prune path invented its own way to say so: the active prune kept a Turn-local placeholder map, the stale prune carried a ref table on the compaction policy. Neither is durable, so neither survives a restart, a concurrent Turn or a copy, and a reader had two places to look before it could say what the model actually sees. Add one closed, versioned record that says it once. A transition names the target RuntimeEvent and projection part, the source projection digest it is allowed to replace, the replacement projection and optional Session-owned archive reference, and the predecessor and high-water identity a reducer needs to fold a sparse set deterministically. Three properties come from the shape rather than from a writer's care: - The record id is derived from its content, so an append duplicated by a concurrent writer is the same record, not a second one. - The source digest binds the record to exactly one prior projection, so a writer that decided against stale state is permanently inert instead of racing. - The archive reference is optional, because "replace this projection" and "replace it with something the Session stores" are different facts and only the first is required. This is deliberately not a generalization of HistoryCompactCheckpoint, which replaces a contiguous prefix. A transition is sparse and per-event, and conflating the two would make the checkpoint a sparse override store. Refs #4283 Generated-by: Claude Code --- packages/core/package.json | 1 + .../model-projection-transition.test.ts | 138 +++++++++ packages/core/src/agent-run.ts | 1 + packages/core/src/events.ts | 8 +- .../core/src/model-projection-transition.ts | 290 ++++++++++++++++++ .../core/src/tool-result-record-schema.ts | 3 +- 6 files changed, 439 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/__tests__/model-projection-transition.test.ts create mode 100644 packages/core/src/model-projection-transition.ts diff --git a/packages/core/package.json b/packages/core/package.json index b9752a2fbc..84d593192a 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..b687cbf8b7 --- /dev/null +++ b/packages/core/src/__tests__/model-projection-transition.test.ts @@ -0,0 +1,138 @@ +/* + * 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, + MODEL_PROJECTION_TRANSITION_HIGH_WATER_NAME, + type ModelProjectionTransition, +} 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, + archive: { + artifactId: 'artifact-1', + bodySha256: 'a'.repeat(64), + originalBytes: 4096, + originalEstimatedTokens: 1024, + }, + reason: 'stale_tool_result_archived', + highWaterSeq: 7, + 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.highWaterName, MODEL_PROJECTION_TRANSITION_HIGH_WATER_NAME); + 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); + assert.notEqual(build().transitionId, build({ highWaterSeq: 8 }).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, an unknown reason, and an unrepresentable replacement', () => { + const transition = build(); + assert.throws(() => + decodeModelProjectionTransition({ ...transition, extra: true }, 'session-1'), + ); + assert.throws(() => + decodeModelProjectionTransition({ ...transition, reason: 'invented' }, 'session-1'), + ); + assert.throws(() => + decodeModelProjectionTransition( + { ...transition, replacement: { version: 1, kind: 'text' } }, + 'session-1', + ), + ); + }); + + test('keeps the archive optional but well formed when present', () => { + const withoutArchive = build({ archive: undefined }); + assert.equal(withoutArchive.archive, undefined); + const transition: ModelProjectionTransition = build(); + assert.throws(() => + decodeModelProjectionTransition( + { ...transition, archive: { ...transition.archive!, bodySha256: 'not-a-digest' } }, + 'session-1', + ), + ); + assert.throws(() => + decodeModelProjectionTransition( + { ...transition, archive: { ...transition.archive!, originalBytes: 0 } }, + 'session-1', + ), + ); + }); +}); diff --git a/packages/core/src/agent-run.ts b/packages/core/src/agent-run.ts index 5c3816d57a..76daed8941 100644 --- a/packages/core/src/agent-run.ts +++ b/packages/core/src/agent-run.ts @@ -420,6 +420,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..e31ccfd422 --- /dev/null +++ b/packages/core/src/model-projection-transition.ts @@ -0,0 +1,290 @@ +/* + * 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 + * deliberately NOT a generalization of `HistoryCompactCheckpoint`: that + * checkpoint replaces one validated CONTIGUOUS prefix, and keeps that meaning. + * A transition is SPARSE — it names one projection part of one RuntimeEvent — + * and the two have different coverage, concurrency, copy and recovery algebra. + * + * 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` / `archive` — what the model sees instead, and where the + * replaced body still lives when it is recoverable at all; + * - `previousTransitionId` + `highWaterSeq` — predecessor and cursor identity, + * so ledger readers in any order converge on the same effective history. + */ + +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'; + +/** Reduction cursor name, mirroring the checkpoint protocol's high-water pair. */ +export const MODEL_PROJECTION_TRANSITION_HIGH_WATER_NAME = 'model-projection-transition-high-water'; + +export const MODEL_PROJECTION_TRANSITION_REASONS = [ + /** Current-turn result archived before the next provider step. */ + 'active_tool_result_archived', + /** Prior-turn result archived before whole-turn compaction. */ + 'stale_tool_result_archived', +] as const; + +export type ModelProjectionTransitionReason = (typeof MODEL_PROJECTION_TRANSITION_REASONS)[number]; + +/** + * 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; +} + +/** + * Session-owned archive of the replaced body. + * + * Optional: a transition that removes content irrecoverably (an image a + * provider refused to accept) is still a valid transition. Present here, it is + * both the model's way back to the content and the reachability root that keeps + * the artifact from being reclaimed. + */ +export interface ModelProjectionTransitionArchive { + artifactId: string; + /** Lowercase hex sha256 of the archived serialized body. */ + bodySha256: string; + originalBytes: number; + originalEstimatedTokens: number; +} + +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; + archive?: ModelProjectionTransitionArchive; + reason: ModelProjectionTransitionReason; + /** The transition this one supersedes for the same target, if any. */ + previousTransitionId?: string; + highWaterName: string; + highWaterSeq: number; +} + +const TRANSITION_SHAPE = defineObjectShape()( + [ + 'kind', + 'version', + 'transitionId', + 'sessionId', + 'createdAt', + 'target', + 'sourceProjectionDigest', + 'replacement', + 'reason', + 'highWaterName', + 'highWaterSeq', + ], + ['archive', 'previousTransitionId'], +); + +const TARGET_SHAPE = defineObjectShape()( + ['runtimeEventId', 'part', 'toolCallId', 'toolName'], + [], +); + +const ARCHIVE_SHAPE = defineObjectShape()( + ['artifactId', 'bodySha256', 'originalBytes', 'originalEstimatedTokens'], + [], +); + +const REASONS: ReadonlySet = new Set(MODEL_PROJECTION_TRANSITION_REASONS); + +/** + * 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; + archive?: ModelProjectionTransitionArchive; + reason: ModelProjectionTransitionReason; + previousTransitionId?: string; + highWaterSeq: number; + now: number; +} + +/** + * Build one transition with a content-derived id. + * + * The id is a digest of everything the record asserts, 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.archive ? { archive: input.archive } : {}), + reason: input.reason, + ...(input.previousTransitionId ? { previousTransitionId: input.previousTransitionId } : {}), + highWaterName: MODEL_PROJECTION_TRANSITION_HIGH_WATER_NAME, + highWaterSeq: input.highWaterSeq, + }; + 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) || + typeof value.reason !== 'string' || + !REASONS.has(value.reason) || + !nonEmptyString(value.highWaterName) || + !isFiniteNumber(value.highWaterSeq) || + (value.previousTransitionId !== undefined && !nonEmptyString(value.previousTransitionId)) || + !isTransitionTarget(value.target) || + (value.archive !== undefined && !isTransitionArchive(value.archive)) + ) { + 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 isTransitionArchive(value: unknown): value is ModelProjectionTransitionArchive { + return ( + isRecord(value) && + hasExactShape(value, ARCHIVE_SHAPE) && + nonEmptyString(value.artifactId) && + typeof value.bodySha256 === 'string' && + /^[a-f0-9]{64}$/.test(value.bodySha256) && + isFiniteNumber(value.originalBytes) && + value.originalBytes > 0 && + isFiniteNumber(value.originalEstimatedTokens) && + value.originalEstimatedTokens > 0 + ); +} + +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 ( From b6a680cece7eb59e34d5ffcdc2170ee6ef6895b4 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 19:01:26 +0800 Subject: [PATCH 2/9] fix(runtime): publish Tool Result projection artifacts atomically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tool result with several inline images publishes one artifact per image before the codec decides whether the finished projection is admissible. If a later image fails validation or its write fails, the projection is refused — but the images already written stay in the Session, referenced by nothing that will ever be replayed. Reclamation has no authority to name them, so they are permanently unreachable and permanently retained. Make publication all-or-nothing. The planner now exposes a retraction for a publication that turns out not to be admitted, and the codec retracts every artifact it published before returning the failure sentinel. Retraction is best effort: a failed retraction only delays reclamation, whereas admitting a partly published projection would leave durable state no reader can explain. The retraction path needs a system delete for one Session-owned artifact, which already existed for Deep Research alone. Generalize it to deleteOwnedArtifactInSession, which takes the source the caller believes it owns and throws on a mismatch — one seam with a declared authority instead of one narrow seam per subsystem. Refs #4283 Generated-by: Claude Code --- .../src/server/deep-research-coordinator.ts | 2 +- .../src/server/execution-model-composition.ts | 8 ++- .../durable-tool-result-projection.test.ts | 69 +++++++++++++++++++ .../src/durable-tool-result-projection.ts | 10 +++ packages/storage/src/artifact-attachments.ts | 29 +++++++- packages/storage/src/artifact-stores.ts | 20 ++++-- 6 files changed, 130 insertions(+), 8 deletions(-) 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 3408e5cf50..a8104a25c5 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -91,7 +91,7 @@ type HostExecutionRuntimePolicyAuthority = { type HostExecutionArtifactAuthority = Pick< InteractiveArtifactStoreWriter, - 'create' | 'readDurableAttachmentBinary' + 'create' | 'readDurableAttachmentBinary' | 'deleteOwnedArtifactInSession' >; type HostExecutionUsageAuthority = { @@ -333,7 +333,11 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom ); } : 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( 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/durable-tool-result-projection.ts b/packages/runtime/src/durable-tool-result-projection.ts index f81e4d5f82..bcd9fd5a11 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/storage/src/artifact-attachments.ts b/packages/storage/src/artifact-attachments.ts index d281fe8206..f55e7fb9e6 100644 --- a/packages/storage/src/artifact-attachments.ts +++ b/packages/storage/src/artifact-attachments.ts @@ -137,9 +137,28 @@ 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 function createReadImageSnapshotPlanner( + artifactStore: Pick, + /** + * 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 +191,7 @@ export function createReadImageSnapshotPlanner(artifactStore: Pick | undefined; + let published = false; const ref = Object.freeze({ kind: 'session_file' as const, sessionId: accepted.sessionId, @@ -193,9 +213,16 @@ export function createReadImageSnapshotPlanner(artifactStore: Pick { if (artifact.id !== id) throw new Error('Artifact publication changed its planned id'); + published = true; }); return publication; }, + async retract() { + if (!published || !retractPublished) return; + published = 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..35b4ef2444 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,19 @@ export interface InteractiveArtifactStoreWriter extends DurableArtifactAttachmen readonly [writerBrand]: true; recover(): Promise; create(input: CreateArtifactInput): Promise; - deleteOwnedDeepResearchArtifactInSession(sessionId: string, artifactId: string): Promise; + /** + * 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 +154,10 @@ function createWriterFacade( const acceptedInput = snapshotCreateInput(input); return run(() => store.create(acceptedInput)); }, - deleteOwnedDeepResearchArtifactInSession: (sessionId, artifactId) => + 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); From 23d8c4324e5a7cbaea9792e76a24634716321dce Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 19:01:52 +0800 Subject: [PATCH 3/9] refactor(runtime): unify Tool Result archiving on projection transitions Both prune paths removed content from the model's view without recording that they had. The active prune kept its replacement in a per-Turn map that a restart discarded, so a continuation could show the full body again after the archive had already been written. The stale prune carried archive refs on the compaction policy and re-derived them on every replay, so what the model saw depended on which policy the reader happened to reconstruct. Two private recovery contracts, neither of them durable, and no single answer to "what does the model see for this Tool Result". Give the Session one answer. A reducer folds the transition ledger onto the RuntimeEvents and produces the effective history that budgeting, replay and compaction all read, and one writer commits every archive decision: - Archive the body, then append the transition, and only then may a caller show the replacement. A failure at either step leaves the model-visible content exactly as it was, so visible history is never lossy without a durable record explaining it. - The fold applies a transition only when its source digest still matches and its predecessor is the last applied one, so a writer holding stale state is refused rather than racing, and two readers reduce identically regardless of append order. - The replaced projection and the legacy result field are rewritten together, so a consumer still reading the older field cannot resurrect a body the model was supposed to have lost. - Reachability is derived from the reduction: an artifact is live exactly when an applied transition or a surviving placeholder names it. Nothing keeps a parallel table that could disagree. The reduction runs once, where prior-Turn context is assembled, so there is no second path to keep in step. Because the fold is the only prune memory, the migrated structures are deleted here rather than left behind: the per-Turn placeholder map, the archive-ref policy table and its replay policy path, the second archived-placeholder kind, and the stale prune's own event rewriter. The active prune now requires a durable recorder and a Turn ledger to address the target; without them it does nothing, which is the correct consequence of "a lossy change must be durable first". Refs #4283 Generated-by: Claude Code --- .../src/server/execution-model-composition.ts | 2 + .../active-tool-result-prune.test.ts | 112 +++++- .../src/__tests__/ai-sdk-backend.test.ts | 119 +----- .../src/__tests__/context-budget.test.ts | 18 - .../execution-boundary-test-helpers.ts | 10 + ...model-projection-transition-ledger.test.ts | 372 ++++++++++++++++++ .../runtime/src/active-tool-result-prune.ts | 311 ++++++--------- packages/runtime/src/agent-run.ts | 37 ++ packages/runtime/src/ai-sdk-backend.ts | 13 +- .../runtime/src/ai-sdk-compaction-contract.ts | 15 +- packages/runtime/src/ai-sdk-compaction.ts | 235 +++++++---- packages/runtime/src/context-budget.ts | 42 +- .../src/model-projection-transition-ledger.ts | 230 +++++++++++ packages/runtime/src/runtime-kernel.ts | 18 + packages/runtime/src/session-manager.ts | 12 + .../src/tool-result-archive-capability.ts | 30 +- .../src/tool-result-archive-transition.ts | 324 +++++++++++++++ packages/runtime/src/tool-result-archive.ts | 305 ++++---------- packages/runtime/src/tool-runtime.ts | 2 + 19 files changed, 1521 insertions(+), 686 deletions(-) create mode 100644 packages/runtime/src/__tests__/model-projection-transition-ledger.test.ts create mode 100644 packages/runtime/src/model-projection-transition-ledger.ts create mode 100644 packages/runtime/src/tool-result-archive-transition.ts diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index a8104a25c5..f67bfc5905 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -432,6 +432,8 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom 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/src/__tests__/active-tool-result-prune.test.ts b/packages/runtime/src/__tests__/active-tool-result-prune.test.ts index 2644a84ce6..13d8ec851a 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 () => { @@ -111,7 +118,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/); @@ -136,7 +143,7 @@ describe('active current-turn tool-result pruning', () => { 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/); + assert.doesNotMatch(JSON.stringify(rewritten.messages), /maka\.archived_tool_result/); }); test('archiveRequired false still keeps original when no archive artifact is written', async () => { @@ -158,7 +165,7 @@ describe('active current-turn tool-result pruning', () => { 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/); + assert.doesNotMatch(JSON.stringify(rewritten.messages), /maka\.archived_tool_result/); }); test('empty archive artifact id keeps the original tool result', async () => { @@ -176,7 +183,7 @@ describe('active current-turn tool-result pruning', () => { 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/); + assert.doesNotMatch(JSON.stringify(rewritten.messages), /maka\.archived_tool_result/); }); test('blank archive artifact id keeps the original tool result', async () => { @@ -194,7 +201,7 @@ describe('active current-turn tool-result pruning', () => { 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/); + assert.doesNotMatch(JSON.stringify(rewritten.messages), /maka\.archived_tool_result/); }); test('empty-artifact placeholders are not treated as idempotent', async () => { @@ -756,6 +763,93 @@ 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; + archivedPlaceholders?: unknown; + committed?: ActiveToolResultPruneInput['committed']; + }, +): Promise { + const { archiveToolResult, recordTransition, archivedPlaceholders: _legacy, ...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), + }, + ...(input.committed ? { committed: input.committed } : {}), + }); +} + +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 +898,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 50a956ba7b..232a6bab78 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'; @@ -4463,6 +4464,7 @@ describe('AiSdkBackend model history', () => { bodySha256: string; }> = []; const oldResult = { body: 'x'.repeat(500) }; + const transitions: ModelProjectionTransition[] = []; const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), @@ -4493,6 +4495,10 @@ describe('AiSdkBackend model history', () => { return { artifactId: `artifact-${event.runtimeEventId}` }; }, }), + loadModelProjectionTransitions: async () => [...transitions], + recordModelProjectionTransition: async (transition) => { + transitions.push(transition); + }, }); await drain( @@ -4542,119 +4548,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; diff --git a/packages/runtime/src/__tests__/context-budget.test.ts b/packages/runtime/src/__tests__/context-budget.test.ts index c360aea827..53a847e2f2 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__/execution-boundary-test-helpers.ts b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts index a40de02125..98f2b4c878 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,17 @@ 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], + recordModelProjectionTransition: async (transition) => { + transitions.push(transition); + }, ...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..3267960fa8 --- /dev/null +++ b/packages/runtime/src/__tests__/model-projection-transition-ledger.test.ts @@ -0,0 +1,372 @@ +/* + * 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 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; + highWaterSeq?: number; + 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), + archive: { + artifactId, + bodySha256: sha256(serialized), + originalBytes: serialized.length, + originalEstimatedTokens: serialized.length, + }, + reason: 'stale_tool_result_archived', + ...(options.previousTransitionId ? { previousTransitionId: options.previousTransitionId } : {}), + highWaterSeq: options.highWaterSeq ?? 10, + 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('reduces identically on the next Turn and after a cold restart', () => { + const event = toolResultEvent('rt-1', 'turn-1', { body: SECRET }); + const transition = archiveTransition(event); + + // Next Turn: the same ledger read, more events after it. + const nextTurn = reduceEffectiveModelProjections( + [event, toolResultEvent('rt-2', 'turn-2', { body: 'later' })], + [transition], + ); + // Cold restart: the ledger is all the process has. + const restart = reduceEffectiveModelProjections([event], [transition]); + + assert.deepEqual(nextTurn.events[0], restart.events[0]); + assert.equal(serializedEffective(nextTurn.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', highWaterSeq: 10 }); + // A second Turn that never saw `first` decides against the same source. + const stale = archiveTransition(event, { artifactId: 'artifact-b', highWaterSeq: 20 }); + + const reduced = reduceEffectiveModelProjections([event], [first, stale]); + + assert.deepEqual( + reduced.applied.map((transition) => transition.transitionId), + [first.transitionId], + ); + assert.deepEqual( + reduced.rejected.map((transition) => transition.transitionId), + [stale.transitionId], + ); + assert.equal(serializedEffective(reduced.events).includes(SECRET), false); + assert.deepEqual([...reduced.reachableArchiveArtifactIds], ['artifact-a']); + }); + + 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', highWaterSeq: 10 }); + const second = archiveTransition(event, { + artifactId: 'artifact-b', + highWaterSeq: 20, + 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([...inOrder.reachableArchiveArtifactIds].sort(), ['artifact-a', 'artifact-b']); + }); + + 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(reduced.reachableArchiveArtifactIds.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('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).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', { transition: { kind: 'nonsense' } }), + ] + : [ledgerEvent(`${transition.transitionId}-replay`, { transition })], + }; + + const loaded = await loadModelProjectionTransitionsFromRunLedger(runStore, 'session-1'); + + assert.deepEqual( + loaded.map((entry) => entry.transitionId), + [transition.transitionId], + ); + }); + + 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..918eadbc24 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,16 @@ 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; + /** + * Records what this run has already committed for a target, so a later step + * chains onto it instead of racing it. Purely a read-through cache of the + * durable ledger: a restart rebuilds the same answer by reduction. + */ + committed?: Map; } export interface ActiveToolResultPruneResult { @@ -189,8 +197,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 +224,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 +272,31 @@ 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 committed = input.input.committed?.get(part.toolCallId); + + const sourceProjection = committed?.projection ?? address.projection; + const serializedResult = serializedToolResultProjection(sourceProjection); const originalEstimatedTokens = estimateTokens(serializedResult.length, input.charsPerToken); if ( input.supersession @@ -301,75 +306,38 @@ 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', + ...((committed?.transitionId ?? address.previousTransitionId) + ? { previousTransitionId: committed?.transitionId ?? address.previousTransitionId } + : {}), + ...(input.supersession ? { supersession: input.supersession } : {}), + result: payload.value, + }); + if (!outcome) return { changed: false, archiveFailure: true }; + input.input.committed?.set(part.toolCallId, { + projection: outcome.transition.replacement, + transitionId: outcome.transition.transitionId, + }); 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 +375,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 +384,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 +395,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 ba68ad62c5..8a7a02085f 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, @@ -519,6 +523,39 @@ 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, + highWaterName: transition.highWaterName, + highWaterSeq: transition.highWaterSeq, + 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 b51269200d..e2fa1e0cdc 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -3467,16 +3467,23 @@ 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 path can resurrect content a committed + // transition removed. + const preparedContextBudget = await this.compaction.prepareContextBudgetPolicy( + rawPriorRuntimeContext, + input.turnId, + ); + const priorRuntimeContext = preparedContextBudget.events; const projectedMessages = await this.materializePriorMessages( scope.imageBudget, 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 f3a6cbdcdf..47cbe972c0 100644 --- a/packages/runtime/src/ai-sdk-compaction-contract.ts +++ b/packages/runtime/src/ai-sdk-compaction-contract.ts @@ -20,9 +20,9 @@ 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 { 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, @@ -83,6 +83,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; @@ -107,6 +112,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 3fa12b2ea0..aebd29f82a 100644 --- a/packages/runtime/src/ai-sdk-compaction.ts +++ b/packages/runtime/src/ai-sdk-compaction.ts @@ -43,13 +43,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, @@ -79,10 +76,22 @@ 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, +} 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'; @@ -218,6 +227,39 @@ export class AiSdkCompaction { this.canReplayProviderNative = deps.canReplayProviderNative; } + /** + * Every transition this session has committed, folded by the reducer. + * + * Read from the durable ledger rather than remembered: a Turn that pruned and + * a Turn that replays it may be different processes. + */ + public async loadModelProjectionTransitions(): Promise { + try { + return (await this.input.loadModelProjectionTransitions?.()) ?? []; + } catch { + return []; + } + } + + /** + * 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), + now: this.now, + }; + } + /** Abort an in-flight manual history compaction (called by AiSdkBackend.stop). */ public abortHistoryCompact(): void { this.historyCompactAbortController?.abort(); @@ -478,58 +520,87 @@ export class AiSdkCompaction { } } - public async prepareContextBudgetPolicy(runtimeContext: readonly RuntimeEvent[]): Promise<{ + /** + * 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 }; + let transitions = await this.loadModelProjectionTransitions(); + let effective = reduceEffectiveModelProjections(runtimeContext, transitions); + if (!policy) return { policy, events: effective.events }; let nextPolicy = policy; + let diagnosticPatch: Partial | undefined; - if (policy.staleToolResultPrune?.enabled === true) { + const services = this.toolResultArchiveTransitionServices(turnId); + 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); + } + 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 } + : {}), }; } } @@ -553,7 +624,11 @@ export class AiSdkCompaction { historyCompact: { ...nextPolicy.historyCompact!, checkpoint: loadedCheckpoint }, }; } - return { policy: nextPolicy }; + return { + policy: nextPolicy, + events: effective.events, + ...(diagnosticPatch ? { diagnosticPatch } : {}), + }; } public buildActiveToolResultPruneProjection( @@ -563,8 +638,49 @@ 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; + + // Read-through cache of what this run has already committed for a target, + // so step N+1 chains onto step N's transition instead of racing it. Derived + // state only: a restart rebuilds the same answer from the ledger. + const committed = new Map< + string, + { projection: DurableToolResultProjection; transitionId: string } + >(); + let turnEvents: RuntimeEvent[] | undefined; + const resolveProjection = async ( + toolCallId: string, + ): Promise => { + for (let attempt = 0; attempt < 2; attempt += 1) { + if (!turnEvents || attempt === 1) { + try { + turnEvents = await this.input.loadTurnRuntimeEvents!(turnId); + } catch { + return undefined; + } + } + const event = turnEvents.find( + (candidate) => + candidate.partial !== true && + candidate.content?.kind === 'function_response' && + candidate.content.id === toolCallId, + ); + if (!event || event.content?.kind !== 'function_response') continue; + const projection = baseToolResultProjection(event); + if (!projection) return undefined; + return { + runtimeEventId: event.id, + turnId: event.turnId, + toolName: event.content.name, + projection, + }; + } + return undefined; + }; - const archivedPlaceholders = new Map(); return async (options) => { const eligibleToolCallIds = collectPrunableCompletedStepToolCallIds( options.completedSteps, @@ -586,16 +702,9 @@ 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, + committed, }); if (hasActiveToolResultPruneDiagnosticPatch(rewritten.diagnosticPatch)) { onDiagnosticPatch?.(rewritten.diagnosticPatch); @@ -1395,12 +1504,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 a38ad111c0..6be300f547 100644 --- a/packages/runtime/src/context-budget.ts +++ b/packages/runtime/src/context-budget.ts @@ -40,7 +40,6 @@ export type { ToolResultArchiveReaderInput, ToolResultArchiveReadFailureReason, ToolResultArchiveReadResult, - ToolResultArchiveRef, ArchivedToolResultPlaceholder, } from './tool-result-archive.js'; export type { ArchivedToolResultReason } from './tool-result-archive.js'; @@ -48,15 +47,11 @@ 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, +import type { + StaleToolResultPrunePolicy, + StaleToolResultArchiveCandidate, } from './tool-result-archive.js'; +import { collectStaleToolResultArchiveCandidates as collectStaleToolResultArchiveCandidatesNarrow } from './tool-result-archive-transition.js'; import { type ActiveToolResultPrunePolicy } from './active-tool-result-prune.js'; import { applyRuntimeEventHistoryCompact as applyRuntimeEventHistoryCompactNarrow, @@ -144,12 +139,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))); @@ -166,23 +161,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, 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..bd21935149 --- /dev/null +++ b/packages/runtime/src/model-projection-transition-ledger.ts @@ -0,0 +1,230 @@ +/* + * 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, so + * ledger order, arrival order and run order 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 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[]; + /** + * Archive artifacts the effective history still needs, derived from the fold + * rather than from a second bookkeeping path. A cleanup pass may reclaim what + * is not here; it may never reclaim what is. + */ + reachableArchiveArtifactIds: 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(); + for (const run of await runStore.listSessionRuns(sessionId)) { + for (const event of await runStore.readEvents(sessionId, run.runId)) { + const transition = decodeLedgerTransition(event, sessionId); + // A content-derived id makes a duplicated concurrent append idempotent. + if (transition && !byId.has(transition.transitionId)) { + byId.set(transition.transitionId, transition); + } + } + } + return [...byId.values()]; +} + +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[], +): EffectiveModelProjectionReduction { + const applied: ModelProjectionTransition[] = []; + const rejected: ModelProjectionTransition[] = []; + const reachableArchiveArtifactIds = new Set(); + if (transitions.length === 0) { + return { events: [...events], applied, rejected, reachableArchiveArtifactIds }; + } + + 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 group = byTarget.get(targetKey(event.id, 'tool_result')); + 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; + for (const transition of sortForReduction(group)) { + if ( + transition.sourceProjectionDigest !== currentDigest || + transition.previousTransitionId !== previousTransitionId || + transition.target.toolCallId !== content.id || + transition.target.toolName !== content.name + ) { + rejected.push(transition); + continue; + } + current = transition.replacement; + currentDigest = durableToolResultProjectionDigest(current); + previousTransitionId = transition.transitionId; + changed = true; + applied.push(transition); + if (transition.archive) reachableArchiveArtifactIds.add(transition.archive.artifactId); + } + 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; + }); + + // An archive whose transition never took effect is unreachable by + // construction: nothing in the effective history names it. + for (const transition of rejected) { + if (transition.archive) reachableArchiveArtifactIds.delete(transition.archive.artifactId); + } + for (const transition of applied) { + if (transition.archive) reachableArchiveArtifactIds.add(transition.archive.artifactId); + } + + return { events: nextEvents, applied, rejected, reachableArchiveArtifactIds }; +} + +/** + * Total order within one target. Cursor first, then the content-derived id, so + * two runs that appended concurrently reduce identically on every reader. + */ +function sortForReduction( + group: readonly ModelProjectionTransition[], +): ModelProjectionTransition[] { + return [...group].sort((left, right) => + left.highWaterSeq !== right.highWaterSeq + ? left.highWaterSeq - right.highWaterSeq + : left.transitionId < right.transitionId + ? -1 + : left.transitionId > right.transitionId + ? 1 + : 0, + ); +} + +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 ccfe7f86c2..ad1eab75a9 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -99,6 +99,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, @@ -2229,6 +2231,8 @@ export class RuntimeKernel implements RuntimeKernelLike { | 'recordRunComposition' | 'loadHistoryCompactCheckpoint' | 'recordHistoryCompactCheckpoint' + | 'loadModelProjectionTransitions' + | 'recordModelProjectionTransition' | 'loadTurnRuntimeEvents' > { const { sessionId } = input; @@ -2270,6 +2274,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 b8497a9df0..f1afea8823 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -163,6 +163,7 @@ 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 { AgentRunLineage, RuntimeContinuationFailpoint } from './agent-run.js'; import type { RuntimeCommitResult, RuntimeCommitSink } from './runtime-commit-sink.js'; import { @@ -686,6 +687,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..118d1a33e8 --- /dev/null +++ b/packages/runtime/src/tool-result-archive-transition.ts @@ -0,0 +1,324 @@ +/* + * 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 reclaimable — 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, + type EffectiveModelProjectionReduction, +} 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; + 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, + replacement: archivedToolResultProjection(placeholder), + archive: { + artifactId, + bodySha256, + originalBytes: request.originalBytes, + originalEstimatedTokens: request.originalEstimatedTokens, + }, + reason: + request.reason === 'stale_tool_result_pruned_before_compact' + ? 'stale_tool_result_archived' + : 'active_tool_result_archived', + ...(request.previousTransitionId + ? { previousTransitionId: request.previousTransitionId } + : {}), + highWaterSeq: services.now(), + now: services.now(), + }); + await services.recordTransition(transition); + } catch { + // The archive artifact is now unreferenced: nothing in the effective + // history names it, which is exactly what reducer-derived reachability + // reports. It is also content-addressed, so a later retry of the same + // decision reuses this artifact rather than publishing a second one — + // reclamation may be delayed, but it cannot grow without bound and cannot + // break replay. + return undefined; + } + return { placeholder, transition }; +} + +/** + * 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 reduction, never from a parallel bookkeeping table: an + * artifact is reachable exactly when an applied transition or a surviving + * placeholder names it. Cleanup may reclaim the rest; a cleanup failure only + * delays reclamation and cannot break replay. + */ +export function collectReachableArchiveArtifactIds( + reduction: EffectiveModelProjectionReduction, +): Set { + const reachable = new Set(reduction.reachableArchiveArtifactIds); + for (const event of reduction.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; From 48403d4b2612d462d35d51a3f5bc10e5a4eabd73 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 19:02:01 +0800 Subject: [PATCH 4/9] refactor(runtime): rebuild projection transitions when copying a conversation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A copied conversation gets new RuntimeEvent ids and new artifact ids, so a transition cannot be carried across verbatim: its target would name an event in the source Session and its source digest would describe a projection the target does not have. Left that way the record would be inert, and an inert transition means the archived body reappears in the copy — the one failure this protocol exists to prevent. Rebuild each transition inside the target instead. The target event, the placeholder and the archive reference are remapped, the source digest is re-derived from the cloned event, and lineage is preserved through the remapped predecessor id so a chain of transitions still folds in order. Because the digest is re-derived rather than copied, a remap this code failed to perform cannot produce a silently inert record: it throws. A transition whose target left the copied slice is dropped, which is safe in exactly one direction — the target is absent too, so nothing it replaced can come back. Copy admission also has to see archives that only a transition names, now that the placeholder is no longer the sole record of one. Reading the operational ledger alongside the RuntimeEvents keeps a copied placeholder from pointing at an artifact the copy never carried over. Refs #4283 Generated-by: Claude Code --- .../server/session-revision-coordinator.ts | 25 ++ .../src/__tests__/conversation-copy.test.ts | 225 ++++++++++++++++++ packages/runtime/src/conversation-copy.ts | 95 ++++++++ 3 files changed, 345 insertions(+) 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__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts index cf7ccbb3a4..07de7395ac 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); @@ -2581,6 +2602,210 @@ 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; + highWaterSeq: 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), + archive: { + artifactId: input.artifactId, + bodySha256: sha256(serialized), + originalBytes: serialized.length, + originalEstimatedTokens: serialized.length, + }, + reason: 'stale_tool_result_archived', + ...(input.previousTransitionId ? { previousTransitionId: input.previousTransitionId } : {}), + highWaterSeq: input.highWaterSeq, + now: 100 + input.highWaterSeq, + }); +} + +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', + highWaterSeq: 1, + }); + const second = sourceProjectionTransition({ + event: resultEvent, + sourceProjection: first.replacement, + artifactId: 'artifact-source-2', + highWaterSeq: 2, + 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, + highWaterName: transition.highWaterName, + highWaterSeq: transition.highWaterSeq, + 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.length, 2); + const [copiedFirst, copiedSecond] = copiedTransitions.sort( + (a, b) => a.highWaterSeq - b.highWaterSeq, + ); + assert.ok(copiedFirst && copiedSecond); + for (const transition of copiedTransitions) { + assert.equal(transition.sessionId, 'session-target'); + assert.equal(transition.target.runtimeEventId, targetResult.id); + } + assert.equal(copiedFirst.archive?.artifactId, 'artifact-target-1'); + assert.equal(copiedSecond.archive?.artifactId, 'artifact-target-2'); + // Lineage is preserved through the remapped ids, never through the source's. + assert.equal(copiedFirst.previousTransitionId, undefined); + assert.equal(copiedSecond.previousTransitionId, copiedFirst.transitionId); + assert.notEqual(copiedFirst.transitionId, first.transitionId); + assert.doesNotMatch(JSON.stringify(copiedTransitions), /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); + 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); + assert.deepEqual([...collectReachableArchiveArtifactIds(reduced)].sort(), [ + 'artifact-target-1', + 'artifact-target-2', + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + function prepareTestCopyPlan( source: RuntimeReadModelSessionView, copiedMessages: readonly StoredMessage[], diff --git a/packages/runtime/src/conversation-copy.ts b/packages/runtime/src/conversation-copy.ts index 41d3c72618..15d5233158 100644 --- a/packages/runtime/src/conversation-copy.ts +++ b/packages/runtime/src/conversation-copy.ts @@ -59,6 +59,15 @@ 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 } from './model-projection-transition-ledger.js'; +import { archivedToolResultProjection } from './tool-result-archive-transition.js'; export interface ConversationCopySlice { readonly messages: readonly StoredMessage[]; @@ -376,6 +385,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 +408,8 @@ export async function cloneConversationRuntimeLedger( sourceCompactableEvents.get(plan.run.runId) ?? [], clonedEventBySourceId, checkpointIds, + transitionIds, + transitionState, operationalEventIds, providerTraceIds, logicalCallIds, @@ -705,6 +724,8 @@ function cloneAgentRunEvent( sourceCompactableEvents: readonly RuntimeEvent[], clonedRuntimeEvents: ReadonlyMap, checkpointIds: Map, + transitionIds: Map, + transitionState: Map, operationalEventIds: ReadonlyMap, providerTraceIds: ReadonlyMap, logicalCallIds: ReadonlyMap, @@ -804,6 +825,19 @@ function cloneAgentRunEvent( checkpointId: checkpoint.checkpointId, checkpoint, }; + } else if (event.type === MODEL_PROJECTION_TRANSITION_EVENT_TYPE) { + const cloned = cloneModelProjectionTransition( + event, + references, + clonedRuntimeEvents, + transitionIds, + transitionState, + ); + // A transition whose target left the copied slice has nothing to replace, + // and dropping it is safe in exactly one direction: the target is absent + // too, so no replaced content can reappear. + if (!cloned) return null; + data = { ...event.data, transition: cloned, runtimeEventId: cloned.target.runtimeEventId }; } return { @@ -815,6 +849,67 @@ 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), + ...(source.archive ? { archive: { ...source.archive, artifactId: rewritten.artifactId } } : {}), + reason: source.reason, + ...(source.previousTransitionId && transitionIds.has(source.previousTransitionId) + ? { previousTransitionId: transitionIds.get(source.previousTransitionId)! } + : {}), + highWaterSeq: source.highWaterSeq, + 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, From 3ed5276b10da8ed8eaa4ac3ea7353057f6da9698 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 20:08:10 +0800 Subject: [PATCH 5/9] refactor(runtime): make the transition reducer the only model-history owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the fold sitting one level too low. It ran inside the prior-Turn budget preparation, so every other producer of model-visible messages kept its own opportunity to show content a transition had already replaced — and the current Turn took that opportunity on the very next provider step. Reproduced against the previous build: a result archived at step N is rebuilt in full at step N+1 and every step after it, because each step rebuilds its prompt from the raw durable Turn ledger. The prune had grown a per-run `committed` map to remember what it had replaced — the same private memory this workstream set out to delete, under a new name — and measuring the threshold against that map is what let the raw body through: the map held the placeholder, the placeholder is small, so the oversized result was never replaced again. The archive stayed durable and correct throughout; what came back is the prompt, not the ledger. Fix it at the owner. The Turn's own events are folded through the same reducer before they become messages, so the prune needs no memory at all: what it sees is already the effective history, and an archived result is simply no longer large. The `committed` map and its parameter are gone, and the regression test drives four provider steps; with the fold removed it archives the same result twice and shows the raw body in the fourth prompt. Two more paths could resurrect replaced content: - A ledger read that failed, or a record this build cannot decode, was smoothed into "there are no transitions". Now both are reported, and a reader that cannot see the whole chain refuses to extend it: a successor built on partly known state would name the wrong predecessor and be inert forever, losing the content it archived. - 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. Copying by run therefore kept the target and dropped the record. Transitions are now gathered by target, so a copy carries every record that applies to what it copied; the comment claiming this was already true is corrected. The same review showed the protocol had grown fields the fold does not need, and one of them was actively wrong. `highWaterSeq` was a wall-clock reading used both as the reduction cursor and as part of the record id, so "the same decision yields the same record" was false, and two transitions written in one millisecond could order the successor first and leave it permanently inert. The chain the record already carries is the ordering authority, so the fold now follows `previousTransitionId` instead of sorting, and ties among writers naming the same predecessor go to the smallest content-derived id. With ordering off the clock, `highWaterSeq` and its unread `highWaterName` are deleted and the id is genuinely derived from content alone. Deleted alongside them, for the same reason — a second representation of a fact that already has an owner: - `archive`, which repeated the artifact id, body digest and original size that the replacement's placeholder already carries; - the reduction's `reachableArchiveArtifactIds`, whose contents were a subset of what scanning the folded events yields, plus a loop over rejected transitions that could not remove anything; - `reason`, a second spelling of the placeholder's own reason with no reader and a hand-written mapping table between the two; - a public `collectStaleToolResultArchiveCandidates` wrapper with no callers, and a test shim parameter for a map that no longer exists. The StoredMessage fallback projection is still outside the fold. That is a second history representation predating this work, and the comment that overstated the guarantee now says which paths it covers. Refs #4283 Generated-by: Claude Code --- .../model-projection-transition.test.ts | 37 +-- .../core/src/model-projection-transition.ts | 102 ++------ .../active-tool-result-prune.test.ts | 109 +++------ .../src/__tests__/ai-sdk-backend.test.ts | 62 +++-- .../src/__tests__/conversation-copy.test.ts | 220 +++++++++++++++--- .../execution-boundary-test-helpers.ts | 2 +- ...model-projection-transition-ledger.test.ts | 81 +++---- .../runtime/src/active-tool-result-prune.ts | 18 +- packages/runtime/src/agent-run.ts | 2 - packages/runtime/src/ai-sdk-backend.ts | 13 +- .../runtime/src/ai-sdk-compaction-contract.ts | 3 +- packages/runtime/src/ai-sdk-compaction.ts | 132 +++++++---- packages/runtime/src/context-budget.ts | 17 +- packages/runtime/src/conversation-copy.ts | 64 ++++- .../src/model-projection-transition-ledger.ts | 115 +++++---- packages/runtime/src/session-manager.ts | 3 +- .../src/tool-result-archive-transition.ts | 36 +-- 17 files changed, 565 insertions(+), 451 deletions(-) diff --git a/packages/core/src/__tests__/model-projection-transition.test.ts b/packages/core/src/__tests__/model-projection-transition.test.ts index b687cbf8b7..3b641d1623 100644 --- a/packages/core/src/__tests__/model-projection-transition.test.ts +++ b/packages/core/src/__tests__/model-projection-transition.test.ts @@ -26,8 +26,6 @@ import { decodeModelProjectionTransition, durableToolResultProjectionDigest, isModelProjectionTransition, - MODEL_PROJECTION_TRANSITION_HIGH_WATER_NAME, - type ModelProjectionTransition, } from '../model-projection-transition.js'; const SOURCE: DurableToolResultProjection = { @@ -53,14 +51,6 @@ function build(overrides: Partial { test('binds the record to the projection it may replace', () => { const transition = build(); assert.equal(transition.sourceProjectionDigest, durableToolResultProjectionDigest(SOURCE)); - assert.equal(transition.highWaterName, MODEL_PROJECTION_TRANSITION_HIGH_WATER_NAME); 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); - assert.notEqual(build().transitionId, build({ highWaterSeq: 8 }).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, @@ -102,14 +92,11 @@ describe('model projection transition schema', () => { assert.throws(() => decodeModelProjectionTransition(transition, 'session-2')); }); - test('rejects an unknown field, an unknown reason, and an unrepresentable replacement', () => { + test('rejects an unknown field and an unrepresentable replacement', () => { const transition = build(); assert.throws(() => decodeModelProjectionTransition({ ...transition, extra: true }, 'session-1'), ); - assert.throws(() => - decodeModelProjectionTransition({ ...transition, reason: 'invented' }, 'session-1'), - ); assert.throws(() => decodeModelProjectionTransition( { ...transition, replacement: { version: 1, kind: 'text' } }, @@ -117,22 +104,4 @@ describe('model projection transition schema', () => { ), ); }); - - test('keeps the archive optional but well formed when present', () => { - const withoutArchive = build({ archive: undefined }); - assert.equal(withoutArchive.archive, undefined); - const transition: ModelProjectionTransition = build(); - assert.throws(() => - decodeModelProjectionTransition( - { ...transition, archive: { ...transition.archive!, bodySha256: 'not-a-digest' } }, - 'session-1', - ), - ); - assert.throws(() => - decodeModelProjectionTransition( - { ...transition, archive: { ...transition.archive!, originalBytes: 0 } }, - 'session-1', - ), - ); - }); }); diff --git a/packages/core/src/model-projection-transition.ts b/packages/core/src/model-projection-transition.ts index e31ccfd422..779bff2626 100644 --- a/packages/core/src/model-projection-transition.ts +++ b/packages/core/src/model-projection-transition.ts @@ -27,20 +27,19 @@ * restart can restore the replaced form. * * This module owns the one typed record that expresses such a change. It is - * deliberately NOT a generalization of `HistoryCompactCheckpoint`: that - * checkpoint replaces one validated CONTIGUOUS prefix, and keeps that meaning. - * A transition is SPARSE — it names one projection part of one RuntimeEvent — - * and the two have different coverage, concurrency, copy and recovery algebra. + * 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` / `archive` — what the model sees instead, and where the - * replaced body still lives when it is recoverable at all; - * - `previousTransitionId` + `highWaterSeq` — predecessor and cursor identity, - * so ledger readers in any order converge on the same effective history. + * - `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'; @@ -57,18 +56,6 @@ 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'; -/** Reduction cursor name, mirroring the checkpoint protocol's high-water pair. */ -export const MODEL_PROJECTION_TRANSITION_HIGH_WATER_NAME = 'model-projection-transition-high-water'; - -export const MODEL_PROJECTION_TRANSITION_REASONS = [ - /** Current-turn result archived before the next provider step. */ - 'active_tool_result_archived', - /** Prior-turn result archived before whole-turn compaction. */ - 'stale_tool_result_archived', -] as const; - -export type ModelProjectionTransitionReason = (typeof MODEL_PROJECTION_TRANSITION_REASONS)[number]; - /** * The addressed projection part. `tool_result` is the whole durable Tool Result * projection of one `function_response` RuntimeEvent — the only part kind that @@ -81,22 +68,6 @@ export interface ModelProjectionTransitionTarget { toolName: string; } -/** - * Session-owned archive of the replaced body. - * - * Optional: a transition that removes content irrecoverably (an image a - * provider refused to accept) is still a valid transition. Present here, it is - * both the model's way back to the content and the reachability root that keeps - * the artifact from being reclaimed. - */ -export interface ModelProjectionTransitionArchive { - artifactId: string; - /** Lowercase hex sha256 of the archived serialized body. */ - bodySha256: string; - originalBytes: number; - originalEstimatedTokens: number; -} - export interface ModelProjectionTransition { kind: 'maka.model_projection_transition'; version: typeof MODEL_PROJECTION_TRANSITION_VERSION; @@ -107,12 +78,13 @@ export interface ModelProjectionTransition { /** Digest of the projection this record is allowed to replace. */ sourceProjectionDigest: `sha256:${string}`; replacement: DurableToolResultProjection; - archive?: ModelProjectionTransitionArchive; - reason: ModelProjectionTransitionReason; - /** The transition this one supersedes for the same target, if any. */ + /** + * 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; - highWaterName: string; - highWaterSeq: number; } const TRANSITION_SHAPE = defineObjectShape()( @@ -125,11 +97,8 @@ const TRANSITION_SHAPE = defineObjectShape()( 'target', 'sourceProjectionDigest', 'replacement', - 'reason', - 'highWaterName', - 'highWaterSeq', ], - ['archive', 'previousTransitionId'], + ['previousTransitionId'], ); const TARGET_SHAPE = defineObjectShape()( @@ -137,13 +106,6 @@ const TARGET_SHAPE = defineObjectShape()( [], ); -const ARCHIVE_SHAPE = defineObjectShape()( - ['artifactId', 'bodySha256', 'originalBytes', 'originalEstimatedTokens'], - [], -); - -const REASONS: ReadonlySet = new Set(MODEL_PROJECTION_TRANSITION_REASONS); - /** * The identity of one durable projection, over strict key-sorted JSON. * @@ -165,20 +127,17 @@ export interface BuildModelProjectionTransitionInput { target: ModelProjectionTransitionTarget; sourceProjection: DurableToolResultProjection; replacement: DurableToolResultProjection; - archive?: ModelProjectionTransitionArchive; - reason: ModelProjectionTransitionReason; previousTransitionId?: string; - highWaterSeq: number; now: number; } /** * Build one transition with a content-derived id. * - * The id is a digest of everything the record asserts, 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. + * 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, @@ -193,11 +152,7 @@ export function buildModelProjectionTransition( target: input.target, sourceProjectionDigest, replacement, - ...(input.archive ? { archive: input.archive } : {}), - reason: input.reason, ...(input.previousTransitionId ? { previousTransitionId: input.previousTransitionId } : {}), - highWaterName: MODEL_PROJECTION_TRANSITION_HIGH_WATER_NAME, - highWaterSeq: input.highWaterSeq, }; const transitionId = `mptransition-${nodeCrypto .createHash('sha256') @@ -238,13 +193,8 @@ export function isModelProjectionTransition( value.sessionId !== sessionId || !isFiniteNumber(value.createdAt) || !isSha256Digest(value.sourceProjectionDigest) || - typeof value.reason !== 'string' || - !REASONS.has(value.reason) || - !nonEmptyString(value.highWaterName) || - !isFiniteNumber(value.highWaterSeq) || (value.previousTransitionId !== undefined && !nonEmptyString(value.previousTransitionId)) || - !isTransitionTarget(value.target) || - (value.archive !== undefined && !isTransitionArchive(value.archive)) + !isTransitionTarget(value.target) ) { return false; } @@ -267,20 +217,6 @@ function isTransitionTarget(value: unknown): value is ModelProjectionTransitionT ); } -function isTransitionArchive(value: unknown): value is ModelProjectionTransitionArchive { - return ( - isRecord(value) && - hasExactShape(value, ARCHIVE_SHAPE) && - nonEmptyString(value.artifactId) && - typeof value.bodySha256 === 'string' && - /^[a-f0-9]{64}$/.test(value.bodySha256) && - isFiniteNumber(value.originalBytes) && - value.originalBytes > 0 && - isFiniteNumber(value.originalEstimatedTokens) && - value.originalEstimatedTokens > 0 - ); -} - function isSha256Digest(value: unknown): value is `sha256:${string}` { return typeof value === 'string' && /^sha256:[a-f0-9]{64}$/.test(value); } 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 13d8ec851a..394fc49dc5 100644 --- a/packages/runtime/src/__tests__/active-tool-result-prune.test.ts +++ b/packages/runtime/src/__tests__/active-tool-result-prune.test.ts @@ -96,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, @@ -126,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\.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\.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\.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\.archived_tool_result/); - }); + } test('empty-artifact placeholders are not treated as idempotent', async () => { const placeholder = invalidActivePlaceholder(); @@ -772,7 +724,7 @@ describe('active current-turn tool-result pruning', () => { * measure exactly what they used to) and an archive + transition recorder. */ async function rewriteActiveToolResultsInMessages( - input: Omit & { + input: Omit & { archiveToolResult?: (candidate: { sessionId: string; runtimeEventId: string; @@ -783,11 +735,9 @@ async function rewriteActiveToolResultsInMessages( bodySha256: string; }) => { artifactId: string } | void | Promise<{ artifactId: string } | void>; recordTransition?: (transition: ModelProjectionTransition) => Promise; - archivedPlaceholders?: unknown; - committed?: ActiveToolResultPruneInput['committed']; }, ): Promise { - const { archiveToolResult, recordTransition, archivedPlaceholders: _legacy, ...rest } = input; + const { archiveToolResult, recordTransition, ...rest } = input; let clock = 1000; return rewriteActiveToolResultsInMessagesNarrow({ ...rest, @@ -801,7 +751,6 @@ async function rewriteActiveToolResultsInMessages( recordTransition: recordTransition ?? (() => Promise.resolve()), now: () => (clock += 1), }, - ...(input.committed ? { committed: input.committed } : {}), }); } diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 232a6bab78..6285b22530 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -4495,7 +4495,10 @@ describe('AiSdkBackend model history', () => { return { artifactId: `artifact-${event.runtimeEventId}` }; }, }), - loadModelProjectionTransitions: async () => [...transitions], + loadModelProjectionTransitions: async () => ({ + transitions: [...transitions], + undecodable: 0, + }), recordModelProjectionTransition: async (transition) => { transitions.push(transition); }, @@ -8253,6 +8256,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({ @@ -8296,17 +8300,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 }), }; @@ -8340,7 +8362,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(), @@ -8360,7 +8385,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/); @@ -8368,8 +8393,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__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts index 07de7395ac..1cad10f11b 100644 --- a/packages/runtime/src/__tests__/conversation-copy.test.ts +++ b/packages/runtime/src/__tests__/conversation-copy.test.ts @@ -2608,7 +2608,7 @@ function sourceProjectionTransition(input: { event: RuntimeEvent; sourceProjection: DurableToolResultProjection; artifactId: string; - highWaterSeq: number; + createdAt: number; previousTransitionId?: string; }): ModelProjectionTransition { const serialized = serializedToolResultProjection(input.sourceProjection); @@ -2632,16 +2632,8 @@ function sourceProjectionTransition(input: { }, sourceProjection: input.sourceProjection, replacement: archivedToolResultProjection(placeholder), - archive: { - artifactId: input.artifactId, - bodySha256: sha256(serialized), - originalBytes: serialized.length, - originalEstimatedTokens: serialized.length, - }, - reason: 'stale_tool_result_archived', ...(input.previousTransitionId ? { previousTransitionId: input.previousTransitionId } : {}), - highWaterSeq: input.highWaterSeq, - now: 100 + input.highWaterSeq, + now: input.createdAt, }); } @@ -2696,13 +2688,13 @@ test('conversation copy rebuilds projection transitions against the copied event event: resultEvent, sourceProjection: baseToolResultProjection(resultEvent)!, artifactId: 'artifact-source-1', - highWaterSeq: 1, + createdAt: 101, }); const second = sourceProjectionTransition({ event: resultEvent, sourceProjection: first.replacement, artifactId: 'artifact-source-2', - highWaterSeq: 2, + createdAt: 102, previousTransitionId: first.transitionId, }); for (const transition of [first, second]) { @@ -2716,8 +2708,6 @@ test('conversation copy rebuilds projection transitions against the copied event data: { runtimeEventId: transition.target.runtimeEventId, part: transition.target.part, - highWaterName: transition.highWaterName, - highWaterSeq: transition.highWaterSeq, transition, }, }); @@ -2767,28 +2757,31 @@ test('conversation copy rebuilds projection transitions against the copied event runStore, 'session-target', ); - assert.equal(copiedTransitions.length, 2); - const [copiedFirst, copiedSecond] = copiedTransitions.sort( - (a, b) => a.highWaterSeq - b.highWaterSeq, + 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(copiedFirst && copiedSecond); - for (const transition of copiedTransitions) { + assert.ok(copiedSecond); + for (const transition of copiedTransitions.transitions) { assert.equal(transition.sessionId, 'session-target'); assert.equal(transition.target.runtimeEventId, targetResult.id); } - assert.equal(copiedFirst.archive?.artifactId, 'artifact-target-1'); - assert.equal(copiedSecond.archive?.artifactId, 'artifact-target-2'); // Lineage is preserved through the remapped ids, never through the source's. - assert.equal(copiedFirst.previousTransitionId, undefined); - assert.equal(copiedSecond.previousTransitionId, copiedFirst.transitionId); assert.notEqual(copiedFirst.transitionId, first.transitionId); - assert.doesNotMatch(JSON.stringify(copiedTransitions), /artifact-source|event-result/); + 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); + 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/); @@ -2797,10 +2790,179 @@ test('conversation copy rebuilds projection transitions against the copied event assert.ok(isArchivedToolResultPlaceholder(effective.content.result)); assert.equal(effective.content.result.artifactId, 'artifact-target-2'); assert.equal(effective.content.result.runtimeEventId, targetResult.id); - assert.deepEqual([...collectReachableArchiveArtifactIds(reduced)].sort(), [ - 'artifact-target-1', - 'artifact-target-2', - ]); + // 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 }); } diff --git a/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts index 98f2b4c878..a47669cbc3 100644 --- a/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts +++ b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts @@ -47,7 +47,7 @@ export function createTestAiSdkBackend(input: TestAiSdkBackendInput): AiSdkBacke const transitions: ModelProjectionTransition[] = []; return new AiSdkBackend({ readExecutionBoundary: readExternalExecutionBoundary, - loadModelProjectionTransitions: async () => [...transitions], + loadModelProjectionTransitions: async () => ({ transitions: [...transitions], undecodable: 0 }), recordModelProjectionTransition: async (transition) => { transitions.push(transition); }, diff --git a/packages/runtime/src/__tests__/model-projection-transition-ledger.test.ts b/packages/runtime/src/__tests__/model-projection-transition-ledger.test.ts index 3267960fa8..d495c7a3b5 100644 --- a/packages/runtime/src/__tests__/model-projection-transition-ledger.test.ts +++ b/packages/runtime/src/__tests__/model-projection-transition-ledger.test.ts @@ -75,7 +75,6 @@ function archiveTransition( event: RuntimeEvent, options: { artifactId?: string; - highWaterSeq?: number; previousTransitionId?: string; sourceProjection?: ReturnType; } = {}, @@ -103,15 +102,7 @@ function archiveTransition( }, sourceProjection, replacement: archivedToolResultProjection(placeholder), - archive: { - artifactId, - bodySha256: sha256(serialized), - originalBytes: serialized.length, - originalEstimatedTokens: serialized.length, - }, - reason: 'stale_tool_result_archived', ...(options.previousTransitionId ? { previousTransitionId: options.previousTransitionId } : {}), - highWaterSeq: options.highWaterSeq ?? 10, now: 100, }); } @@ -136,48 +127,43 @@ describe('effective model projection reduction', () => { assert.equal(serializedEffective(reduced.events).includes(SECRET), false); }); - test('reduces identically on the next Turn and after a cold restart', () => { - const event = toolResultEvent('rt-1', 'turn-1', { body: SECRET }); - const transition = archiveTransition(event); - - // Next Turn: the same ledger read, more events after it. - const nextTurn = reduceEffectiveModelProjections( - [event, toolResultEvent('rt-2', 'turn-2', { body: 'later' })], - [transition], - ); - // Cold restart: the ledger is all the process has. - const restart = reduceEffectiveModelProjections([event], [transition]); - - assert.deepEqual(nextTurn.events[0], restart.events[0]); - assert.equal(serializedEffective(nextTurn.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', highWaterSeq: 10 }); + 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', highWaterSeq: 20 }); - - const reduced = reduceEffectiveModelProjections([event], [first, stale]); - - assert.deepEqual( - reduced.applied.map((transition) => transition.transitionId), - [first.transitionId], - ); - assert.deepEqual( - reduced.rejected.map((transition) => transition.transitionId), - [stale.transitionId], - ); - assert.equal(serializedEffective(reduced.events).includes(SECRET), false); - assert.deepEqual([...reduced.reachableArchiveArtifactIds], ['artifact-a']); + 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', highWaterSeq: 10 }); + const first = archiveTransition(event, { artifactId: 'artifact-a' }); const second = archiveTransition(event, { artifactId: 'artifact-b', - highWaterSeq: 20, previousTransitionId: first.transitionId, sourceProjection: first.replacement, }); @@ -190,7 +176,7 @@ describe('effective model projection reduction', () => { inOrder.applied.map((transition) => transition.transitionId), [first.transitionId, second.transitionId], ); - assert.deepEqual([...inOrder.reachableArchiveArtifactIds].sort(), ['artifact-a', 'artifact-b']); + assert.deepEqual([...collectReachableArchiveArtifactIds(inOrder.events)], ['artifact-b']); }); test('leaves provider-native opaque results alone', () => { @@ -211,7 +197,7 @@ describe('effective model projection reduction', () => { assert.deepEqual(reduced.events[0], event); assert.equal(reduced.applied.length, 0); assert.equal(reduced.rejected.length, 1); - assert.equal(reduced.reachableArchiveArtifactIds.size, 0); + assert.equal(collectReachableArchiveArtifactIds(reduced.events).size, 0); }); test('rolling compaction cannot re-measure or re-archive replaced content', () => { @@ -314,7 +300,7 @@ describe('durable transition writer', () => { assert.equal(outcome, undefined); const reduced = reduceEffectiveModelProjections([event], []); - assert.equal(collectReachableArchiveArtifactIds(reduced).has('artifact-orphan'), false); + assert.equal(collectReachableArchiveArtifactIds(reduced.events).has('artifact-orphan'), false); assert.ok(serializedEffective(reduced.events).includes(SECRET)); }); }); @@ -347,9 +333,12 @@ describe('transition ledger reads', () => { const loaded = await loadModelProjectionTransitionsFromRunLedger(runStore, 'session-1'); assert.deepEqual( - loaded.map((entry) => entry.transitionId), + loaded.transitions.map((entry) => entry.transitionId), [transition.transitionId], ); + // A record of the right type this build cannot decode is reported, not + // silently treated as "no transition here". + assert.equal(loaded.undecodable, 1); }); test('legacy retry: an event with no durable projection still folds through one codec', () => { diff --git a/packages/runtime/src/active-tool-result-prune.ts b/packages/runtime/src/active-tool-result-prune.ts index 918eadbc24..11a335e41e 100644 --- a/packages/runtime/src/active-tool-result-prune.ts +++ b/packages/runtime/src/active-tool-result-prune.ts @@ -109,12 +109,6 @@ export interface ActiveToolResultPruneInput { resolveProjection: ActiveToolResultProjectionResolver; /** Archive + transition writer. */ transitions: ToolResultArchiveTransitionServices; - /** - * Records what this run has already committed for a target, so a later step - * chains onto it instead of racing it. Purely a read-through cache of the - * durable ledger: a restart rebuilds the same answer by reduction. - */ - committed?: Map; } export interface ActiveToolResultPruneResult { @@ -293,9 +287,7 @@ async function rewriteToolResultPart(input: { // 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 committed = input.input.committed?.get(part.toolCallId); - - const sourceProjection = committed?.projection ?? address.projection; + const sourceProjection = address.projection; const serializedResult = serializedToolResultProjection(sourceProjection); const originalEstimatedTokens = estimateTokens(serializedResult.length, input.charsPerToken); if ( @@ -316,17 +308,11 @@ async function rewriteToolResultPart(input: { originalBytes: utf8ByteLength(serializedResult), originalEstimatedTokens, reason: 'active_current_turn_tool_result_pruned_before_next_step', - ...((committed?.transitionId ?? address.previousTransitionId) - ? { previousTransitionId: committed?.transitionId ?? address.previousTransitionId } - : {}), + ...(address.previousTransitionId ? { previousTransitionId: address.previousTransitionId } : {}), ...(input.supersession ? { supersession: input.supersession } : {}), result: payload.value, }); if (!outcome) return { changed: false, archiveFailure: true }; - input.input.committed?.set(part.toolCallId, { - projection: outcome.transition.replacement, - transitionId: outcome.transition.transitionId, - }); const placeholderText = payload.field === 'output' && diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index 8a7a02085f..ece3e124e5 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -546,8 +546,6 @@ export class AgentRun { data: { runtimeEventId: transition.target.runtimeEventId, part: transition.target.part, - highWaterName: transition.highWaterName, - highWaterSeq: transition.highWaterSeq, transition, }, }); diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index e2fa1e0cdc..1626d5854c 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -1924,7 +1924,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, @@ -3472,8 +3477,10 @@ export class AiSdkBackend implements AgentBackend { ); // 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 path can resurrect content a committed - // transition removed. + // 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, diff --git a/packages/runtime/src/ai-sdk-compaction-contract.ts b/packages/runtime/src/ai-sdk-compaction-contract.ts index 47cbe972c0..dfd2f7eeef 100644 --- a/packages/runtime/src/ai-sdk-compaction-contract.ts +++ b/packages/runtime/src/ai-sdk-compaction-contract.ts @@ -21,6 +21,7 @@ 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 { ProviderRequestTracker } from './provider-request-telemetry.js'; import type { ContextBudgetPolicy } from './context-budget.js'; @@ -83,7 +84,7 @@ export type HistoryCompactCheckpointRecorder = ( checkpoint: HistoryCompactCheckpoint, turnId: string, ) => void | Promise; -export type ModelProjectionTransitionLoader = () => Promise; +export type ModelProjectionTransitionLoader = () => Promise; export type ModelProjectionTransitionLedgerRecorder = ( transition: ModelProjectionTransition, turnId: string, diff --git a/packages/runtime/src/ai-sdk-compaction.ts b/packages/runtime/src/ai-sdk-compaction.ts index aebd29f82a..e5f7c15022 100644 --- a/packages/runtime/src/ai-sdk-compaction.ts +++ b/packages/runtime/src/ai-sdk-compaction.ts @@ -89,6 +89,7 @@ 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'; @@ -228,16 +229,23 @@ export class AiSdkCompaction { } /** - * Every transition this session has committed, folded by the reducer. + * 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 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. */ - public async loadModelProjectionTransitions(): Promise { + private async loadModelProjectionTransitions(): Promise< + LoadedModelProjectionTransitions & { readable: boolean } + > { try { - return (await this.input.loadModelProjectionTransitions?.()) ?? []; + const loaded = await this.input.loadModelProjectionTransitions?.(); + return { transitions: [], undecodable: 0, ...loaded, readable: true }; } catch { - return []; + return { transitions: [], undecodable: 0, readable: false }; } } @@ -520,6 +528,20 @@ export class AiSdkCompaction { } } + /** + * 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. @@ -537,13 +559,22 @@ export class AiSdkCompaction { diagnosticPatch?: Partial; }> { const policy = this.input.contextBudget; - let transitions = await this.loadModelProjectionTransitions(); - let effective = reduceEffectiveModelProjections(runtimeContext, transitions); + const loaded = await this.loadModelProjectionTransitions(); + let transitions = loaded.transitions; + let effective = reduceEffectiveModelProjections( + runtimeContext, + transitions, + loaded.undecodable, + ); if (!policy) return { policy, events: effective.events }; let nextPolicy = policy; let diagnosticPatch: Partial | undefined; - const services = this.toolResultArchiveTransitionServices(turnId); + // 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 chainKnown = loaded.readable && effective.undecodable === 0; + const services = chainKnown ? 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 @@ -583,7 +614,11 @@ export class AiSdkCompaction { } if (committed.length > 0) { transitions = [...transitions, ...committed]; - effective = reduceEffectiveModelProjections(runtimeContext, transitions); + effective = reduceEffectiveModelProjections( + runtimeContext, + transitions, + loaded.undecodable, + ); } if (committed.length > 0 || archiveFailures > 0) { diagnosticPatch = { @@ -643,42 +678,51 @@ export class AiSdkCompaction { // this run prune content that the NEXT request would have shown again. if (!services || !this.input.loadTurnRuntimeEvents) return undefined; - // Read-through cache of what this run has already committed for a target, - // so step N+1 chains onto step N's transition instead of racing it. Derived - // state only: a restart rebuilds the same answer from the ledger. - const committed = new Map< - string, - { projection: DurableToolResultProjection; transitionId: string } - >(); - let turnEvents: RuntimeEvent[] | 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.readable || loaded.undecodable > 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 => { - for (let attempt = 0; attempt < 2; attempt += 1) { - if (!turnEvents || attempt === 1) { - try { - turnEvents = await this.input.loadTurnRuntimeEvents!(turnId); - } catch { - return undefined; - } - } - const event = turnEvents.find( - (candidate) => - candidate.partial !== true && - candidate.content?.kind === 'function_response' && - candidate.content.id === toolCallId, - ); - if (!event || event.content?.kind !== 'function_response') continue; - const projection = baseToolResultProjection(event); - if (!projection) return undefined; - return { - runtimeEventId: event.id, - turnId: event.turnId, - toolName: event.content.name, - projection, - }; - } - return undefined; + 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 } : {}), + }; }; return async (options) => { @@ -687,6 +731,9 @@ export class AiSdkCompaction { 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, @@ -704,7 +751,6 @@ export class AiSdkCompaction { ), resolveProjection, transitions: services, - committed, }); if (hasActiveToolResultPruneDiagnosticPatch(rewritten.diagnosticPatch)) { onDiagnosticPatch?.(rewritten.diagnosticPatch); diff --git a/packages/runtime/src/context-budget.ts b/packages/runtime/src/context-budget.ts index 6be300f547..1f0dd22048 100644 --- a/packages/runtime/src/context-budget.ts +++ b/packages/runtime/src/context-budget.ts @@ -47,11 +47,7 @@ export type { HistoryCompactionPolicy, HistoryCompactionReplayResult, } from './history-compaction.js'; -import type { - StaleToolResultPrunePolicy, - StaleToolResultArchiveCandidate, -} from './tool-result-archive.js'; -import { collectStaleToolResultArchiveCandidates as collectStaleToolResultArchiveCandidatesNarrow } from './tool-result-archive-transition.js'; +import type { StaleToolResultPrunePolicy } from './tool-result-archive.js'; import { type ActiveToolResultPrunePolicy } from './active-tool-result-prune.js'; import { applyRuntimeEventHistoryCompact as applyRuntimeEventHistoryCompactNarrow, @@ -365,17 +361,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 15d5233158..1f2ea94a47 100644 --- a/packages/runtime/src/conversation-copy.ts +++ b/packages/runtime/src/conversation-copy.ts @@ -66,7 +66,10 @@ import { MODEL_PROJECTION_TRANSITION_EVENT_TYPE, type ModelProjectionTransition, } from '@maka/core/model-projection-transition'; -import { baseToolResultProjection } from './model-projection-transition-ledger.js'; +import { + baseToolResultProjection, + decodeLedgerTransition, +} from './model-projection-transition-ledger.js'; import { archivedToolResultProjection } from './tool-result-archive-transition.js'; export interface ConversationCopySlice { @@ -285,6 +288,12 @@ export async function prepareConversationRuntimeLedgerCopy(input: { return { run, runtimeEvents: events, operationalEvents }; }), ); + await attachOutOfRunProjectionTransitions( + input.sourceSessionId, + sourceRuns, + runs, + input.runStore, + ); const plan = { sourceSessionId: input.sourceSessionId, copyTurnIds, @@ -295,6 +304,49 @@ export async function prepareConversationRuntimeLedgerCopy(input: { return plan; } +/** + * Carry in transitions written by runs outside the copied slice. + * + * 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. Copying by run alone + * would therefore keep the target and drop the record that replaced it, and the + * archived body would reappear in the copy — the one outcome this protocol + * exists to prevent. Each such record is attached to the run that owns its + * target; ledger append time orders a chain, because a successor can only be + * written after the predecessor it names. + */ +async function attachOutOfRunProjectionTransitions( + sessionId: string, + sourceRuns: readonly AgentRunHeader[], + runs: readonly { + readonly run: AgentRunHeader; + readonly runtimeEvents: readonly RuntimeEvent[]; + readonly operationalEvents: AgentRunEvent[]; + }[], + runStore: Pick, +): Promise { + const owningRun = new Map(); + for (const { run, runtimeEvents, operationalEvents } of runs) { + for (const event of runtimeEvents) owningRun.set(event.id, { run, operationalEvents }); + } + const copiedRunIds = new Set(runs.map(({ run }) => run.runId)); + const carried: AgentRunEvent[] = []; + for (const run of sourceRuns) { + if (copiedRunIds.has(run.runId)) continue; + for (const event of await runStore.readEvents(sessionId, run.runId)) { + const transition = decodeLedgerTransition(event, sessionId); + if (transition && owningRun.has(transition.target.runtimeEventId)) carried.push(event); + } + } + for (const event of carried.sort((left, right) => left.ts - right.ts)) { + const transition = decodeLedgerTransition(event, sessionId)!; + const owner = owningRun.get(transition.target.runtimeEventId)!; + // 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 { @@ -833,9 +885,10 @@ function cloneAgentRunEvent( transitionIds, transitionState, ); - // A transition whose target left the copied slice has nothing to replace, - // and dropping it is safe in exactly one direction: the target is absent - // too, so no replaced content can reappear. + // 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 }; } @@ -894,12 +947,9 @@ function cloneModelProjectionTransition( }, sourceProjection, replacement: archivedToolResultProjection(rewritten), - ...(source.archive ? { archive: { ...source.archive, artifactId: rewritten.artifactId } } : {}), - reason: source.reason, ...(source.previousTransitionId && transitionIds.has(source.previousTransitionId) ? { previousTransitionId: transitionIds.get(source.previousTransitionId)! } : {}), - highWaterSeq: source.highWaterSeq, now: source.createdAt, }); transitionIds.set(source.transitionId, transition.transitionId); diff --git a/packages/runtime/src/model-projection-transition-ledger.ts b/packages/runtime/src/model-projection-transition-ledger.ts index bd21935149..af7f57eb77 100644 --- a/packages/runtime/src/model-projection-transition-ledger.ts +++ b/packages/runtime/src/model-projection-transition-ledger.ts @@ -34,8 +34,10 @@ * 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, so - * ledger order, arrival order and run order cannot disagree about the result. + * 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'; @@ -64,11 +66,12 @@ export interface EffectiveModelProjectionReduction { */ rejected: ModelProjectionTransition[]; /** - * Archive artifacts the effective history still needs, derived from the fold - * rather than from a second bookkeeping path. A cleanup pass may reclaim what - * is not here; it may never reclaim what is. + * Records in the ledger this build could not decode. + * + * A reader that cannot interpret the whole chain may still show what it did + * fold, but it must not commit a successor onto a state it only partly knows. */ - reachableArchiveArtifactIds: Set; + undecodable: number; } /** @@ -81,18 +84,28 @@ export interface EffectiveModelProjectionReduction { export async function loadModelProjectionTransitionsFromRunLedger( runStore: Pick, sessionId: string, -): Promise { +): Promise { const byId = new Map(); + let undecodable = 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); - // A content-derived id makes a duplicated concurrent append idempotent. - if (transition && !byId.has(transition.transitionId)) { - byId.set(transition.transitionId, transition); + if (!transition) { + undecodable += 1; + continue; } + // A content-derived id makes a duplicated concurrent append idempotent. + if (!byId.has(transition.transitionId)) byId.set(transition.transitionId, transition); } } - return [...byId.values()]; + return { transitions: [...byId.values()], undecodable }; +} + +export interface LoadedModelProjectionTransitions { + transitions: ModelProjectionTransition[]; + /** Ledger records of the right type that this build could not decode. */ + undecodable: number; } export function decodeLedgerTransition( @@ -127,12 +140,12 @@ export function baseToolResultProjection( export function reduceEffectiveModelProjections( events: readonly RuntimeEvent[], transitions: readonly ModelProjectionTransition[], + undecodable = 0, ): EffectiveModelProjectionReduction { const applied: ModelProjectionTransition[] = []; const rejected: ModelProjectionTransition[] = []; - const reachableArchiveArtifactIds = new Set(); if (transitions.length === 0) { - return { events: [...events], applied, rejected, reachableArchiveArtifactIds }; + return { events: [...events], applied, rejected, undecodable }; } const byTarget = new Map(); @@ -158,23 +171,21 @@ export function reduceEffectiveModelProjections( let currentDigest = durableToolResultProjectionDigest(current); let previousTransitionId: string | undefined; let changed = false; - for (const transition of sortForReduction(group)) { - if ( - transition.sourceProjectionDigest !== currentDigest || - transition.previousTransitionId !== previousTransitionId || - transition.target.toolCallId !== content.id || - transition.target.toolName !== content.name - ) { - rejected.push(transition); - continue; - } - current = transition.replacement; + 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 = transition.transitionId; + previousTransitionId = next.transitionId; changed = true; - applied.push(transition); - if (transition.archive) reachableArchiveArtifactIds.add(transition.archive.artifactId); + applied.push(next); } + for (const transition of remaining) rejected.push(transition); if (!changed) return event; return { ...event, @@ -188,34 +199,36 @@ export function reduceEffectiveModelProjections( } satisfies RuntimeEvent; }); - // An archive whose transition never took effect is unreachable by - // construction: nothing in the effective history names it. - for (const transition of rejected) { - if (transition.archive) reachableArchiveArtifactIds.delete(transition.archive.artifactId); - } - for (const transition of applied) { - if (transition.archive) reachableArchiveArtifactIds.add(transition.archive.artifactId); - } - - return { events: nextEvents, applied, rejected, reachableArchiveArtifactIds }; + return { events: nextEvents, applied, rejected, undecodable }; } /** - * Total order within one target. Cursor first, then the content-derived id, so - * two runs that appended concurrently reduce identically on every reader. + * 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. */ -function sortForReduction( - group: readonly ModelProjectionTransition[], -): ModelProjectionTransition[] { - return [...group].sort((left, right) => - left.highWaterSeq !== right.highWaterSeq - ? left.highWaterSeq - right.highWaterSeq - : left.transitionId < right.transitionId - ? -1 - : left.transitionId > right.transitionId - ? 1 - : 0, - ); +function nextInChain( + remaining: ReadonlySet, + 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 { diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index f1afea8823..d86455c6d7 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -164,6 +164,7 @@ 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 { @@ -692,7 +693,7 @@ export interface BackendFactoryContext { * The reducer folds these onto the RuntimeEvent ledger, so a lossy rewrite * survives the Turn that made it. */ - loadModelProjectionTransitions?: () => Promise; + loadModelProjectionTransitions?: () => Promise; /** Durable append for one transition; persistence precedes any model-visible loss. */ recordModelProjectionTransition?: ( transition: ModelProjectionTransition, diff --git a/packages/runtime/src/tool-result-archive-transition.ts b/packages/runtime/src/tool-result-archive-transition.ts index 118d1a33e8..2b1e233b96 100644 --- a/packages/runtime/src/tool-result-archive-transition.ts +++ b/packages/runtime/src/tool-result-archive-transition.ts @@ -57,10 +57,7 @@ import { utf8ByteLength, } from './context-budget-helpers.js'; import { durableProjectionToToolResultOutput } from './durable-tool-result-projection.js'; -import { - baseToolResultProjection, - type EffectiveModelProjectionReduction, -} from './model-projection-transition-ledger.js'; +import { baseToolResultProjection } from './model-projection-transition-ledger.js'; import { ARCHIVED_TOOL_RESULT_REWRITE_VERSION, buildArchivedToolResultPlaceholder, @@ -205,21 +202,13 @@ export async function archiveToolResultAsTransition( 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), - archive: { - artifactId, - bodySha256, - originalBytes: request.originalBytes, - originalEstimatedTokens: request.originalEstimatedTokens, - }, - reason: - request.reason === 'stale_tool_result_pruned_before_compact' - ? 'stale_tool_result_archived' - : 'active_tool_result_archived', ...(request.previousTransitionId ? { previousTransitionId: request.previousTransitionId } : {}), - highWaterSeq: services.now(), now: services.now(), }); await services.recordTransition(transition); @@ -291,16 +280,15 @@ export function collectStaleToolResultArchiveCandidates( /** * Archive artifacts the effective history still needs. * - * Derived from the reduction, never from a parallel bookkeeping table: an - * artifact is reachable exactly when an applied transition or a surviving - * placeholder names it. Cleanup may reclaim the rest; a cleanup failure only - * delays reclamation and cannot break replay. + * 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. Cleanup may reclaim the rest; a cleanup failure only delays + * reclamation and cannot break replay. */ -export function collectReachableArchiveArtifactIds( - reduction: EffectiveModelProjectionReduction, -): Set { - const reachable = new Set(reduction.reachableArchiveArtifactIds); - for (const event of reduction.events) { +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)) { From c95dd52f4dea4063e8e2a6104dc57ebf77f60ada Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 21:41:50 +0800 Subject: [PATCH 6/9] fix(runtime): withhold a Tool Result whose transition record is unreadable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the reader still trusting raw history when it could not read a record. A transition written by a newer build — or damaged — was counted and then skipped, so replay showed the original body: the very record that may have removed that content became the reason for showing it again. Both the current-Turn fold and the prior-history fold did this, and a downgrade or a partially written record is enough to reach it. The envelope names the target outside the payload, so a record this build cannot read can still be confined to the one event it concerns. That event's projection is withheld — replaced with the codec's existing failure projection, the same answer this system already gives for anything it cannot represent safely — and every other record for that target is refused, since an unreadable link makes the rest of the chain's order unknown. The session keeps working; only the content that might have been removed stops being shown. A record that does not name a target cannot be confined to anything, so model-history replay fails instead. Failing is recoverable by fixing or removing the record; showing content another version removed is not. Refs #4283 Generated-by: Claude Code --- .../src/__tests__/ai-sdk-backend.test.ts | 3 +- .../execution-boundary-test-helpers.ts | 6 +- ...model-projection-transition-ledger.test.ts | 48 +++++++++++-- packages/runtime/src/ai-sdk-compaction.ts | 34 +++++---- .../src/model-projection-transition-ledger.ts | 71 +++++++++++++++---- 5 files changed, 128 insertions(+), 34 deletions(-) diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 6285b22530..0a0ca234ce 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -4497,7 +4497,8 @@ describe('AiSdkBackend model history', () => { }), loadModelProjectionTransitions: async () => ({ transitions: [...transitions], - undecodable: 0, + unreadableTargets: new Set(), + unscopedUnreadable: 0, }), recordModelProjectionTransition: async (transition) => { transitions.push(transition); diff --git a/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts index a47669cbc3..124aaceb88 100644 --- a/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts +++ b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts @@ -47,7 +47,11 @@ export function createTestAiSdkBackend(input: TestAiSdkBackendInput): AiSdkBacke const transitions: ModelProjectionTransition[] = []; return new AiSdkBackend({ readExecutionBoundary: readExternalExecutionBoundary, - loadModelProjectionTransitions: async () => ({ transitions: [...transitions], undecodable: 0 }), + loadModelProjectionTransitions: async () => ({ + transitions: [...transitions], + unreadableTargets: new Set(), + unscopedUnreadable: 0, + }), recordModelProjectionTransition: async (transition) => { transitions.push(transition); }, diff --git a/packages/runtime/src/__tests__/model-projection-transition-ledger.test.ts b/packages/runtime/src/__tests__/model-projection-transition-ledger.test.ts index d495c7a3b5..0d7a9a28e2 100644 --- a/packages/runtime/src/__tests__/model-projection-transition-ledger.test.ts +++ b/packages/runtime/src/__tests__/model-projection-transition-ledger.test.ts @@ -27,6 +27,7 @@ import { 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 { @@ -179,6 +180,38 @@ describe('effective model projection reduction', () => { 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: { @@ -325,7 +358,12 @@ describe('transition ledger reads', () => { runId === 'run-1' ? [ ledgerEvent(transition.transitionId, { transition }), - ledgerEvent('broken', { transition: { kind: 'nonsense' } }), + ledgerEvent('broken', { + runtimeEventId: 'rt-1', + part: 'tool_result', + transition: { kind: 'nonsense' }, + }), + ledgerEvent('broken-unscoped', { transition: { kind: 'nonsense' } }), ] : [ledgerEvent(`${transition.transitionId}-replay`, { transition })], }; @@ -336,9 +374,11 @@ describe('transition ledger reads', () => { loaded.transitions.map((entry) => entry.transitionId), [transition.transitionId], ); - // A record of the right type this build cannot decode is reported, not - // silently treated as "no transition here". - assert.equal(loaded.undecodable, 1); + // 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', () => { diff --git a/packages/runtime/src/ai-sdk-compaction.ts b/packages/runtime/src/ai-sdk-compaction.ts index e5f7c15022..eadbb8aed5 100644 --- a/packages/runtime/src/ai-sdk-compaction.ts +++ b/packages/runtime/src/ai-sdk-compaction.ts @@ -238,15 +238,21 @@ export class AiSdkCompaction { * what it folded, but it may not append a successor onto a state it only * partly knows. */ - private async loadModelProjectionTransitions(): Promise< - LoadedModelProjectionTransitions & { readable: boolean } - > { - try { - const loaded = await this.input.loadModelProjectionTransitions?.(); - return { transitions: [], undecodable: 0, ...loaded, readable: true }; - } catch { - return { transitions: [], undecodable: 0, readable: false }; + 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; } /** @@ -564,7 +570,7 @@ export class AiSdkCompaction { let effective = reduceEffectiveModelProjections( runtimeContext, transitions, - loaded.undecodable, + loaded.unreadableTargets, ); if (!policy) return { policy, events: effective.events }; let nextPolicy = policy; @@ -573,8 +579,10 @@ export class AiSdkCompaction { // 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 chainKnown = loaded.readable && effective.undecodable === 0; - const services = chainKnown ? this.toolResultArchiveTransitionServices(turnId) : undefined; + 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 @@ -617,7 +625,7 @@ export class AiSdkCompaction { effective = reduceEffectiveModelProjections( runtimeContext, transitions, - loaded.undecodable, + loaded.unreadableTargets, ); } if (committed.length > 0 || archiveFailures > 0) { @@ -685,7 +693,7 @@ export class AiSdkCompaction { const loadEffectiveTurnEvents = async (): Promise => { if (effective) return effective; const loaded = await this.loadModelProjectionTransitions(); - if (!loaded.readable || loaded.undecodable > 0) return undefined; + if (loaded.unreadableTargets.size > 0) return undefined; let turnEvents: RuntimeEvent[]; try { turnEvents = await this.input.loadTurnRuntimeEvents!(turnId); diff --git a/packages/runtime/src/model-projection-transition-ledger.ts b/packages/runtime/src/model-projection-transition-ledger.ts index af7f57eb77..b137e283bd 100644 --- a/packages/runtime/src/model-projection-transition-ledger.ts +++ b/packages/runtime/src/model-projection-transition-ledger.ts @@ -47,7 +47,10 @@ import { MODEL_PROJECTION_TRANSITION_EVENT_TYPE, type ModelProjectionTransition, } from '@maka/core/model-projection-transition'; -import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection'; +import { + DURABLE_TOOL_RESULT_PROJECTION_FAILURE, + type DurableToolResultProjection, +} from '@maka/core/durable-tool-result-projection'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { @@ -66,12 +69,13 @@ export interface EffectiveModelProjectionReduction { */ rejected: ModelProjectionTransition[]; /** - * Records in the ledger this build could not decode. + * Targets that carry a record this build could not decode. * - * A reader that cannot interpret the whole chain may still show what it did - * fold, but it must not commit a successor onto a state it only partly knows. + * 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. */ - undecodable: number; + unreadableTargets: Set; } /** @@ -86,26 +90,46 @@ export async function loadModelProjectionTransitionsFromRunLedger( sessionId: string, ): Promise { const byId = new Map(); - let undecodable = 0; + 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) { - undecodable += 1; + // 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()], undecodable }; + return { transitions: [...byId.values()], unreadableTargets, unscopedUnreadable }; } export interface LoadedModelProjectionTransitions { transitions: ModelProjectionTransition[]; - /** Ledger records of the right type that this build could not decode. */ - undecodable: number; + /** 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( @@ -140,12 +164,12 @@ export function baseToolResultProjection( export function reduceEffectiveModelProjections( events: readonly RuntimeEvent[], transitions: readonly ModelProjectionTransition[], - undecodable = 0, + unreadableTargets: ReadonlySet = new Set(), ): EffectiveModelProjectionReduction { const applied: ModelProjectionTransition[] = []; const rejected: ModelProjectionTransition[] = []; - if (transitions.length === 0) { - return { events: [...events], applied, rejected, undecodable }; + if (transitions.length === 0 && unreadableTargets.size === 0) { + return { events: [...events], applied, rejected, unreadableTargets: new Set() }; } const byTarget = new Map(); @@ -157,7 +181,24 @@ export function reduceEffectiveModelProjections( } const nextEvents = events.map((event) => { - const group = byTarget.get(targetKey(event.id, 'tool_result')); + 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) { @@ -199,7 +240,7 @@ export function reduceEffectiveModelProjections( } satisfies RuntimeEvent; }); - return { events: nextEvents, applied, rejected, undecodable }; + return { events: nextEvents, applied, rejected, unreadableTargets: new Set(unreadableTargets) }; } /** From 47a782ef4e9dafa88897f1b5156af80b10e16d8b Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 22:14:05 +0800 Subject: [PATCH 7/9] fix(storage): reclaim only the projection artifacts a plan itself published Artifact ids are derived from the bytes and `create` replays an existing record idempotently, so a projection that repeats an image an earlier projection already published gets that record back. The planner read every successful create as its own publication, so retracting the second plan deleted the artifact the first, already-committed projection still points at: a Tool Result the model can still see becomes an artifact that reads back as deleted. Publication now carries a receipt. The writer facade probes and creates under one write lease and reports whether that call is the one that published; a plan retracts only what it published. A caller without the receipt seam does not retract at all, which is the same conservative outcome as having no reclaim seam. Refs #4283 Generated-by: Claude Code --- .../src/server/execution-model-composition.ts | 2 +- .../__tests__/artifact-attachments.test.ts | 45 +++++++++++++++ packages/storage/src/artifact-attachments.ts | 56 ++++++++++++------- packages/storage/src/artifact-stores.ts | 23 ++++++++ 4 files changed, 106 insertions(+), 20 deletions(-) diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index f67bfc5905..29cda5af68 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -91,7 +91,7 @@ type HostExecutionRuntimePolicyAuthority = { type HostExecutionArtifactAuthority = Pick< InteractiveArtifactStoreWriter, - 'create' | 'readDurableAttachmentBinary' | 'deleteOwnedArtifactInSession' + 'create' | 'createOwned' | 'readDurableAttachmentBinary' | 'deleteOwnedArtifactInSession' >; type HostExecutionUsageAuthority = { 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 f55e7fb9e6..07d06c66c2 100644 --- a/packages/storage/src/artifact-attachments.ts +++ b/packages/storage/src/artifact-attachments.ts @@ -150,8 +150,16 @@ export interface ReadImageSnapshotPlan { retract(): Promise; } +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: Pick, + artifactStore: ReadImageSnapshotArtifactStore, /** * Narrow reclaim for a `tool_result_projection` artifact this planner * published. Optional so callers that cannot reclaim still get the planner; @@ -191,7 +199,11 @@ export function createReadImageSnapshotPlanner( .update(accepted.bytes) .digest('hex')}`; let publication: Promise | undefined; - let published = false; + // 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, @@ -200,26 +212,32 @@ export function createReadImageSnapshotPlanner( return Object.freeze({ ref, persist() { - publication ??= artifactStore - .create({ - id, - sessionId: accepted.sessionId, - turnId: accepted.turnId, - name: accepted.name, - kind: 'image', - content: accepted.bytes, - mimeType: accepted.mimeType, - source: 'tool_result_projection', - }) - .then((artifact) => { - if (artifact.id !== id) throw new Error('Artifact publication changed its planned id'); - published = true; - }); + 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 (!published || !retractPublished) return; - published = false; + 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 35b4ef2444..1a7a46f3a4 100644 --- a/packages/storage/src/artifact-stores.ts +++ b/packages/storage/src/artifact-stores.ts @@ -59,6 +59,18 @@ export interface InteractiveArtifactStoreWriter extends DurableArtifactAttachmen readonly [writerBrand]: true; recover(): Promise; create(input: CreateArtifactInput): 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. * @@ -154,6 +166,17 @@ function createWriterFacade( const acceptedInput = snapshotCreateInput(input); return run(() => store.create(acceptedInput)); }, + 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); From 4f7f0896e37ca9288047590ffc0066133fc82d31 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 22:14:21 +0800 Subject: [PATCH 8/9] fix(runtime): show the transition the fold accepts, not the one just appended MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A successful append does not make a transition the reducer's answer. Two Turns can load the same source projection and append rival roots, and the fold deliberately accepts exactly one of them — the smaller content-derived id — so a writer that showed its own replacement could have the next read swap the placeholder underneath the model, metadata and all. The writer now re-reads the ledger after appending and returns whatever the chain settles on for that target, using the same successor rule the fold uses rather than a second copy of it. A writer whose record lost shows the winner's placeholder; its own record stays durable and inert, and the body it archived stays unreachable exactly as a refused transition's archive should be. Also corrects a claim these comments were making: reducer-derived reachability is the authority a reclaiming pass must ask, but no such pass exists yet, so an archive whose transition append failed is retained rather than reclaimed. Saying "cleanup may reclaim the rest" described a pass that is not there. Refs #4283 Generated-by: Claude Code --- ...model-projection-transition-ledger.test.ts | 39 ++++++++++++++ packages/runtime/src/ai-sdk-compaction.ts | 1 + .../src/model-projection-transition-ledger.ts | 4 +- .../src/tool-result-archive-transition.ts | 54 +++++++++++++++---- 4 files changed, 86 insertions(+), 12 deletions(-) diff --git a/packages/runtime/src/__tests__/model-projection-transition-ledger.test.ts b/packages/runtime/src/__tests__/model-projection-transition-ledger.test.ts index 0d7a9a28e2..c7fb260528 100644 --- a/packages/runtime/src/__tests__/model-projection-transition-ledger.test.ts +++ b/packages/runtime/src/__tests__/model-projection-transition-ledger.test.ts @@ -300,6 +300,45 @@ describe('durable transition writer', () => { 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( diff --git a/packages/runtime/src/ai-sdk-compaction.ts b/packages/runtime/src/ai-sdk-compaction.ts index eadbb8aed5..18ecac5277 100644 --- a/packages/runtime/src/ai-sdk-compaction.ts +++ b/packages/runtime/src/ai-sdk-compaction.ts @@ -270,6 +270,7 @@ export class AiSdkCompaction { sessionId: this.sessionId, archiveToolResult: (candidate) => archive(candidate), recordTransition: (transition) => record(transition, turnId), + loadTransitions: () => this.loadModelProjectionTransitions(), now: this.now, }; } diff --git a/packages/runtime/src/model-projection-transition-ledger.ts b/packages/runtime/src/model-projection-transition-ledger.ts index b137e283bd..93cd9aec2e 100644 --- a/packages/runtime/src/model-projection-transition-ledger.ts +++ b/packages/runtime/src/model-projection-transition-ledger.ts @@ -251,8 +251,8 @@ export function reduceEffectiveModelProjections( * 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. */ -function nextInChain( - remaining: ReadonlySet, +export function nextInChain( + remaining: Iterable, previousTransitionId: string | undefined, currentDigest: string, content: { id: string; name: string }, diff --git a/packages/runtime/src/tool-result-archive-transition.ts b/packages/runtime/src/tool-result-archive-transition.ts index 2b1e233b96..f96588a4e8 100644 --- a/packages/runtime/src/tool-result-archive-transition.ts +++ b/packages/runtime/src/tool-result-archive-transition.ts @@ -32,8 +32,8 @@ * * 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 reclaimable — and again leaves the - * projection untouched. + * 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 @@ -57,7 +57,7 @@ import { utf8ByteLength, } from './context-budget-helpers.js'; import { durableProjectionToToolResultOutput } from './durable-tool-result-projection.js'; -import { baseToolResultProjection } from './model-projection-transition-ledger.js'; +import { baseToolResultProjection, nextInChain } from './model-projection-transition-ledger.js'; import { ARCHIVED_TOOL_RESULT_REWRITE_VERSION, buildArchivedToolResultPlaceholder, @@ -118,6 +118,15 @@ export interface ToolResultArchiveTransitionServices { 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; } @@ -212,18 +221,40 @@ export async function archiveToolResultAsTransition( 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 exactly what reducer-derived reachability - // reports. It is also content-addressed, so a later retry of the same - // decision reuses this artifact rather than publishing a second one — - // reclamation may be delayed, but it cannot grow without bound and cannot - // break replay. + // 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. * @@ -283,8 +314,11 @@ export function collectStaleToolResultArchiveCandidates( * 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. Cleanup may reclaim the rest; a cleanup failure only delays - * reclamation and cannot break replay. + * 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(); From 5ea474c0b116fcb615c4a9a53cb91d26e1edc420 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 22:14:21 +0800 Subject: [PATCH 9/9] fix(runtime): copy the transition chain the source fold applied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conversation copy rebuilt transitions in the order it happened to encounter them and, when a predecessor had not been mapped, quietly turned its successor into a root. Neither matches the source's authority: rival roots are resolved by content-derived id, not by run or timestamp, and a chain whose predecessor is missing is not a chain. A copy could therefore apply a transition the source fold had refused, showing a placeholder the source never showed. The source reduction now decides here too. Every record for a copied target is gathered — including from runs outside the copied slice, since a prior-Turn archive is recorded by a later run — folded once, and only the applied chain is rebuilt, in fold order. A predecessor that cannot be mapped fails the copy instead of being dropped, and an unreadable record for a copied target fails it too, rather than silently losing whatever that record removed. Refs #4283 Generated-by: Claude Code --- .../src/__tests__/conversation-copy.test.ts | 179 ++++++++++++++++++ packages/runtime/src/conversation-copy.ts | 99 +++++++--- 2 files changed, 254 insertions(+), 24 deletions(-) diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts index 1cad10f11b..5a633008e7 100644 --- a/packages/runtime/src/__tests__/conversation-copy.test.ts +++ b/packages/runtime/src/__tests__/conversation-copy.test.ts @@ -2968,6 +2968,185 @@ test('conversation copy carries a transition recorded by a later, uncopied run', } }); +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/conversation-copy.ts b/packages/runtime/src/conversation-copy.ts index 1f2ea94a47..0ef2a87b0b 100644 --- a/packages/runtime/src/conversation-copy.ts +++ b/packages/runtime/src/conversation-copy.ts @@ -69,6 +69,7 @@ import { import { baseToolResultProjection, decodeLedgerTransition, + reduceEffectiveModelProjections, } from './model-projection-transition-ledger.js'; import { archivedToolResultProjection } from './tool-result-archive-transition.js'; @@ -288,12 +289,7 @@ export async function prepareConversationRuntimeLedgerCopy(input: { return { run, runtimeEvents: events, operationalEvents }; }), ); - await attachOutOfRunProjectionTransitions( - input.sourceSessionId, - sourceRuns, - runs, - input.runStore, - ); + await rebuildCopiedProjectionTransitions(input.sourceSessionId, sourceRuns, runs, input.runStore); const plan = { sourceSessionId: input.sourceSessionId, copyTurnIds, @@ -305,17 +301,21 @@ export async function prepareConversationRuntimeLedgerCopy(input: { } /** - * Carry in transitions written by runs outside the copied slice. + * 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. * - * 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. Copying by run alone - * would therefore keep the target and drop the record that replaced it, and the - * archived body would reappear in the copy — the one outcome this protocol - * exists to prevent. Each such record is attached to the run that owns its - * target; ledger append time orders a chain, because a successor can only be - * written after the predecessor it names. + * 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 attachOutOfRunProjectionTransitions( +async function rebuildCopiedProjectionTransitions( sessionId: string, sourceRuns: readonly AgentRunHeader[], runs: readonly { @@ -326,21 +326,63 @@ async function attachOutOfRunProjectionTransitions( 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 }); + 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)); - const carried: AgentRunEvent[] = []; + for (const { operationalEvents } of runs) collect(operationalEvents); for (const run of sourceRuns) { if (copiedRunIds.has(run.runId)) continue; - for (const event of await runStore.readEvents(sessionId, run.runId)) { - const transition = decodeLedgerTransition(event, sessionId); - if (transition && owningRun.has(transition.target.runtimeEventId)) carried.push(event); + 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 event of carried.sort((left, right) => left.ts - right.ts)) { - const transition = decodeLedgerTransition(event, sessionId)!; + + 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 }); @@ -947,8 +989,17 @@ function cloneModelProjectionTransition( }, sourceProjection, replacement: archivedToolResultProjection(rewritten), - ...(source.previousTransitionId && transitionIds.has(source.previousTransitionId) - ? { previousTransitionId: transitionIds.get(source.previousTransitionId)! } + // 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, });