diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 4eb2376355..6a7cdac267 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -98,7 +98,7 @@ test('production composition owns the long-term memory database lifecycle', asyn }); }); -test('production composition reaches Ready when the optional context reader cannot open', async () => { +test('production composition reaches Ready when the optional context Store cannot open', async () => { await withCompositionRoot(async ({ root, owner }) => { await mkdir(join(root, CONTEXT_OFFLOAD_DATABASE_NAME)); const originalConsoleError = console.error; @@ -109,7 +109,7 @@ test('production composition reaches Ready when the optional context reader cann composition = await createExecutionRuntimeHostComposition(compositionContext(owner)); assert.equal(composition.workspaceExecution.state, 'ready'); assert.equal( - diagnostics.some((message) => message.includes('optional context-offload reader')), + diagnostics.some((message) => message.includes('optional context-offload Store')), true, ); } finally { diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 24c14a6f89..21a1a2f4f4 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -134,6 +134,10 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 22); }); + test('publishes a new compatibility epoch for Read image Session context refs', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 67); + }); + test('rejects the legacy connection update result in the current compatibility epoch', () => { assert.throws( () => diff --git a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts index e0ec3dbca3..4a5f4f5da1 100644 --- a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts @@ -228,7 +228,7 @@ describe('Host Session retirement coordinator', () => { 'parent retirement cleanup did not converge', ); assert.deepEqual(new Set(harness.actions.purgedArtifacts), new Set(harness.familyIds)); - assert.deepEqual(new Set(harness.actions.checkedContext), new Set(harness.familyIds)); + assert.deepEqual(new Set(harness.actions.retiredContext), new Set(harness.familyIds)); }); }); @@ -1009,7 +1009,7 @@ interface RetirementActions { readonly retiredCapabilities: string[]; readonly retiredMessages: string[]; readonly purgedArtifacts: string[]; - readonly checkedContext: string[]; + readonly retiredContext: string[]; readonly purgedTasks: string[]; readonly purgedOperationalState: string[]; readonly purgedAgentGraphs: string[]; @@ -1048,7 +1048,7 @@ async function withHarness( retiredCapabilities: [], retiredMessages: [], purgedArtifacts: [], - checkedContext: [], + retiredContext: [], purgedTasks: [], purgedOperationalState: [], purgedAgentGraphs: [], @@ -1214,8 +1214,11 @@ async function withHarness( actions.purgedTasks.push(sessionId); }, }, - assertNoContextOffloadReferences: async (sessionIds) => { - actions.checkedContext.push(...sessionIds); + contextOffload: { + retireSession: async (sessionId) => { + actions.retiredContext.push(sessionId); + return { releasedReferences: 0, releasedLogicalBytes: 0 }; + }, }, purgeOperationalState: async (sessionId) => { actions.purgedOperationalState.push(sessionId); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index a359bcdefd..c91a9de81f 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -94,7 +94,8 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 67 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 68 as const; +// 68: Read image tool results may carry durable `session_context` refs. // 67: Message lifecycle queries expose durable execution ownership and // cancellation. Older peers cannot decode or provide the closed proof list. // 66: Peer Mesh queries expose one canonical transit selection and runtime metrics. diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 1f59857dff..54b8439830 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -77,14 +77,9 @@ import { import { type MakaTool } from '@maka/runtime/tool-runtime'; import { type RuntimeHostedRootAuthority } from '@maka/runtime/message-authority'; import { createAgentGraphControlStore } from '@maka/storage/agent-graph-control-store'; -import { - createArtifactAttachmentResourceReader, - createReadImageSnapshotter, -} from '@maka/storage/artifact-stores'; -import { - isSessionNotFoundError, - SessionMetadataConflictError, -} from '@maka/storage/execution-stores'; +import { createArtifactAttachmentResourceReader } from '@maka/storage/artifact-stores'; +import { createReadImageSnapshotStore } from '@maka/storage/read-image-snapshot-store'; +import { isSessionNotFoundError } from '@maka/storage/execution-stores'; import { createExternalSessionAdapterRegistry } from '@maka/storage/external-sessions'; import { createGitWorktreeChildExecutor } from '@maka/storage/git-worktree-child-executor'; import { runWithStorageRootLease } from '@maka/storage/root-authority'; @@ -202,15 +197,16 @@ export interface ExecutionRuntimeHostComposition extends RuntimeHostComposition readonly workspaceExecution: RuntimeHostWorkspaceExecutionComposition; } -const CONTEXT_OFFLOAD_READER_LIMITS: ContextOffloadLimits = Object.freeze({ +const GIBIBYTE = 1024 * 1024 * 1024; +const CONTEXT_OFFLOAD_LIMITS: ContextOffloadLimits = Object.freeze({ ownerMaxBytes: Object.freeze({ read_image_snapshot: MAX_READ_IMAGE_BYTES, tool_result_archive: 0, }), - // This expand slice opens only the reader path. Zero quotas make accidental - // non-empty puts fail closed until the writer/lifecycle cutover lands. - sessionLogicalBytes: 0, - workspacePhysicalBytes: 0, + // Read images are bounded individually and logically per Session. Physical + // bytes are content-addressed across Sessions and bounded per workspace. + sessionLogicalBytes: GIBIBYTE, + workspacePhysicalBytes: 20 * GIBIBYTE, }); export interface CreateExecutionRuntimeHostCompositionOptions { @@ -239,7 +235,7 @@ export async function createExecutionRuntimeHostComposition( dependencies: ExecutionRuntimeHostCompositionDependencies = {}, ): Promise { const storage = await openStorageWriterComposition(context.owner.lease, { - contextOffloadLimits: CONTEXT_OFFLOAD_READER_LIMITS, + contextOffloadLimits: CONTEXT_OFFLOAD_LIMITS, afterRuntimePolicyOpened: async (stores) => { if (options.bootstrapRuntimePolicy !== false) { await ensureBootstrapRuntimePolicy({ @@ -255,7 +251,7 @@ export async function createExecutionRuntimeHostComposition( }); if (storage.contextOffloadUnavailable) { console.error( - `[runtime-host] optional context-offload reader could not be opened: ${generalizedErrorMessage(storage.contextOffloadUnavailable.cause)}`, + `[runtime-host] optional context-offload Store could not be opened: ${generalizedErrorMessage(storage.contextOffloadUnavailable.cause)}`, ); } const stores = storage.execution; @@ -285,6 +281,17 @@ export async function createExecutionRuntimeHostComposition( const openedContextOffloadReader = openedContextOffloadStore ? createInteractiveContextOffloadReader(openedContextOffloadStore) : undefined; + const contextOffloadRetirement = openedContextOffloadStore + ? openedContextOffloadStore + : storage.contextOffloadUnavailable + ? { + retireSession: async (_sessionId: string): Promise => { + throw new Error('Context-offload Store is unavailable during Session retirement', { + cause: storage.contextOffloadUnavailable?.cause, + }); + }, + } + : undefined; const openedUsageStores = storage.usage; const openedShellRunStore = storage.shellRuns; const worktreeChildExecutor = createGitWorktreeChildExecutor({ @@ -395,7 +402,21 @@ export async function createExecutionRuntimeHostComposition( }), backgroundTasks: runtimeResources, ptyControls: runtimeResources, - snapshotImage: createReadImageSnapshotter(openedArtifactStore), + ...(openedContextOffloadStore + ? { + snapshotImage: async (input: { + readonly sessionId: string; + readonly ownerId: string; + readonly bytes: Uint8Array; + readonly mimeType: string; + }) => + createReadImageSnapshotStore(openedContextOffloadStore, input.sessionId).snapshot({ + ownerId: input.ownerId, + bytes: input.bytes, + mimeType: input.mimeType, + }), + } + : {}), ...(sandboxManager ? { sandboxManager } : {}), ...(filesystemWorker ? { filesystemWorker } : {}), }; @@ -1437,6 +1458,7 @@ export async function createExecutionRuntimeHostComposition( stores, artifacts: openedArtifactStore, taskLedger: taskLedgerStore, + ...(openedContextOffloadStore ? { contextOffload: openedContextOffloadStore } : {}), manager, admission: sessionAdmission, continuity: continuityCoordinator, @@ -1461,20 +1483,7 @@ export async function createExecutionRuntimeHostComposition( continuity: continuityCoordinator, artifacts: openedArtifactStore, taskLedger: taskLedgerStore, - assertNoContextOffloadReferences: async (sessionIds) => { - if (!openedContextOffloadStore) { - throw new Error('Context-offload reader is unavailable during Session removal', { - cause: storage.contextOffloadUnavailable?.cause, - }); - } - for (const sessionId of sessionIds) { - if ((await openedContextOffloadStore.usage(sessionId)).references > 0) { - throw new SessionMetadataConflictError( - 'Session removal does not support Session context references yet', - ); - } - } - }, + ...(contextOffloadRetirement ? { contextOffload: contextOffloadRetirement } : {}), purgeOperationalState: async (sessionId) => { await stores.purgeConversationOperationalState(sessionId); await openedPlanStore.purgeSessionState(sessionId); diff --git a/packages/runtime-host/src/server/session-retirement-coordinator.ts b/packages/runtime-host/src/server/session-retirement-coordinator.ts index c3cb224fe7..902c1f1090 100644 --- a/packages/runtime-host/src/server/session-retirement-coordinator.ts +++ b/packages/runtime-host/src/server/session-retirement-coordinator.ts @@ -32,6 +32,7 @@ import { } from '@maka/storage/execution-stores'; import { type SessionManager } from '@maka/runtime/session-manager'; import type { InteractiveTaskLedgerWriter } from '@maka/storage/task-ledger-authority'; +import type { InteractiveContextOffloadWriter } from '@maka/storage/context-offload-store'; import { type OperationOutcome, type SessionCatalogItem, @@ -121,7 +122,7 @@ export interface HostSessionRetirementCoordinatorOptions { readonly continuity: RetirementContinuity; readonly artifacts: Pick; readonly taskLedger: Pick; - readonly assertNoContextOffloadReferences?: (sessionIds: readonly string[]) => Promise; + readonly contextOffload?: Pick; readonly purgeOperationalState: (sessionId: string) => Promise; readonly purgeAgentGraphState: (sessionId: string) => Promise; readonly worktrees?: Pick; @@ -192,7 +193,7 @@ export class HostSessionRetirementCoordinator { readonly #continuity: RetirementContinuity; readonly #artifacts: HostSessionRetirementCoordinatorOptions['artifacts']; readonly #taskLedger: HostSessionRetirementCoordinatorOptions['taskLedger']; - readonly #assertNoContextOffloadReferences: HostSessionRetirementCoordinatorOptions['assertNoContextOffloadReferences']; + readonly #contextOffload: HostSessionRetirementCoordinatorOptions['contextOffload']; readonly #purgeOperationalState: HostSessionRetirementCoordinatorOptions['purgeOperationalState']; readonly #purgeAgentGraphState: HostSessionRetirementCoordinatorOptions['purgeAgentGraphState']; readonly #worktrees: HostSessionRetirementCoordinatorOptions['worktrees']; @@ -220,7 +221,7 @@ export class HostSessionRetirementCoordinator { this.#continuity = options.continuity; this.#artifacts = options.artifacts; this.#taskLedger = options.taskLedger; - this.#assertNoContextOffloadReferences = options.assertNoContextOffloadReferences; + this.#contextOffload = options.contextOffload; this.#purgeOperationalState = options.purgeOperationalState; this.#purgeAgentGraphState = options.purgeAgentGraphState; this.#worktrees = options.worktrees; @@ -336,7 +337,6 @@ export class HostSessionRetirementCoordinator { if (plan.archive.sessionIds.length > 0) { archiveHandles = await this.#prepareRetirement(plan.archive, 'archive'); } - await this.#assertNoContextOffloadReferences?.(plan.remove.sessionIds); const allSessionIds = [...plan.remove.sessionIds, ...plan.archive.sessionIds]; await this.#finalizeWorkspacePatches(allSessionIds); await this.#disposeBackends(allSessionIds); @@ -672,6 +672,7 @@ export class HostSessionRetirementCoordinator { { artifacts: this.#artifacts, taskLedger: this.#taskLedger, + ...(this.#contextOffload ? { contextOffload: this.#contextOffload } : {}), purgeOperationalState: this.#purgeOperationalState, }, sessionId, diff --git a/packages/runtime-host/src/server/session-revision-coordinator.ts b/packages/runtime-host/src/server/session-revision-coordinator.ts index 5768558101..66d03d7e4b 100644 --- a/packages/runtime-host/src/server/session-revision-coordinator.ts +++ b/packages/runtime-host/src/server/session-revision-coordinator.ts @@ -35,6 +35,7 @@ import { archivedToolResultContainsConversationOwnedReferences, cloneConversationRuntimeLedger, collectConversationCopyLinkedChildReferences, + collectConversationCopySessionContextRefIds, createConversationCopySlice, prepareConversationRuntimeLedgerCopy, type ConversationRuntimeLedgerCopyPlan, @@ -54,6 +55,7 @@ import { authenticateInteractiveTaskLedgerWriter, type InteractiveTaskLedgerWriter, } from '@maka/storage/task-ledger-authority'; +import type { InteractiveContextOffloadWriter } from '@maka/storage/context-offload-store'; import type { OperationOutcome, SessionConversationCopyInput, @@ -99,6 +101,10 @@ export interface HostSessionRevisionCoordinatorOptions { readonly stores: ExecutionStoresWriter<'interactive'>; readonly artifacts: InteractiveArtifactStoreWriter; readonly taskLedger: InteractiveTaskLedgerWriter; + readonly contextOffload?: Pick< + InteractiveContextOffloadWriter, + 'copyReferences' | 'retireSession' + >; readonly manager: SessionManager; readonly admission: SessionAdmissionGate; readonly continuity: SessionContinuityCoordinator; @@ -489,6 +495,28 @@ export class HostSessionRevisionCoordinator { ) .map(({ descriptor, serializedResult }) => [descriptor.artifactId, serializedResult]), ); + const sourceContextRefIds = collectConversationCopySessionContextRefIds({ + sourceSessionId: input.sourceSessionId, + copiedMessages: slice.messages, + plan, + }); + if (sourceContextRefIds.length > 0 && !this.options.contextOffload) { + throw new Error('Session context copy authority is unavailable'); + } + const contextCopy = + sourceContextRefIds.length === 0 + ? { ok: true as const, copied: [] } + : await this.options.contextOffload!.copyReferences({ + sourceSessionId: input.sourceSessionId, + targetSessionId: input.targetSessionId, + references: sourceContextRefIds.map((sourceRefId) => ({ + sourceRefId, + targetOwner: { kind: 'read_image_snapshot', ownerId: sourceRefId }, + })), + }); + if (!contextCopy.ok) { + throw new Error(`Session context references could not be copied: ${contextCopy.reason}`); + } const artifactCopy = await this.#artifacts.copyConversationArtifacts({ sourceSessionId: input.sourceSessionId, targetSessionId: input.targetSessionId, @@ -511,6 +539,9 @@ export class HostSessionRevisionCoordinator { targetSessionId: input.targetSessionId, artifactIds: artifactCopy.artifactIds, relativePaths: artifactCopy.relativePaths, + contextRefs: new Map( + contextCopy.copied.map(({ sourceRefId, targetRefId }) => [sourceRefId, targetRefId]), + ), linkedChildren: kind === 'side_conversation' ? { @@ -803,6 +834,7 @@ export class HostSessionRevisionCoordinator { { artifacts: this.#artifacts, taskLedger: this.#taskLedger, + ...(this.options.contextOffload ? { contextOffload: this.options.contextOffload } : {}), purgeOperationalState: (sessionId) => this.#stores.purgeConversationOperationalState(sessionId), }, diff --git a/packages/runtime-host/src/server/session-sidecar-purge.ts b/packages/runtime-host/src/server/session-sidecar-purge.ts index b1cdbcf2e2..ea5d542753 100644 --- a/packages/runtime-host/src/server/session-sidecar-purge.ts +++ b/packages/runtime-host/src/server/session-sidecar-purge.ts @@ -19,10 +19,12 @@ import type { InteractiveArtifactStoreWriter } from '@maka/storage/artifact-stores'; import type { InteractiveTaskLedgerWriter } from '@maka/storage/task-ledger-authority'; +import type { InteractiveContextOffloadWriter } from '@maka/storage/context-offload-store'; export interface SessionSidecarPurgeAuthority { readonly artifacts: Pick; readonly taskLedger: Pick; + readonly contextOffload?: Pick; readonly purgeOperationalState: (sessionId: string) => Promise; } @@ -33,6 +35,7 @@ export async function purgeSessionSidecars( const outcomes = await Promise.allSettled([ authority.artifacts.purgeSessionArtifacts(sessionId), authority.taskLedger.purgeConversationTaskLedger(sessionId), + ...(authority.contextOffload ? [authority.contextOffload.retireSession(sessionId)] : []), authority.purgeOperationalState(sessionId), ]); const failures = outcomes.flatMap((outcome) => diff --git a/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts b/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts index bc0eaa5886..fe9c9a1c24 100644 --- a/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts +++ b/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts @@ -169,6 +169,7 @@ describe('builtin file tools use the sandboxed worker', () => { test('uses one worker read operation for image paths', async () => { const cwd = await temporaryDirectory('maka-file-worker-cwd-'); const calls: FilesystemWorkerExecuteInput[] = []; + let snapshotOwnerId: string | undefined; const tools = buildBuiltinTools({ filesystemWorker: { execute: async (input) => { @@ -176,11 +177,14 @@ describe('builtin file tools use the sandboxed worker', () => { return { kind: 'read_image', base64: 'iVBORw0KGgo=', mimeType: 'image/png' }; }, }, - snapshotImage: async () => ({ - kind: 'session_file', - sessionId: 'session-1', - relativePath: 'artifact-1', - }), + snapshotImage: async (input) => { + snapshotOwnerId = input.ownerId; + return { + kind: 'session_context', + sessionId: 'session-1', + refId: 'context-1', + }; + }, sandboxPlatform: 'darwin', }); @@ -188,6 +192,7 @@ describe('builtin file tools use the sandboxed worker', () => { assert.equal(calls.length, 1); assert.deepEqual(calls[0]?.operation, { kind: 'read', path: 'image.png', offset: 1, limit: 1 }); + assert.equal(snapshotOwnerId, 'tool-Read'); }); test('serializes writes through real and symlinked cwd paths', async () => { diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts index 7d7db16e9e..e26f20f525 100644 --- a/packages/runtime/src/__tests__/conversation-copy.test.ts +++ b/packages/runtime/src/__tests__/conversation-copy.test.ts @@ -38,6 +38,7 @@ import { archivedToolResultContainsConversationOwnedReferences, cloneConversationRuntimeLedger, collectConversationCopyLinkedChildReferences, + collectConversationCopySessionContextRefIds, createConversationCopySlice, prepareConversationRuntimeLedgerCopy, rewriteConversationCopyMessage, @@ -604,6 +605,17 @@ test('conversation copy rewrites owned references without changing opaque tool p relativePath: 'session-source/artifact-source-file.txt', }, }, + { + kind: 'image', + name: 'snapshot.png', + mimeType: 'image/png', + bytes: 4, + ref: { + kind: 'session_context', + sessionId: 'session-source', + refId: 'context-source', + }, + }, ], }, { @@ -683,6 +695,7 @@ test('conversation copy rewrites owned references without changing opaque tool p relativePaths: new Map([ ['session-source/artifact-source-file.txt', 'session-target/artifact-target-file.txt'], ]), + contextRefs: new Map([['context-source', 'context-target']]), runIds: new Map([['run-source', 'run-target']]), invocationIds: new Map([['invocation-source', 'invocation-target']]), runtimeEventIds: new Map([['event-source', 'event-target']]), @@ -695,6 +708,32 @@ test('conversation copy rewrites owned references without changing opaque tool p sessionId: 'session-target', relativePath: 'session-target/artifact-target-file.txt', }); + assert.deepEqual(rewritten[0]?.type === 'user' ? rewritten[0].attachments?.[1]?.ref : undefined, { + kind: 'session_context', + sessionId: 'session-target', + refId: 'context-target', + }); + assert.deepEqual( + collectConversationCopySessionContextRefIds({ + sourceSessionId: 'session-source', + copiedMessages: messages, + plan: { + sourceSessionId: 'session-source', + copyTurnIds: ['turn-1'], + inlineRuntimeEvents: [], + runs: [], + }, + }), + ['context-source'], + ); + assert.throws( + () => + rewriteConversationCopyMessage(messages[0]!, { + ...references, + contextRefs: new Map(), + }), + /missing Session context context-source/, + ); const userMessage = messages[0]; assert.equal(userMessage?.type, 'user'); if (userMessage?.type !== 'user') return; @@ -887,44 +926,6 @@ test('conversation copy rewrites owned references without changing opaque tool p ); }); -test('reader-only conversation copy rejects source-owned Session context refs', () => { - const message: StoredMessage = { - type: 'user', - id: 'user-context', - turnId: 'turn-1', - ts: 1, - text: 'context', - attachments: [ - { - kind: 'image', - name: 'snapshot.png', - mimeType: 'image/png', - bytes: 4, - ref: { - kind: 'session_context', - sessionId: 'session-source', - refId: 'context-source', - }, - }, - ], - }; - assert.throws( - () => - rewriteConversationCopyMessage(message, { - mode: 'exact', - sourceSessionId: 'session-source', - targetSessionId: 'session-target', - artifactIds: new Map(), - relativePaths: new Map(), - linkedChildren: { mode: 'reject' }, - runIds: new Map(), - runtimeEventIds: new Map(), - providerTraceIds: new Map(), - }), - /does not support Session context references yet/, - ); -}); - test('conversation copy rejects continuation authority selected through the child-run closure', async () => { const parent = agentRunHeader({ runId: 'run-parent', turnId: 'turn-parent' }); const child = agentRunHeader({ diff --git a/packages/runtime/src/__tests__/filesystem-authority.test.ts b/packages/runtime/src/__tests__/filesystem-authority.test.ts index 685d364e7a..66ddd6201b 100644 --- a/packages/runtime/src/__tests__/filesystem-authority.test.ts +++ b/packages/runtime/src/__tests__/filesystem-authority.test.ts @@ -431,7 +431,7 @@ describe('file tools follow the execution boundary', () => { }, snapshotImage: async (input) => { snapshots.push(input.bytes); - return { kind: 'session_file', sessionId: input.sessionId, relativePath: 'artifact-1' }; + return { kind: 'session_context', sessionId: input.sessionId, refId: 'context-1' }; }, }); diff --git a/packages/runtime/src/builtin-tools.ts b/packages/runtime/src/builtin-tools.ts index dbb1d7dc65..37a2f43011 100644 --- a/packages/runtime/src/builtin-tools.ts +++ b/packages/runtime/src/builtin-tools.ts @@ -184,11 +184,10 @@ export interface BuildBuiltinToolsOptions { sandboxPlatform?: SandboxPlatform; snapshotImage?: (input: { sessionId: string; - turnId: string; - name: string; + ownerId: string; bytes: Uint8Array; mimeType: string; - }) => Promise>; + }) => Promise>; } export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaTool[] { @@ -400,8 +399,7 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT throw new Error('Read image snapshots are not available in this toolset.'); const ref = await options.snapshotImage({ sessionId, - turnId: ctx.turnId, - name: basename(path), + ownerId: ctx.toolCallId, bytes: result.bytes, mimeType: result.mimeType, }); diff --git a/packages/runtime/src/conversation-copy.ts b/packages/runtime/src/conversation-copy.ts index 85867ee812..728ab4b6bd 100644 --- a/packages/runtime/src/conversation-copy.ts +++ b/packages/runtime/src/conversation-copy.ts @@ -25,7 +25,7 @@ import type { } from '@maka/core/agent-run'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; -import type { StorageRef, ToolResultContent } from '@maka/core/events'; +import { isStorageRef, type StorageRef, type ToolResultContent } from '@maka/core/events'; import { parseAttachmentResourceRef } from '@maka/core/attachments'; import { markPersisted } from '@maka/core/persisted-value'; import type { StoredMessage } from '@maka/core/session'; @@ -90,6 +90,7 @@ export type ConversationCopyArtifactReferenceMap = readonly mode: 'exact'; readonly artifactIds: ReadonlyMap; readonly relativePaths: ReadonlyMap; + readonly contextRefs?: ReadonlyMap; readonly linkedChildren: | { readonly mode: 'reject' } | { @@ -145,6 +146,35 @@ export interface ConversationRuntimeLedgerCopyPlan { }[]; } +/** Finds durable Session context references that the exact copy will rewrite. */ +export function collectConversationCopySessionContextRefIds(input: { + readonly sourceSessionId: string; + readonly copiedMessages: readonly StoredMessage[]; + readonly plan: ConversationRuntimeLedgerCopyPlan; +}): readonly string[] { + const refIds = new Set(); + const seen = new WeakSet(); + const visit = (value: unknown): void => { + if (isStorageRef(value)) { + if (value.kind === 'session_context' && value.sessionId === input.sourceSessionId) { + refIds.add(value.refId); + } + return; + } + if (typeof value !== 'object' || value === null || seen.has(value)) return; + seen.add(value); + if (Array.isArray(value)) { + for (const item of value) visit(item); + return; + } + for (const item of Object.values(value)) visit(item); + }; + visit(input.copiedMessages); + visit(input.plan.inlineRuntimeEvents); + for (const run of input.plan.runs) visit(run.runtimeEvents); + return [...refIds].sort(); +} + export interface CloneConversationRuntimeLedgerResult { readonly copiedMessages: readonly StoredMessage[]; readonly runIdMap: readonly { @@ -1510,12 +1540,22 @@ function rewriteStorageRef( ref: StorageRef, references: ConversationCopyArtifactReferenceMap, ): StorageRef { - if (ref.kind === 'session_context' && ref.sessionId === references.sourceSessionId) { - if (references.mode === 'preserve_external') return ref; - throw new Error('Conversation copy does not support Session context references yet'); + if ( + (ref.kind !== 'session_file' && ref.kind !== 'session_context') || + ref.sessionId !== references.sourceSessionId + ) { + return ref; } - if (ref.kind !== 'session_file' || ref.sessionId !== references.sourceSessionId) return ref; if (references.mode === 'preserve_external') return ref; + if (ref.kind === 'session_context') { + const refId = references.contextRefs?.get(ref.refId); + if (!refId) throw new Error(`Conversation copy is missing Session context ${ref.refId}`); + return { + ...ref, + sessionId: references.targetSessionId, + refId, + }; + } const artifactId = references.artifactIds.get(ref.relativePath); if (artifactId) { return {