Files
maka/packages/runtime/src/tool-result-archive-transition.ts
T
agent-radar 7be535059e
Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 19:05:14 +08:00

347 lines
13 KiB
TypeScript

/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* The one writer that turns a Tool Result prune decision into durable truth
* (#4283).
*
* Both prune paths — the current Turn's active prune before the next provider
* step, and the prior Turn's stale prune before compaction — come through here.
* They used to keep their replacement in a Turn-local map and a policy-carried
* ref table respectively, so each owned a private recovery contract and neither
* survived a restart. Now each records one `ModelProjectionTransition`, and the
* Session reducer is the only thing that decides what the model sees.
*
* Write order is the whole safety argument:
*
* 1. Archive the replaced body. A failure here leaves the projection untouched.
* 2. Append the transition. A failure here leaves an artifact nothing points
* at — unreachable by the reducer, so safe to reclaim once something does —
* and again leaves the projection untouched.
* 3. Only then may a caller show the replacement.
*
* There is no state in which the model has lost content the ledger cannot
* explain, and none in which a completed tool effect is repeated.
*/
import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection';
import { DURABLE_TOOL_RESULT_PROJECTION_VERSION } from '@maka/core/durable-tool-result-projection';
import {
buildModelProjectionTransition,
type ModelProjectionTransition,
} from '@maka/core/model-projection-transition';
import type { RuntimeEvent } from '@maka/core/runtime-event';
import type { ActiveToolResultSupersession } from './active-tool-result-working-set.js';
import {
estimateTokens,
finitePositive,
sha256,
turnKey,
utf8ByteLength,
} from './context-budget-helpers.js';
import { durableProjectionToToolResultOutput } from './durable-tool-result-projection.js';
import { baseToolResultProjection, nextInChain } from './model-projection-transition-ledger.js';
import {
ARCHIVED_TOOL_RESULT_REWRITE_VERSION,
buildArchivedToolResultPlaceholder,
isArchivedToolResultPlaceholder,
serializeToolResultForArchive,
type ArchivedToolResultPlaceholder,
type ArchivedToolResultReason,
type StaleToolResultArchiveCandidate,
type StaleToolResultPrunePolicy,
} from './tool-result-archive.js';
const DEFAULT_MAX_TOOL_RESULT_ESTIMATED_TOKENS = 2048;
export type ModelProjectionTransitionRecorder = (
transition: ModelProjectionTransition,
) => Promise<void>;
/**
* 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<string, never>,
};
}
export interface ToolResultArchiveTransitionServices {
sessionId: string;
archiveToolResult: (input: {
sessionId: string;
runtimeEventId: string;
turnId: string;
toolCallId: string;
toolName: string;
result: unknown;
serializedResult: string;
bodySha256: string;
originalBytes: number;
originalEstimatedTokens: number;
rewriteVersion: typeof ARCHIVED_TOOL_RESULT_REWRITE_VERSION;
reason: ArchivedToolResultReason;
}) => Promise<{ artifactId: string } | void> | { artifactId: string } | void;
recordTransition: ModelProjectionTransitionRecorder;
/**
* Re-read the durable ledger after an append.
*
* A successful append does not make this transition the fold's answer: a
* concurrent Turn can append a rival successor to the same source, and the
* fold accepts exactly one of them. Without this seam the caller would show a
* replacement that the next read replaces with the other writer's.
*/
loadTransitions?: () => Promise<{ transitions: ModelProjectionTransition[] }>;
now: () => number;
}
export interface ToolResultArchiveTransitionRequest {
runtimeEventId: string;
turnId: string;
toolCallId: string;
toolName: string;
/** The projection this transition is allowed to replace. */
sourceProjection: DurableToolResultProjection;
serializedResult: string;
originalBytes: number;
originalEstimatedTokens: number;
reason: ArchivedToolResultReason;
previousTransitionId?: string;
supersession?: ActiveToolResultSupersession;
/** Raw execution fact kept only so the archive writer can name the artifact. */
result?: unknown;
}
export interface ToolResultArchiveTransitionOutcome {
placeholder: ArchivedToolResultPlaceholder;
transition: ModelProjectionTransition;
}
/**
* Archive one body and commit the transition that replaces its projection.
*
* Returns `undefined` when either durable step fails: the caller then leaves
* the model-visible content exactly as it was, which is the only outcome that
* keeps "visible history is append-only" true under partial failure.
*/
export async function archiveToolResultAsTransition(
services: ToolResultArchiveTransitionServices,
request: ToolResultArchiveTransitionRequest,
): Promise<ToolResultArchiveTransitionOutcome | undefined> {
const bodySha256 = sha256(request.serializedResult);
let archived: { artifactId: string } | void;
try {
archived = await Promise.resolve(
services.archiveToolResult({
sessionId: services.sessionId,
runtimeEventId: request.runtimeEventId,
turnId: request.turnId,
toolCallId: request.toolCallId,
toolName: request.toolName,
result: request.result,
serializedResult: request.serializedResult,
bodySha256,
originalBytes: request.originalBytes,
originalEstimatedTokens: request.originalEstimatedTokens,
rewriteVersion: ARCHIVED_TOOL_RESULT_REWRITE_VERSION,
reason: request.reason,
}),
);
} catch {
return undefined;
}
const artifactId = archived?.artifactId;
if (typeof artifactId !== 'string' || artifactId.trim().length === 0) return undefined;
const placeholder = buildArchivedToolResultPlaceholder({
artifactId,
runtimeEventId: request.runtimeEventId,
toolCallId: request.toolCallId,
toolName: request.toolName,
bodySha256,
originalEstimatedTokens: request.originalEstimatedTokens,
originalBytes: request.originalBytes,
reason: request.reason,
...(request.supersession ? { supersession: request.supersession } : {}),
});
let transition: ModelProjectionTransition;
try {
transition = buildModelProjectionTransition({
sessionId: services.sessionId,
target: {
runtimeEventId: request.runtimeEventId,
part: 'tool_result',
toolCallId: request.toolCallId,
toolName: request.toolName,
},
sourceProjection: request.sourceProjection,
// The placeholder inside the replacement is the whole archive record:
// artifact id, body digest and original size. The transition does not
// repeat them — one fact, one place.
replacement: archivedToolResultProjection(placeholder),
...(request.previousTransitionId
? { previousTransitionId: request.previousTransitionId }
: {}),
now: services.now(),
});
await services.recordTransition(transition);
const winner = await winningTransition(services, transition);
if (winner && winner.transitionId !== transition.transitionId) {
// The rival won. Show what the ledger says, not what this writer wrote;
// its own record stays durable and inert, and the body it archived is
// unreachable exactly as a refused transition's archive should be.
const replaced = winner.replacement.kind === 'json' ? winner.replacement.value : undefined;
if (!isArchivedToolResultPlaceholder(replaced)) return undefined;
return { placeholder: replaced, transition: winner };
}
} catch {
// The archive artifact is now unreferenced: nothing in the effective
// history names it, which is what reducer-derived reachability reports. It
// is content-addressed, so a retry of the same decision reuses it rather
// than publishing a second one. No cleanup pass consumes that reachability
// yet, so such an artifact is retained until one does — it cannot break
// replay, but it is not reclaimed either (#4283).
return undefined;
}
return { placeholder, transition };
}
/** The transition the durable fold accepts for this target, after an append. */
async function winningTransition(
services: ToolResultArchiveTransitionServices,
appended: ModelProjectionTransition,
): Promise<ModelProjectionTransition | undefined> {
if (!services.loadTransitions) return appended;
const { transitions } = await services.loadTransitions();
return nextInChain(transitions, appended.previousTransitionId, appended.sourceProjectionDigest, {
id: appended.target.toolCallId,
name: appended.target.toolName,
});
}
/**
* Prior-Turn results large enough to archive before compaction.
*
* Collection reads events the transition reducer has already folded, so a
* result an earlier transition replaced is measured at its replacement size and
* simply falls below the threshold — there is no second "already pruned?"
* predicate to keep in step with the fold.
*/
export function collectStaleToolResultArchiveCandidates(
events: readonly RuntimeEvent[],
prunePolicy: StaleToolResultPrunePolicy | undefined,
charsPerToken: number,
): StaleToolResultArchiveCandidate[] {
if (prunePolicy?.enabled !== true) return [];
const maxResultEstimatedTokens =
finitePositive(prunePolicy.maxResultEstimatedTokens) ??
DEFAULT_MAX_TOOL_RESULT_ESTIMATED_TOKENS;
const minRecentTurnsFull = Math.max(0, Math.floor(prunePolicy.minRecentTurnsFull ?? 1));
const protectedTurnIds = recentTurnIds(events, minRecentTurnsFull);
const candidates: StaleToolResultArchiveCandidate[] = [];
for (const event of events) {
const content = event.content;
if (
event.partial ||
event.modelVisibility === 'hidden' ||
content?.kind !== 'function_response' ||
protectedTurnIds.has(turnKey(event))
) {
continue;
}
const sourceProjection = baseToolResultProjection(event);
if (!sourceProjection) continue;
const serializedResult = serializedToolResultProjection(sourceProjection);
const originalBytes = utf8ByteLength(serializedResult);
const originalEstimatedTokens = estimateTokens(serializedResult.length, charsPerToken);
if (originalEstimatedTokens <= maxResultEstimatedTokens) continue;
candidates.push({
runtimeEventId: event.id,
turnId: event.turnId,
toolCallId: content.id,
toolName: content.name,
result: content.result,
sourceProjection,
serializedResult,
originalEstimatedTokens,
originalBytes,
rewriteVersion: ARCHIVED_TOOL_RESULT_REWRITE_VERSION,
reason: 'stale_tool_result_pruned_before_compact',
});
}
return candidates;
}
/**
* Archive artifacts the effective history still needs.
*
* Derived from the folded events, never from a parallel bookkeeping table: an
* artifact is reachable exactly when a placeholder the model can still see names
* it. An archive whose transition was refused is therefore unreachable by
* construction.
*
* This is the reachability authority a reclaiming pass must ask; no such pass
* exists yet, so nothing here is reclaimed today (#4283). Adding one is what
* makes an unreferenced archive temporary rather than retained.
*/
export function collectReachableArchiveArtifactIds(events: readonly RuntimeEvent[]): Set<string> {
const reachable = new Set<string>();
for (const event of events) {
const content = event.content;
if (content?.kind !== 'function_response') continue;
if (isArchivedToolResultPlaceholder(content.result)) {
reachable.add(content.result.artifactId);
}
}
return reachable;
}
function recentTurnIds(events: readonly RuntimeEvent[], count: number): Set<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)));
}