blob: 23e11fb7ef43a554f317be70f6f956ed9ea904dd [file]
import type { RuntimeEvent } from '@maka/core/runtime-event';
import { createHash } from 'node:crypto';
import type { ContextBudgetDiagnostic } from '@maka/core/usage-stats/types';
import type {
ArchivedToolResultSourceRef,
ArchivedToolResultReason,
} from './context-source-ref.js';
import {
estimateTokens,
finitePositive,
increment,
sha256,
stableJsonLength,
turnKey,
utf8ByteLength,
} from './context-budget-helpers.js';
import {
buildToolResultArchiveResourceRef,
TOOL_RESULT_ARCHIVE_READ_INSTRUCTIONS,
} from './tool-result-archive-resource.js';
export interface StaleToolResultPrunePolicy {
enabled: boolean;
/** Tool result payloads above this estimate are replaced with archive placeholders. Defaults to 2048. */
maxResultEstimatedTokens?: number;
/** Keep this many newest turns' tool results full. Defaults to ContextBudgetPolicy.minRecentTurns, then 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<Record<string, ToolResultArchiveRef>>;
}
export interface ArchiveRetrievalPolicy {
enabled: boolean;
/**
* Defaults to `eager` for Phase 6 compatibility. `history_search_gated`
* only hydrates placeholders whose turn was selected by history search.
*/
mode?: ArchiveRetrievalMode;
maxResults?: number;
maxEstimatedTokens?: number;
maxBytes?: number;
order?: 'newest_first';
}
export type ArchiveRetrievalMode = 'eager' | 'history_search_gated';
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;
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;
runtimeEventId: string;
toolCallId: string;
toolName: string;
bodySha256: string;
originalEstimatedTokens: number;
originalBytes: number;
reason: ArchivedToolResultReason;
}
export interface StaleToolResultArchiveCandidate {
runtimeEventId: string;
turnId: string;
toolCallId: string;
toolName: string;
result: unknown;
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;
}
export type ToolResultArchiveReadFailureReason =
| 'not_found'
| 'deleted'
| 'too_large'
| 'not_allowed'
| 'read_failed'
| 'source_mismatch'
| 'session_mismatch'
| 'size_mismatch'
| 'corrupt';
export interface ToolResultArchiveReaderInput extends ArchivedToolResultPlaceholder {
sessionId: string;
maxBytes?: number;
}
export type ToolResultArchiveReadResult =
| { ok: true; serializedResult: string }
| { ok: false; reason: ToolResultArchiveReadFailureReason };
export type ToolResultArchiveReader = (
input: ToolResultArchiveReaderInput,
) => Promise<ToolResultArchiveReadResult> | ToolResultArchiveReadResult;
export function stableToolResultArchiveArtifactId(event: {
sessionId: string;
runtimeEventId: string;
toolCallId: string;
toolName: string;
bodySha256: string;
rewriteVersion: number;
}): string {
return `tool-result-archive-${createHash('sha256')
.update(
JSON.stringify({
sessionId: event.sessionId,
runtimeEventId: event.runtimeEventId,
toolCallId: event.toolCallId,
toolName: event.toolName,
bodySha256: event.bodySha256,
rewriteVersion: event.rewriteVersion,
}),
)
.digest('hex')
.slice(0, 32)}`;
}
export interface ArchiveRetrievalResult {
events: RuntimeEvent[];
diagnosticPatch: Partial<ContextBudgetDiagnostic>;
retrievedSourceRefs?: ArchivedToolResultSourceRef[];
}
export async function retrieveArchivedToolResultsForReplay(
events: readonly RuntimeEvent[],
policy: ArchiveRetrievalPolicy | undefined,
reader: ToolResultArchiveReader | undefined,
options: {
sessionId: string;
charsPerToken?: number;
allowedTurnIds?: ReadonlySet<string> | readonly string[];
},
): Promise<ArchiveRetrievalResult> {
if (policy?.enabled !== true || !reader) {
return { events: [...events], diagnosticPatch: {} };
}
const charsPerToken = options.charsPerToken ?? 4;
const mode = policy.mode ?? 'eager';
const allowedTurnIds = normalizeAllowedTurnIds(options.allowedTurnIds);
const maxResults = finitePositive(policy.maxResults) ?? 3;
const maxEstimatedTokens = finitePositive(policy.maxEstimatedTokens) ?? 8_192;
const maxBytes = finitePositive(policy.maxBytes) ?? 1024 * 1024;
const candidates = collectArchiveRetrievalCandidates(events, policy.order ?? 'newest_first');
let retrieved = 0;
let retrievedTokens = 0;
let skipped = 0;
let failures = 0;
const skippedReasonCounts: Record<string, number> = {};
const failureReasonCounts: Record<string, number> = {};
const replacements = new Map<string, unknown>();
const retrievedSourceRefs: ArchivedToolResultSourceRef[] = [];
for (const candidate of candidates) {
if (retrieved >= maxResults) break;
if (mode === 'history_search_gated' && !allowedTurnIds.has(turnKey(candidate.event))) {
skipped += 1;
increment(skippedReasonCounts, 'history_search_gate');
continue;
}
if (candidate.placeholder.originalBytes > maxBytes) {
skipped += 1;
increment(skippedReasonCounts, 'max_bytes');
continue;
}
if (candidate.placeholder.originalEstimatedTokens > maxEstimatedTokens) {
skipped += 1;
increment(skippedReasonCounts, 'max_candidate_tokens');
continue;
}
if (retrievedTokens + candidate.placeholder.originalEstimatedTokens > maxEstimatedTokens) {
skipped += 1;
increment(skippedReasonCounts, 'max_total_tokens');
continue;
}
const readResult = await Promise.resolve(
reader({
...candidate.placeholder,
sessionId: options.sessionId,
maxBytes,
}),
).catch((): ToolResultArchiveReadResult => ({ ok: false, reason: 'read_failed' }));
if (!readResult.ok) {
failures += 1;
increment(failureReasonCounts, readResult.reason);
continue;
}
const actualHash = sha256(readResult.serializedResult);
if (actualHash !== candidate.placeholder.bodySha256) {
failures += 1;
increment(failureReasonCounts, 'corrupt');
continue;
}
replacements.set(candidate.event.id, deserializeToolResultArchive(readResult.serializedResult));
retrievedSourceRefs.push({
kind: 'archived_tool_result',
sessionId: options.sessionId,
turnId: turnKey(candidate.event),
runtimeEventId: candidate.event.id,
toolCallId: candidate.placeholder.toolCallId,
toolName: candidate.placeholder.toolName,
artifactId: candidate.placeholder.artifactId,
bodySha256: candidate.placeholder.bodySha256,
originalEstimatedTokens: candidate.placeholder.originalEstimatedTokens,
originalBytes: candidate.placeholder.originalBytes,
placeholderReason: candidate.placeholder.reason,
});
retrieved += 1;
retrievedTokens += candidate.placeholder.originalEstimatedTokens;
}
const hydratedEvents = events.map((event) => {
const replacement = replacements.get(event.id);
if (!replacements.has(event.id) || event.content?.kind !== 'function_response') return event;
return {
...event,
content: {
...event.content,
result: replacement,
},
};
});
return {
events: hydratedEvents,
...(retrievedSourceRefs.length > 0 ? { retrievedSourceRefs } : {}),
diagnosticPatch: {
archiveRetrievalMode: mode,
...(mode === 'history_search_gated'
? { archiveRetrievalEligibleTurns: allowedTurnIds.size }
: {}),
retrievedArchiveToolResults: retrieved,
retrievedArchiveEstimatedTokens: retrievedTokens,
archiveRetrievalSkipped: skipped,
archiveRetrievalFailures: failures,
...(Object.keys(skippedReasonCounts).length > 0
? { archiveRetrievalSkippedReasonCounts: skippedReasonCounts }
: {}),
...(Object.keys(failureReasonCounts).length > 0
? { archiveRetrievalFailureReasonCounts: failureReasonCounts }
: {}),
},
};
}
export function deserializeToolResultArchive(serialized: string): unknown {
if (serialized === 'undefined') return undefined;
try {
return JSON.parse(serialized) as unknown;
} catch {
return serialized;
}
}
export function pruneStaleToolResultsBeforeCompact(
events: readonly RuntimeEvent[],
prunePolicy: StaleToolResultPrunePolicy | undefined,
charsPerToken: number,
minRecentTurns: number | undefined,
): {
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 ?? minRecentTurns ?? 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,
minRecentTurns: number | undefined,
): 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 ?? minRecentTurns ?? 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 {
return JSON.stringify(result) ?? 'null';
} catch {
return String(result);
}
}
export function isArchivedToolResultPlaceholder(
value: unknown,
): value is ArchivedToolResultPlaceholder {
if (!value || typeof value !== 'object') return false;
const candidate = value as Partial<ArchivedToolResultPlaceholder>;
return (
candidate.kind === ARCHIVED_TOOL_RESULT_PLACEHOLDER_KIND &&
candidate.rewriteVersion === ARCHIVED_TOOL_RESULT_REWRITE_VERSION &&
typeof candidate.artifactId === 'string' &&
candidate.artifactId.length > 0 &&
typeof candidate.runtimeEventId === 'string' &&
candidate.runtimeEventId.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 === 'stale_tool_result_pruned_before_compact'
);
}
function normalizeArchiveRefs(
refs: StaleToolResultPrunePolicy['archiveRefs'],
): Map<string, ToolResultArchiveRef> {
const map = new Map<string, ToolResultArchiveRef>();
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<string> {
if (count <= 0) return new Set();
const order: string[] = [];
const seen = new Set<string>();
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)));
}
function collectArchiveRetrievalCandidates(
events: readonly RuntimeEvent[],
order: NonNullable<ArchiveRetrievalPolicy['order']>,
): Array<{
event: RuntimeEvent;
placeholder: ArchivedToolResultPlaceholder;
}> {
const candidates: Array<{ event: RuntimeEvent; placeholder: ArchivedToolResultPlaceholder }> = [];
for (const event of events) {
if (event.content?.kind !== 'function_response') continue;
if (!isArchivedToolResultPlaceholder(event.content.result)) continue;
candidates.push({ event, placeholder: event.content.result });
}
return order === 'newest_first' ? candidates.reverse() : candidates;
}
function normalizeAllowedTurnIds(
turnIds: ReadonlySet<string> | readonly string[] | undefined,
): ReadonlySet<string> {
if (!turnIds) return new Set();
if (turnIds instanceof Set) return turnIds;
return new Set(turnIds);
}