blob: 3f2d59e71e7f2fe72de5024dade8e6002653eb35 [file]
import { createHash } from 'node:crypto';
import { generalizedErrorMessage, redactSecrets } from '@maka/core';
import type {
CacheMissInputSource,
ContextBudgetDiagnostic,
PrefixChangeReason,
PromptSegmentEstimate,
ToolSchemaChangeReason,
ToolAvailabilityDiagnostic,
} from '@maka/core';
import type { SandboxRunTraceProjection } from './sandbox/diagnostics.js';
export type RunTracePhase =
| 'turn'
| 'model'
| 'tool'
| 'permission'
| 'sandbox'
| 'skill'
| 'plan'
| 'agent_graph'
| 'abort'
| 'usage';
export type RunTraceEventType =
| 'turn_started'
| 'sandbox_context_resolved'
| 'plan_context_resolved'
| 'plan_submitted'
| 'plan_execution_started'
| 'plan_progress_updated'
| 'plan_execution_completed'
| 'plan_execution_cancelled'
| 'plan_execution_interrupted'
| 'plan_execution_resumed'
| 'plan_transition_failed'
| 'graph_supervisor_yielded'
| 'model_resolved'
| 'model_resolve_failed'
| 'model_stream_started'
| 'model_stream_completed'
| 'model_stream_failed'
| 'send_diagnostics_recorded'
| 'tool_started'
| 'tool_completed'
| 'tool_failed'
| 'skill_catalog_built'
| 'skill_searched'
| 'skill_loaded'
| 'skill_load_failed'
| 'permission_requested'
| 'permission_decided'
| 'permission_failed'
| 'approval_routed'
| 'auto_review_started'
| 'auto_review_decided'
| 'auto_review_failed'
| 'sandbox_escalation_requested'
| 'sandbox_escalation_granted'
| 'sandbox_escalation_denied'
| 'sandbox_escalation_applied'
| 'sandbox_escalation_failed'
| 'sandbox_denial_detected'
| 'abort_requested';
export interface RunTraceEvent {
id: string;
sessionId: string;
turnId: string;
ts: number;
phase: RunTracePhase;
type: RunTraceEventType;
message: string;
data?: Record<string, unknown>;
}
export type RunTraceRecorder = (event: RunTraceEvent) => unknown;
const REDACTED_ERROR_MESSAGE_MAX_CHARS = 2_048;
export interface RunTraceInput {
sessionId: string;
turnId: string;
connectionSlug: string;
providerId: string;
modelId: string;
newId: () => string;
now: () => number;
record?: RunTraceRecorder;
}
export class RunTrace {
constructor(private readonly input: RunTraceInput) {}
emit(
phase: RunTracePhase,
type: RunTraceEventType,
message: string,
data?: Record<string, unknown>,
): void {
const event: RunTraceEvent = {
id: this.input.newId(),
sessionId: this.input.sessionId,
turnId: this.input.turnId,
ts: this.input.now(),
phase,
type,
message,
...(data ? { data: sanitizeTraceData(data) } : {}),
};
try {
const recorded = this.input.record?.(event);
if (isPromiseLike(recorded)) void Promise.resolve(recorded).catch(() => {});
} catch {
// Tracing is diagnostic-only and must not perturb model/tool execution.
}
}
turnStarted(extra: Record<string, unknown> = {}): void {
this.emit('turn', 'turn_started', 'Turn started', {
connectionSlug: this.input.connectionSlug,
providerId: this.input.providerId,
modelId: this.input.modelId,
...extra,
});
}
sandboxContextResolved(snapshot: SandboxRunTraceProjection): void {
this.emit('sandbox', 'sandbox_context_resolved', 'Sandbox context resolved', { snapshot });
}
modelResolved(): void {
this.emit('model', 'model_resolved', 'Model resolved', {
connectionSlug: this.input.connectionSlug,
providerId: this.input.providerId,
modelId: this.input.modelId,
});
}
modelResolveFailed(error: unknown): void {
this.emit('model', 'model_resolve_failed', 'Model resolution failed', {
error: explainError(error),
});
}
modelStreamStarted(
activeTools: readonly string[],
prefix?: {
systemPromptHash?: string;
prefixHash: string;
prefixChangeReason: PrefixChangeReason;
requestShapeHash?: string;
requestShapeChangeReason?: PrefixChangeReason;
toolSchemaChangeReason?: ToolSchemaChangeReason;
toolAvailability?: ToolAvailabilityDiagnostic;
promptSegments?: PromptSegmentEstimate[];
contextBudget?: ContextBudgetDiagnostic;
},
): void {
this.emit('model', 'model_stream_started', 'Model stream started', {
activeTools: [...activeTools],
...(prefix !== undefined ? prefix : {}),
});
}
modelStreamCompleted(stopReason: string): void {
this.emit('model', 'model_stream_completed', 'Model stream completed', {
stopReason,
});
}
modelStreamFailed(
errorClass: string | undefined,
error: unknown,
replay?: { gate: string; diagnosticCodes: readonly string[] },
): void {
this.emit('model', 'model_stream_failed', 'Model stream failed', {
...(errorClass ? { errorClass } : {}),
...(replay
? {
priorReplayGate: replay.gate,
priorReplayDiagnosticCodes: [...replay.diagnosticCodes],
}
: {}),
error: explainError(error),
...diagnoseError(error),
});
}
/**
* Terminal diagnostics for one send: what the request actually cost in
* context, and how the send ended.
*
* Explicitly not accounting. It carries no cost and is not summed by
* anything — spend lives in `ModelCallAttempt`, one record per physical
* provider request (#1679). This exists because the exhausted and aborted
* paths produce no `token_usage` SessionEvent, so their compaction decisions
* and accumulated step usage would otherwise have no durable home at all.
*/
sendDiagnostics(diagnostics: {
status: string;
errorClass?: string;
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
contextBudget?: unknown;
promptSegments?: readonly unknown[];
systemPromptHash?: string;
prefixHash?: string;
prefixChangeReason?: PrefixChangeReason;
requestShapeHash?: string;
requestShapeChangeReason?: PrefixChangeReason;
toolSchemaChangeReason?: ToolSchemaChangeReason;
toolAvailability?: ToolAvailabilityDiagnostic;
}): void {
this.emit('model', 'send_diagnostics_recorded', 'Send diagnostics recorded', {
status: diagnostics.status,
...(diagnostics.errorClass !== undefined ? { errorClass: diagnostics.errorClass } : {}),
...(diagnostics.inputTokens !== undefined ? { inputTokens: diagnostics.inputTokens } : {}),
...(diagnostics.outputTokens !== undefined ? { outputTokens: diagnostics.outputTokens } : {}),
...(diagnostics.totalTokens !== undefined ? { totalTokens: diagnostics.totalTokens } : {}),
...(diagnostics.contextBudget !== undefined
? { contextBudget: diagnostics.contextBudget }
: {}),
...(diagnostics.promptSegments !== undefined && diagnostics.promptSegments.length > 0
? { promptSegments: diagnostics.promptSegments }
: {}),
// The FINAL request shape, not step 0's: a same-turn tool load changes it
// mid-send, and `model_stream_started` reports only what the first
// request carried.
...(diagnostics.systemPromptHash !== undefined
? { systemPromptHash: diagnostics.systemPromptHash }
: {}),
...(diagnostics.prefixHash !== undefined ? { prefixHash: diagnostics.prefixHash } : {}),
...(diagnostics.prefixChangeReason !== undefined
? { prefixChangeReason: diagnostics.prefixChangeReason }
: {}),
...(diagnostics.requestShapeHash !== undefined
? { requestShapeHash: diagnostics.requestShapeHash }
: {}),
...(diagnostics.requestShapeChangeReason !== undefined
? { requestShapeChangeReason: diagnostics.requestShapeChangeReason }
: {}),
...(diagnostics.toolSchemaChangeReason !== undefined
? { toolSchemaChangeReason: diagnostics.toolSchemaChangeReason }
: {}),
...(diagnostics.toolAvailability !== undefined
? { toolAvailability: diagnostics.toolAvailability }
: {}),
});
}
abortRequested(reason: string): void {
this.emit('abort', 'abort_requested', 'Abort requested', { reason });
}
}
export interface RunTraceLike {
emit(
phase: RunTracePhase,
type: RunTraceEventType,
message: string,
data?: Record<string, unknown>,
): void;
}
export function explainError(error: unknown): string {
return generalizedErrorMessage(error);
}
function diagnoseError(error: unknown): Record<string, unknown> {
const rawMessage = rawErrorMessage(error);
const redactedMessage = redactSecrets(rawMessage);
const stack =
error instanceof Error && typeof error.stack === 'string'
? redactSecrets(error.stack)
: undefined;
const message = truncate(redactedMessage, REDACTED_ERROR_MESSAGE_MAX_CHARS);
return {
rawErrorName: rawErrorName(error),
rawErrorType: typeof error,
redactedErrorMessage: message.text,
redactedErrorMessageSha256: sha256(redactedMessage),
...(message.truncated ? { redactedErrorMessageTruncated: true } : {}),
...(stack ? { redactedErrorStackSha256: sha256(stack) } : {}),
};
}
function rawErrorName(error: unknown): string {
if (error instanceof Error && typeof error.name === 'string' && error.name.length > 0) {
return error.name;
}
if (error === null) return 'null';
return typeof error;
}
function rawErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
try {
const serialized = JSON.stringify(error);
if (typeof serialized === 'string') return serialized;
} catch {
// Fall back to string coercion for cyclic or otherwise unserializable values.
}
try {
return String(error);
} catch {
return '[unprintable error]';
}
}
function truncate(value: string, maxChars: number): { text: string; truncated: boolean } {
if (value.length <= maxChars) return { text: value, truncated: false };
return { text: value.slice(0, maxChars), truncated: true };
}
function sha256(value: string): string {
return `sha256:${createHash('sha256').update(value).digest('hex')}`;
}
function sanitizeTraceData(data: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(data).filter(([, value]) => value !== undefined));
}
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
return Boolean(
value &&
(typeof value === 'object' || typeof value === 'function') &&
'then' in value &&
typeof value.then === 'function',
);
}