/** * In-memory tool-use trace store, adapted from openclaw-lark's * tool-use-trace-store. One trace per agent run; the card builder * reads steps from here to render the tool-use panel. * * The store is per-run (not per-session) because each run has its * own card lifecycle. Steps are capped to prevent unbounded memory. */ export interface ToolUseTraceStep { readonly id: string; readonly seq: number; readonly toolName: string; readonly toolUseId: string | undefined; readonly input: unknown; readonly result: unknown; readonly error: string | undefined; readonly durationMs: number | undefined; readonly status: "running" | "success" | "error"; readonly startedAt: number; readonly finishedAt: number | undefined; } interface RunTraceState { nextSeq: number; updatedAt: number; steps: ToolUseTraceStep[]; } const MAX_STEPS = 64; const STEP_RUNNING_TIMEOUT_MS = 5 * 60 * 1000; const RUN_TTL_MS = 30 * 60 * 1000; const RESULT_LIMIT = 2048; const GENERIC_LIMIT = 512; const runs = new Map(); export function startToolUseTraceRun(runId: string): void { pruneStaleRuns(); runs.set(runId, { nextSeq: 1, updatedAt: Date.now(), steps: [] }); } export function clearToolUseTraceRun(runId: string): void { runs.delete(runId); } export function recordToolUseStart(params: { runId: string; toolName: string; toolUseId: string | undefined; input: unknown; }): void { const state = runs.get(params.runId); if (state === undefined) return; if (state.steps.length >= MAX_STEPS) { state.steps.splice(0, state.steps.length - MAX_STEPS + 1); } const now = Date.now(); state.steps.push({ id: `${state.nextSeq}`, seq: state.nextSeq, toolName: params.toolName, toolUseId: params.toolUseId, input: sanitizeTraceValue(params.input), result: undefined, error: undefined, durationMs: undefined, status: "running", startedAt: now, finishedAt: undefined, }); state.nextSeq += 1; state.updatedAt = now; } export function recordToolUseEnd(params: { runId: string; toolName: string; toolUseId: string | undefined; result: unknown; error: string | undefined; durationMs: number | undefined; }): void { const state = runs.get(params.runId); if (state === undefined) return; const now = Date.now(); const pendingIndex = findPendingStepIndex(state.steps, params.toolUseId, params.toolName); if (pendingIndex >= 0) { const step = state.steps[pendingIndex]; if (step !== undefined) { (state.steps as Array)[pendingIndex] = { ...step, status: params.error !== undefined ? "error" : "success", result: sanitizeTraceValue(params.result), error: params.error !== undefined ? truncate(params.error, 160) : undefined, durationMs: params.durationMs, finishedAt: now, }; state.updatedAt = now; return; } } // No matching pending step — record as already-completed state.steps.push({ id: `${state.nextSeq}`, seq: state.nextSeq, toolName: params.toolName, toolUseId: params.toolUseId, input: undefined, result: sanitizeTraceValue(params.result), error: params.error !== undefined ? truncate(params.error, 160) : undefined, durationMs: params.durationMs, status: params.error !== undefined ? "error" : "success", startedAt: now, finishedAt: now, }); state.nextSeq += 1; state.updatedAt = now; } export function getToolUseTraceSteps(runId: string): ToolUseTraceStep[] { const state = runs.get(runId); if (state === undefined) return []; if (Date.now() - state.updatedAt > RUN_TTL_MS) { runs.delete(runId); return []; } const now = Date.now(); return state.steps.map((step) => { if (step.status === "running" && now - step.startedAt > STEP_RUNNING_TIMEOUT_MS) { return { ...step, status: "error" as const, error: "timed out", finishedAt: now }; } return { ...step }; }); } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- function findPendingStepIndex( steps: ToolUseTraceStep[], toolUseId: string | undefined, toolName: string | undefined, ): number { if (toolUseId !== undefined) { for (let i = steps.length - 1; i >= 0; i -= 1) { const step = steps[i]; if (step !== undefined && step.status === "running" && step.toolUseId === toolUseId) { return i; } } } if (toolName !== undefined) { for (let i = steps.length - 1; i >= 0; i -= 1) { const step = steps[i]; if (step !== undefined && step.status === "running" && step.toolName === toolName) { return i; } } } return -1; } function pruneStaleRuns(): void { const now = Date.now(); for (const [runId, state] of runs) { if (now - state.updatedAt > RUN_TTL_MS) { runs.delete(runId); } } } function truncate(value: string, maxLength: number): string { return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`; } /** * Sanitize a tool input or result value for display. * Truncates strings, redacts sensitive keys, limits depth. */ function sanitizeTraceValue( value: unknown, depth = 0, context: { source: "input" | "result" | undefined; key: string | undefined } = { source: undefined, key: undefined }, ): unknown { if (value === null || value === undefined) return undefined; if (typeof value === "string") { const limit = context.source === "result" ? RESULT_LIMIT : GENERIC_LIMIT; return truncate(value, limit); } if (typeof value === "number" || typeof value === "boolean") return value; if (depth >= 2) return "[truncated]"; if (Array.isArray(value)) { return value.slice(0, 8).map((item) => sanitizeTraceValue(item, depth + 1, { source: context.source, key: undefined })); } if (typeof value === "object") { const input = value as Record; const output: Record = {}; for (const [key, entryValue] of Object.entries(input).slice(0, 12)) { output[key] = isSensitiveKey(key) ? "[redacted]" : sanitizeTraceValue(entryValue, depth + 1, { source: context.source, key }); } return output; } return truncate(String(value), 180); } const SENSITIVE_KEY_RE = /token|secret|password|api[_-]?key|authorization|cookie|credential|bearer|session[_-]?id|client[_-]?secret|access[_-]?key/i; function isSensitiveKey(key: string): boolean { return SENSITIVE_KEY_RE.test(key); }