import { boundToolCallArgs, capCodePoints } from "./call-capture.ts"; export const MAX_TRACE_RECORDS = 100; export const MAX_ARG_PREVIEW_CHARS = 1_000; export const MAX_ERROR_CHARS = 400; export type CellCallOutcome = "succeeded" | "failed" | "aborted" | "timed_out"; export type CellCallStage = | "resolve" | "guard" | "prepare" | "validate" | "approve" | "invoke"; export interface CellCallRecord { readonly tool: string; /** Bounded preview of the call arguments (omitted when unbounded). */ readonly args?: unknown; readonly outcome: CellCallOutcome; /** Failure stage; present on failed/aborted/timed_out calls. */ readonly stage?: CellCallStage; /** Bounded error message. */ readonly error?: string; } export interface CellCallInput { readonly tool: string; readonly args?: unknown; readonly outcome: CellCallOutcome; readonly stage?: CellCallStage; readonly error?: unknown; } export interface CellTrace { readonly records: readonly CellCallRecord[]; readonly droppedRecords: number; } const boundErrorMessage = (error: unknown): string => { const message = error instanceof Error ? error.message : typeof error === "string" ? error : String(error); return capCodePoints(message, MAX_ERROR_CHARS - 1); }; /** * Bound the argument preview: reuse the shared capture bounds, then cap the * retained serialization at MAX_ARG_PREVIEW_CHARS. When the capped JSON no * longer fits, the preview degrades to a truncated JSON string; when the * shared capture omits the arguments entirely, the preview is undefined. */ const boundArgPreview = (args: unknown): unknown | undefined => { const bounded = boundToolCallArgs(args); if (bounded.args === undefined) return undefined; const serialized = JSON.stringify(bounded.args); if (serialized !== undefined && serialized.length <= MAX_ARG_PREVIEW_CHARS) { return bounded.args; } return capCodePoints(serialized ?? String(bounded.args), MAX_ARG_PREVIEW_CHARS - 1); }; /** * Audit-style call trace for one cell. Records tool calls with their * outcome and — for failed calls — the failure stage and a bounded error * message. The trace is bounded at MAX_TRACE_RECORDS records per cell; * calls recorded past the bound are dropped and counted. `seal` freezes * the trace; later records are ignored. */ export class CellCallTracer { readonly #records: CellCallRecord[] = []; #droppedRecords = 0; #trace: CellTrace | undefined; sealed = false; get recordCount(): number { return this.#records.length; } get droppedRecordCount(): number { return this.#droppedRecords; } record(call: CellCallInput): void { if (this.sealed) return; if (this.#records.length >= MAX_TRACE_RECORDS) { this.#droppedRecords += 1; return; } const args = call.args === undefined ? undefined : boundArgPreview(call.args); const error = call.error === undefined ? undefined : boundErrorMessage(call.error); this.#records.push({ tool: call.tool, ...(args === undefined ? {} : { args }), outcome: call.outcome, ...(call.stage === undefined ? {} : { stage: call.stage }), ...(error === undefined ? {} : { error }), }); } seal(): CellTrace { if (this.#trace === undefined) { this.sealed = true; this.#trace = { records: [...this.#records], droppedRecords: this.#droppedRecords, }; } return this.#trace; } }