blob: a0d901032336cb8e8054d8c24a6cf59e48fbe09e [file]
import { createHash } from 'node:crypto';
import { open, realpath, stat } from 'node:fs/promises';
import { isAbsolute, resolve } from 'node:path';
import { MAX_ATTACHMENT_BYTES } from '@maka/core';
import {
createToolResultArchiveCapability,
isPathInside,
stableToolResultArchiveArtifactId,
type ToolResultArchiveCapability,
type ToolArtifactRecorderInput,
type ToolResultArchiveReaderInput,
type ToolResultArchiveReadResult,
type ToolResultArchiveRecorderInput,
type ToolResultArchiveResourceReadInput,
} from '@maka/runtime';
import type { InteractiveArtifactStoreWriter } from '@maka/storage/artifact-stores';
export interface HostExecutionArtifactServices {
recordToolArtifacts(event: ToolArtifactRecorderInput): Promise<void>;
/**
* One archive authority over the session artifact store (#2026). All three
* reads and the writer address the same store, so the host has no way to hand
* out half of it.
*/
toolResultArchive: ToolResultArchiveCapability;
}
export function createHostExecutionArtifactServices(input: {
artifacts: InteractiveArtifactStoreWriter;
requestDrain: () => void;
}): HostExecutionArtifactServices {
const runWrite = async <T>(operation: () => Promise<T>): Promise<T> => {
try {
return await operation();
} catch (error) {
input.requestDrain();
throw error;
}
};
const recordToolArtifacts = async (event: ToolArtifactRecorderInput): Promise<void> => {
for (const candidate of event.candidates) {
let content = candidate.content;
if (content === undefined && candidate.sourcePath) {
content = (await readBoundedSourceFile(event.cwd, candidate.sourcePath)) ?? undefined;
}
if (content === undefined || contentBytes(content) > MAX_ATTACHMENT_BYTES) continue;
await runWrite(() =>
input.artifacts.create({
sessionId: event.sessionId,
turnId: event.turnId,
name: candidate.name,
kind: candidate.kind,
content,
...(candidate.mimeType ? { mimeType: candidate.mimeType } : {}),
source: candidate.source ?? 'tool_result',
...(candidate.summary ? { summary: candidate.summary } : {}),
}),
);
}
};
const services: HostExecutionArtifactServices = {
recordToolArtifacts,
toolResultArchive: createToolResultArchiveCapability({
archiveToolResult: (event: ToolResultArchiveRecorderInput) =>
runWrite(async () => {
const artifactId = stableToolResultArchiveArtifactId(event);
const existing = await input.artifacts.getInSession(event.sessionId, artifactId);
if (existing.record?.status === 'live') {
const read = await readArchive(input.artifacts, {
artifactId,
sessionId: event.sessionId,
bodySha256: event.bodySha256,
originalBytes: event.originalBytes,
maxBytes: event.originalBytes,
});
if (!read.ok) {
throw new Error(`Tool result archive identity conflict: ${read.reason}`);
}
return { artifactId };
}
const artifact = await input.artifacts.create({
id: artifactId,
sessionId: event.sessionId,
turnId: event.turnId,
name: `archived-${event.toolName}-${event.runtimeEventId}.json`,
kind: 'file',
content: event.serializedResult,
mimeType: 'application/json',
source: 'tool_result_archive',
summary: `Archived ${event.toolName} tool result for context budget replay`,
});
return { artifactId: artifact.id };
}),
readToolResultArchive: (event: ToolResultArchiveReaderInput) =>
readArchive(input.artifacts, event),
readArchivedToolResultResource: (event: ToolResultArchiveResourceReadInput) =>
readArchive(input.artifacts, event),
}),
};
return Object.freeze(services);
}
async function readBoundedSourceFile(cwd: string, sourcePath: string): Promise<Buffer | null> {
const candidate = isAbsolute(sourcePath) ? sourcePath : resolve(cwd, sourcePath);
let root: string;
let target: string;
try {
[root, target] = await Promise.all([realpath(cwd), realpath(candidate)]);
} catch {
return null;
}
if (!isPathInside(root, target)) return null;
let handle: Awaited<ReturnType<typeof open>> | undefined;
try {
handle = await open(target, 'r');
const opened = await handle.stat();
if (!opened.isFile() || opened.size > MAX_ATTACHMENT_BYTES) return null;
const currentTarget = await realpath(target);
if (!isPathInside(root, currentTarget)) return null;
const current = await stat(currentTarget);
if (current.dev !== opened.dev || current.ino !== opened.ino) return null;
const bytes = Buffer.allocUnsafe(opened.size);
let offset = 0;
while (offset < bytes.byteLength) {
const read = await handle.read(bytes, offset, bytes.byteLength - offset, offset);
if (read.bytesRead === 0) break;
offset += read.bytesRead;
}
return offset === bytes.byteLength ? bytes : bytes.subarray(0, offset);
} catch {
return null;
} finally {
await handle?.close().catch(() => undefined);
}
}
function contentBytes(content: string | Uint8Array): number {
return typeof content === 'string' ? Buffer.byteLength(content, 'utf8') : content.byteLength;
}
async function readArchive(
artifacts: InteractiveArtifactStoreWriter,
event: Pick<
ToolResultArchiveReaderInput,
'artifactId' | 'sessionId' | 'bodySha256' | 'originalBytes' | 'maxBytes'
>,
): Promise<ToolResultArchiveReadResult> {
const entry = await artifacts.getInSession(event.sessionId, event.artifactId);
const record = entry.record;
if (!record) return { ok: false, reason: 'not_found' };
if (record.status === 'deleted') return { ok: false, reason: 'deleted' };
if (record.source !== 'tool_result_archive') return { ok: false, reason: 'source_mismatch' };
if (record.sizeBytes !== event.originalBytes) return { ok: false, reason: 'size_mismatch' };
const read = await artifacts.readTextInSession(event.sessionId, event.artifactId, {
maxBytes: event.maxBytes ?? event.originalBytes,
});
if (!read.ok) return read;
if (sha256(read.text) !== event.bodySha256) return { ok: false, reason: 'corrupt' };
return { ok: true, serializedResult: read.text };
}
function sha256(text: string): string {
return createHash('sha256').update(text).digest('hex');
}