blob: 6dbd39cf6d1b915ce75d5ee6b95ac1b2541a666e [file]
/*
* 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.
*/
import {
decodeModelCallAttempt,
MODEL_CALL_ATTEMPT_EVENT_TYPE,
type ModelCallAttempt,
} from '@maka/core/model-call-attempt';
import type { RuntimeEvent } from '@maka/core/runtime-event';
import { readRunInvocation } from '@maka/core/runtime-event-store';
import type {
RuntimeInvocationPageCursor,
RuntimeInvocationRecord,
} from '@maka/core/runtime-invocation';
import { inspectAgentRunDocument, inspectSessionDocument } from '@maka/runtime/execution-inspect';
import { projectSessionTrace } from '@maka/runtime/session-trace-projection';
import {
type AgentRunInspectDocument,
type SessionInspectDocument,
} from '@maka/core/execution-inspect';
import {
isSessionNotFoundError,
type BoundedEvidenceReadResult,
type EvidenceReadBudget,
type ExecutionAgentRunReader,
type ExecutionRuntimeEventReader,
type ExecutionSessionWriter,
} from '@maka/storage/execution-stores';
import {
EXECUTION_INSPECT_EVIDENCE_MAX_BYTES,
EXECUTION_INSPECT_EVIDENCE_MAX_RECORDS,
EXECUTION_INSPECT_RESULT_MAX_BYTES,
EXECUTION_INSPECT_SESSION_MAX_RUNS,
EXECUTION_INSPECT_TRACE_PAGE_MAX_TURNS,
type ExecutionInspectQueryInput,
type ExecutionInspectQueryResult,
type OperationOutcome,
} from '../protocol/index.js';
import type { ExecutionInspectOperationHandlerMap } from './operation-dispatcher.js';
interface InspectStores {
readonly sessionStore: Pick<ExecutionSessionWriter, 'readHeaderSnapshot'>;
readonly agentRunStore: Pick<
ExecutionAgentRunReader,
'readEventsBounded' | 'readEventsByTypeBounded' | 'readRootTurnAdmission'
>;
readonly runtimeEventStore: Pick<
ExecutionRuntimeEventReader,
| 'readRuntimeEventsBounded'
| 'listSessionInvocations'
| 'listSessionInvocationsBounded'
| 'listSessionInvocationsPage'
| 'readInvocation'
>;
}
/** Host-owned, payload-safe read model for live Interactive execution evidence. */
export class HostExecutionInspectCoordinator {
readonly handlers: ExecutionInspectOperationHandlerMap = {
'execution.inspect.query': (input) => this.#query(input),
};
readonly #stores: InspectStores;
constructor(stores: InspectStores) {
this.#stores = stores;
}
async #query(
input: ExecutionInspectQueryInput,
): Promise<OperationOutcome<'execution.inspect.query'>> {
try {
const result =
input.kind === 'agent_run'
? await this.#inspectAgentRun(input.sessionId, input.agentRunId)
: input.kind === 'session'
? await this.#inspectSession(input.sessionId)
: input.kind === 'turn_trace'
? await this.#inspectTurnTrace(input.sessionId, input.turnId)
: await this.#inspectSessionTracePage(input);
if (result === undefined) {
return failure('not_found', 'Execution evidence was not found');
}
if (encodedBytes(result) > EXECUTION_INSPECT_RESULT_MAX_BYTES) {
return failure(
'invalid_request',
input.kind === 'session'
? 'Session inspection exceeds the live Host result limit; inspect one AgentRun instead'
: input.kind === 'turn_trace'
? 'Turn trace exceeds the live Host result limit'
: input.kind === 'session_trace_start' || input.kind === 'session_trace_continue'
? 'One Session trace page exceeds the live Host result limit'
: 'AgentRun inspection exceeds the live Host result limit',
);
}
return { ok: true, result };
} catch (error) {
if (error instanceof InspectQueryTooLargeError) {
return failure('invalid_request', error.message);
}
if (error instanceof InspectQueryInvalidError) {
return failure('invalid_request', error.message);
}
return failure('persistence_failed', 'Execution evidence is unavailable');
}
}
async #inspectAgentRun(
sessionId: string,
agentRunId: string,
): Promise<ExecutionInspectQueryResult | undefined> {
let invocation;
try {
invocation = await readRunInvocation(this.#stores.runtimeEventStore, sessionId, agentRunId);
} catch (error) {
if (isMissing(error)) return undefined;
throw error;
}
if (!invocation) return undefined;
const document: AgentRunInspectDocument = await inspectAgentRunDocument(
...this.#budgetedReaders('AgentRun'),
{
sessionId,
agentRunId,
invocation,
isFatalReadError: isInspectQueryTooLargeError,
},
);
return { kind: 'agent_run', document };
}
async #inspectSession(sessionId: string): Promise<ExecutionInspectQueryResult | undefined> {
let header;
try {
header = await this.#stores.sessionStore.readHeaderSnapshot(sessionId);
} catch (error) {
if (isMissing(error)) return undefined;
throw error;
}
const runPage = await this.#stores.runtimeEventStore.listSessionInvocationsBounded(
sessionId,
EXECUTION_INSPECT_SESSION_MAX_RUNS,
);
if (runPage.truncated) {
throw new InspectQueryTooLargeError(
'Session inspection exceeds the live Host run limit; inspect one AgentRun instead',
);
}
const readers = this.#budgetedReaders('Session');
const document: SessionInspectDocument = await inspectSessionDocument(
{ readHeader: (id) => this.#stores.sessionStore.readHeaderSnapshot(id) },
readers[0],
readers[1],
sessionId,
{
header,
invocations: runPage.invocations,
isFatalReadError: isInspectQueryTooLargeError,
},
);
return { kind: 'session', document };
}
async #inspectSessionTracePage(
input: Extract<
ExecutionInspectQueryInput,
{ kind: 'session_trace_start' | 'session_trace_continue' }
>,
): Promise<ExecutionInspectQueryResult | undefined> {
try {
await this.#stores.sessionStore.readHeaderSnapshot(input.sessionId);
} catch (error) {
if (isMissing(error)) return undefined;
throw error;
}
const before =
input.kind === 'session_trace_continue' ? decodeTraceCursor(input.cursor) : undefined;
const runPage = await this.#stores.runtimeEventStore.listSessionInvocationsPage(
input.sessionId,
{
...(before ? { before } : {}),
limit: EXECUTION_INSPECT_TRACE_PAGE_MAX_TURNS,
},
);
const budget = new InspectEvidenceBudget('Session');
const runtimeEvents: RuntimeEvent[] = [];
const modelCallAttempts: ModelCallAttempt[] = [];
let unreadableRecords = 0;
let includedRuns = 0;
let acceptedPage: ExecutionInspectQueryResult | undefined;
for (const run of runPage.invocations) {
let evidence: {
readonly runtimeEvents: RuntimeEvent[];
readonly modelCallAttempts: ModelCallAttempt[];
readonly unreadableRecords: number;
};
try {
evidence = await this.#readRunTraceEvidence(input.sessionId, run.runId, budget);
} catch (error) {
if (!isInspectQueryTooLargeError(error)) throw error;
if (includedRuns > 0) break;
return oversizedTracePage(
input.sessionId,
tracePageCursorAfter(runPage.invocations, 1, runPage.nextCursor),
);
}
const candidateRuntimeEvents = [...runtimeEvents, ...evidence.runtimeEvents];
const candidateModelCallAttempts = [...modelCallAttempts, ...evidence.modelCallAttempts];
const candidateUnreadableRecords = unreadableRecords + evidence.unreadableRecords;
const candidateTrace = projectSessionTrace({
sessionId: input.sessionId,
runtimeEvents: candidateRuntimeEvents,
modelCallAttempts: candidateModelCallAttempts,
...(candidateUnreadableRecords > 0
? { unreadableRecords: candidateUnreadableRecords }
: {}),
});
const candidateRunCount = includedRuns + 1;
const candidatePage: ExecutionInspectQueryResult = {
kind: 'session_trace_page',
...candidateTrace,
nextCursor: tracePageCursorAfter(
runPage.invocations,
candidateRunCount,
runPage.nextCursor,
),
};
if (
candidateTrace.turns.length > EXECUTION_INSPECT_TRACE_PAGE_MAX_TURNS ||
encodedBytes(candidatePage) > EXECUTION_INSPECT_RESULT_MAX_BYTES
) {
if (includedRuns === 0) {
return oversizedTracePage(
input.sessionId,
tracePageCursorAfter(runPage.invocations, 1, runPage.nextCursor),
);
}
break;
}
runtimeEvents.push(...evidence.runtimeEvents);
modelCallAttempts.push(...evidence.modelCallAttempts);
unreadableRecords = candidateUnreadableRecords;
includedRuns = candidateRunCount;
acceptedPage = candidatePage;
}
return (
acceptedPage ?? {
kind: 'session_trace_page',
...projectSessionTrace({
sessionId: input.sessionId,
runtimeEvents: [],
modelCallAttempts: [],
}),
nextCursor: tracePageCursorAfter(runPage.invocations, 0, runPage.nextCursor),
}
);
}
async #inspectTurnTrace(
sessionId: string,
turnId: string,
): Promise<ExecutionInspectQueryResult | undefined> {
try {
await this.#stores.sessionStore.readHeaderSnapshot(sessionId);
} catch (error) {
if (isMissing(error)) return undefined;
throw error;
}
const admission = await this.#stores.agentRunStore.readRootTurnAdmission(sessionId, turnId);
if (!admission) return undefined;
let run;
try {
run = await readRunInvocation(this.#stores.runtimeEventStore, sessionId, admission.runId);
} catch (error) {
if (isMissing(error)) return undefined;
throw error;
}
if (!run) return undefined;
if (run.turnId !== turnId) {
throw new InspectQueryInvalidError('Turn trace admission does not match its AgentRun');
}
const evidence = await this.#readRunTraceEvidence(
sessionId,
admission.runId,
new InspectEvidenceBudget('Turn'),
);
const turn = projectSessionTrace({
sessionId,
runtimeEvents: evidence.runtimeEvents,
modelCallAttempts: evidence.modelCallAttempts,
...(evidence.unreadableRecords > 0 ? { unreadableRecords: evidence.unreadableRecords } : {}),
}).turns.find(
(candidate) => candidate.turnId === turnId && candidate.runId === admission.runId,
);
return turn ? { kind: 'turn_trace', sessionId, turn } : undefined;
}
async #readRunTraceEvidence(
sessionId: string,
runId: string,
budget: InspectEvidenceBudget,
): Promise<{
readonly runtimeEvents: RuntimeEvent[];
readonly modelCallAttempts: ModelCallAttempt[];
readonly unreadableRecords: number;
}> {
let unreadableRecords = 0;
const runEvents = await budget
.read((remaining) =>
this.#stores.agentRunStore.readEventsByTypeBounded(
sessionId,
runId,
MODEL_CALL_ATTEMPT_EVENT_TYPE,
remaining,
),
)
.catch((error) => {
if (isInspectQueryTooLargeError(error)) throw error;
unreadableRecords += 1;
return [];
});
const modelCallAttempts: ModelCallAttempt[] = [];
for (const event of runEvents) {
if (event.type === 'event_corrupt') {
unreadableRecords += 1;
continue;
}
if (event.type !== MODEL_CALL_ATTEMPT_EVENT_TYPE) continue;
try {
modelCallAttempts.push(decodeModelCallAttempt(event.data));
} catch {
unreadableRecords += 1;
}
}
const runtimeEvents = await budget.read((remaining) =>
this.#stores.runtimeEventStore.readRuntimeEventsBounded(sessionId, runId, remaining),
);
return { runtimeEvents, modelCallAttempts, unreadableRecords };
}
#budgetedReaders(label: 'AgentRun' | 'Session') {
const budget = new InspectEvidenceBudget(label);
return [
{
readEvents: (sessionId: string, runId: string) =>
budget.read((remaining) =>
this.#stores.agentRunStore.readEventsBounded(sessionId, runId, remaining),
),
},
{
readRuntimeEvents: (sessionId: string, runId: string) =>
budget.read((remaining) =>
this.#stores.runtimeEventStore.readRuntimeEventsBounded(sessionId, runId, remaining),
),
listSessionInvocations: (sessionId: string) =>
this.#stores.runtimeEventStore.listSessionInvocations(sessionId),
readInvocation: (sessionId: string, invocationId: string) =>
this.#stores.runtimeEventStore.readInvocation(sessionId, invocationId),
},
] as const;
}
}
class InspectQueryTooLargeError extends Error {
readonly name = 'InspectQueryTooLargeError';
}
class InspectQueryInvalidError extends Error {
readonly name = 'InspectQueryInvalidError';
}
class InspectEvidenceBudget {
#remainingRecords = EXECUTION_INSPECT_EVIDENCE_MAX_RECORDS;
#remainingBytes = EXECUTION_INSPECT_EVIDENCE_MAX_BYTES;
constructor(private readonly label: 'AgentRun' | 'Session' | 'Turn') {}
async read<T>(
operation: (budget: EvidenceReadBudget) => Promise<BoundedEvidenceReadResult<T>>,
): Promise<T[]> {
const result = await operation({
maxRecords: this.#remainingRecords,
maxBytes: this.#remainingBytes,
});
if (result.status === 'limit_exceeded') this.#throwExceeded();
this.#remainingRecords -= result.sourceRecordCount;
this.#remainingBytes -= result.storedBytes;
return [...result.records];
}
#throwExceeded(): never {
throw new InspectQueryTooLargeError(
`${this.label} inspection exceeds the live Host evidence limit; stop the Host to inspect it offline`,
);
}
}
function encodedBytes(result: ExecutionInspectQueryResult): number {
return Buffer.byteLength(JSON.stringify(result), 'utf8');
}
function oversizedTracePage(
sessionId: string,
nextCursor: string | null,
): ExecutionInspectQueryResult {
return {
kind: 'session_trace_page',
...projectSessionTrace({
sessionId,
runtimeEvents: [],
modelCallAttempts: [],
oversizedRuns: 1,
}),
nextCursor,
};
}
function tracePageCursorAfter(
invocations: readonly RuntimeInvocationRecord[],
includedRuns: number,
sourceNextCursor: RuntimeInvocationPageCursor | null,
): string | null {
const last = invocations[includedRuns - 1];
if (!last) return null;
const hasMore = includedRuns < invocations.length || sourceNextCursor !== null;
return hasMore
? encodeTraceCursor({ openedAt: last.openedAt, invocationId: last.invocationId })
: null;
}
function encodeTraceCursor(cursor: RuntimeInvocationPageCursor): string {
return Buffer.from(JSON.stringify({ v: 1, ...cursor }), 'utf8').toString('base64url');
}
function decodeTraceCursor(cursor: string): RuntimeInvocationPageCursor {
try {
const value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as Record<
string,
unknown
>;
if (
value.v !== 1 ||
typeof value.openedAt !== 'number' ||
!Number.isFinite(value.openedAt) ||
typeof value.invocationId !== 'string' ||
!/^[A-Za-z0-9_-]{1,128}$/.test(value.invocationId) ||
Object.keys(value).length !== 3
) {
throw new Error('invalid cursor');
}
return { openedAt: value.openedAt, invocationId: value.invocationId };
} catch {
throw new InspectQueryInvalidError('Session trace continuation cursor is invalid');
}
}
function isMissing(error: unknown): boolean {
return isSessionNotFoundError(error) || (error as NodeJS.ErrnoException)?.code === 'ENOENT';
}
function isInspectQueryTooLargeError(error: unknown): boolean {
return error instanceof InspectQueryTooLargeError;
}
function failure(
code: Extract<OperationOutcome<'execution.inspect.query'>, { ok: false }>['error']['code'],
message: string,
): OperationOutcome<'execution.inspect.query'> {
return { ok: false, error: { code, message } };
}