blob: ca973d4b387f29ccc625c250fc11ad4aee2774ed [file]
import type { AgentRunHeader, AgentRunStore, RuntimeEvent, RuntimeEventStore } from '@maka/core';
import { isSessionInlineRun, isTerminalRuntimeEvent } from '@maka/core';
import type { StoredMessage } from '@maka/core';
import { buildRuntimeEventModelReplayPlan } from './model-history.js';
import { backfillRuntimeEventsFromStoredMessages } from './runtime-event-backfill.js';
import { classifyRuntimeEventTerminalFact } from './runtime-event-read-model.js';
import { isTerminalRunStatus } from './session-projection-helpers.js';
import { effectiveRunHeaderFromTerminalFact } from './terminal-run-commit.js';
export interface PriorRuntimeContext {
events: RuntimeEvent[];
runs: AgentRunHeader[];
}
export interface BuildPriorRuntimeContextInput {
sessionId: string;
currentRunId: string;
currentTurnId: string;
parentRunId?: string;
resumedFromRunId?: string;
agentId?: string;
linkedChildSession: boolean;
runStore?: AgentRunStore;
runtimeEventStore?: RuntimeEventStore;
runStoreAvailable: boolean;
runtimeEventStoreAvailable: boolean;
repairRunRuntimeLedger?: (sessionId: string, runId: string) => Promise<boolean>;
readMessages: () => Promise<StoredMessage[]>;
}
interface PriorRunTerminalFactContext {
events: RuntimeEvent[];
run: AgentRunHeader;
}
export async function buildPriorRuntimeContext(
input: BuildPriorRuntimeContextInput,
): Promise<PriorRuntimeContext | undefined> {
if (input.resumedFromRunId)
return await buildResumedChildRuntimeContext(input, input.resumedFromRunId);
if (input.parentRunId) return undefined;
if (
!input.runStore ||
!input.runtimeEventStore ||
!input.runStoreAvailable ||
!input.runtimeEventStoreAvailable
)
return undefined;
const runs = await input.runStore.listSessionRuns(input.sessionId);
const priorRuns = runs.filter(
(run) =>
run.runId !== input.currentRunId &&
run.turnId !== input.currentTurnId &&
isSessionInlineRun(run),
);
if (priorRuns.length === 0) return undefined;
const ordered: Array<{ event: RuntimeEvent; runIndex: number; eventIndex: number }> = [];
for (let runIndex = 0; runIndex < priorRuns.length; runIndex += 1) {
const run = priorRuns[runIndex]!;
if (!isTerminalRunStatus(run.status)) {
const nonTerminal = await readNonTerminalPriorRun(input, run);
if (nonTerminal.run) priorRuns[runIndex] = nonTerminal.run;
appendEvents(ordered, nonTerminal.events, runIndex, input);
continue;
}
let events = await input.runtimeEventStore.readRuntimeEvents(input.sessionId, run.runId);
if (events.length === 0 && (await input.repairRunRuntimeLedger?.(input.sessionId, run.runId))) {
events = await input.runtimeEventStore.readRuntimeEvents(input.sessionId, run.runId);
}
if (events.length === 0) {
const recovered = await backfillMissingPriorRuntimeEvents(input, run);
if (recovered.length === 0 || !recovered.some(isTerminalRuntimeEvent)) {
throw new Error(
`Cannot build model context: RuntimeEvent ledger is missing for prior run ${run.runId}`,
);
}
events = recovered;
}
if (
!events.some(isTerminalRuntimeEvent) &&
(await input.repairRunRuntimeLedger?.(input.sessionId, run.runId))
) {
events = await input.runtimeEventStore.readRuntimeEvents(input.sessionId, run.runId);
}
if (!events.some(isTerminalRuntimeEvent)) {
throw new Error(
`Cannot build model context: RuntimeEvent ledger has no terminal fact for prior run ${run.runId}`,
);
}
let terminalFact = classifyRuntimeEventTerminalFact(run, events).fact;
if (!terminalFact && (await input.repairRunRuntimeLedger?.(input.sessionId, run.runId))) {
events = await input.runtimeEventStore.readRuntimeEvents(input.sessionId, run.runId);
terminalFact = classifyRuntimeEventTerminalFact(run, events).fact;
}
if (!terminalFact) {
throw new Error(
`Cannot build model context: RuntimeEvent ledger has no valid terminal fact for prior run ${run.runId}`,
);
}
priorRuns[runIndex] = effectiveRunHeaderFromTerminalFact(run, terminalFact);
appendEvents(ordered, events, runIndex, input);
}
ordered.sort((a, b) => a.runIndex - b.runIndex || a.eventIndex - b.eventIndex);
const events = ordered.map((item) => item.event);
if (events.length === 0 || buildRuntimeEventModelReplayPlan(events).items.length === 0)
return undefined;
return { events, runs: priorRuns };
}
async function buildResumedChildRuntimeContext(
input: BuildPriorRuntimeContextInput,
sourceRunId: string,
): Promise<PriorRuntimeContext> {
if (
!input.runStore ||
!input.runtimeEventStore ||
!input.runStoreAvailable ||
!input.runtimeEventStoreAvailable
) {
throw new Error('Child AgentRun resume requires durable run and RuntimeEvent stores');
}
const sessionRuns = await input.runStore.listSessionRuns(input.sessionId);
const runsById = new Map(sessionRuns.map((run) => [run.runId, run]));
const reverseChain: AgentRunHeader[] = [];
const visited = new Set<string>();
let cursor: string | undefined = sourceRunId;
while (cursor) {
if (visited.has(cursor))
throw new Error(`Child AgentRun resume lineage contains a cycle at ${cursor}`);
visited.add(cursor);
const run = runsById.get(cursor);
if (!run) throw new Error(`Child AgentRun resume source ${cursor} was not found`);
if (
input.linkedChildSession
? !isSessionInlineRun(run)
: !run.parentRunId || isSessionInlineRun(run)
) {
throw new Error(`AgentRun ${cursor} is not a resumable child run`);
}
if (!run.agentId || run.agentId !== input.agentId) {
throw new Error(`Child AgentRun resume profile changed at ${cursor}`);
}
reverseChain.push(run);
cursor = run.resumedFromRunId ?? (input.linkedChildSession ? run.retriedFromRunId : undefined);
}
const effectiveRuns: AgentRunHeader[] = [];
const events: RuntimeEvent[] = [];
for (const run of reverseChain.reverse()) {
const loaded = await loadRequiredChildResumeContext(input, run);
effectiveRuns.push(loaded.run);
events.push(...loaded.events);
}
const replay = buildRuntimeEventModelReplayPlan(events);
const unsafe = replay.diagnostics.find((diagnostic) =>
[
'unmatched_tool_call',
'unmatched_tool_result',
'tool_id_mismatch',
'unsupported_role',
'unsupported_content',
].includes(diagnostic.code),
);
if (unsafe) throw new Error(`Child AgentRun resume history is unsafe: ${unsafe.code}`);
const first = replay.items[0];
if (!first || first.kind !== 'text' || first.role !== 'user') {
throw new Error('Child AgentRun resume history has no user-anchored replay boundary');
}
return { events, runs: effectiveRuns };
}
async function loadRequiredChildResumeContext(
input: BuildPriorRuntimeContextInput,
run: AgentRunHeader,
): Promise<PriorRunTerminalFactContext> {
let events = await input.runtimeEventStore!.readRuntimeEvents(input.sessionId, run.runId);
if (
(events.length === 0 || !events.some(isTerminalRuntimeEvent)) &&
(await input.repairRunRuntimeLedger?.(input.sessionId, run.runId))
) {
events = await input.runtimeEventStore!.readRuntimeEvents(input.sessionId, run.runId);
}
if (events.length === 0 || !events.some(isTerminalRuntimeEvent)) {
throw new Error(`Child AgentRun resume source ${run.runId} has no terminal RuntimeEvent fact`);
}
const terminalFact = classifyRuntimeEventTerminalFact(run, events).fact;
if (!terminalFact)
throw new Error(
`Child AgentRun resume source ${run.runId} has an invalid terminal RuntimeEvent fact`,
);
return { events, run: effectiveRunHeaderFromTerminalFact(run, terminalFact) };
}
/**
* A prior run whose header never reached a terminal status: it was stopped
* while parked on an interaction, or the process died mid-turn. Its turn is
* still conversation the model must see, so the ledger it does have is
* replayed either way. Dropping the run instead would delete a whole turn —
* the user message included — from every later turn's context, silently and
* for good, because the header never becomes terminal on its own.
*/
async function readNonTerminalPriorRun(
input: BuildPriorRuntimeContextInput,
run: AgentRunHeader,
): Promise<{ events: RuntimeEvent[]; run?: AgentRunHeader }> {
if (!input.runtimeEventStore) return { events: [] };
// No repair attempt here, unlike the terminal branch: `repairRunTerminalFact`
// returns false for a non-terminal header before reading anything, so calling
// it would be a promise that only ever answers "no".
const events = await input.runtimeEventStore
.readRuntimeEvents(input.sessionId, run.runId)
.catch(() => []);
const terminalFact = classifyRuntimeEventTerminalFact(run, events).fact;
return terminalFact
? { events, run: effectiveRunHeaderFromTerminalFact(run, terminalFact) }
: { events };
}
async function backfillMissingPriorRuntimeEvents(
input: BuildPriorRuntimeContextInput,
run: AgentRunHeader,
): Promise<RuntimeEvent[]> {
let messages: StoredMessage[];
try {
messages = await input.readMessages();
} catch {
return [];
}
return backfillRuntimeEventsFromStoredMessages({ run, messages }).events;
}
function appendEvents(
ordered: Array<{ event: RuntimeEvent; runIndex: number; eventIndex: number }>,
events: readonly RuntimeEvent[],
runIndex: number,
input: BuildPriorRuntimeContextInput,
): void {
for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) {
const event = events[eventIndex]!;
if (event.runId !== input.currentRunId && event.turnId !== input.currentTurnId) {
ordered.push({ event, runIndex, eventIndex });
}
}
}