nanobot/webui/src/lib/tool-traces.ts

119 lines
3.9 KiB
TypeScript

import type { ToolProgressEvent } from "@/lib/types";
export function formatToolCallTrace(call: unknown): string | null {
if (!call || typeof call !== "object") return null;
const item = call as {
name?: unknown;
arguments?: unknown;
function?: { name?: unknown; arguments?: unknown };
};
const name =
typeof item.function?.name === "string"
? item.function.name
: typeof item.name === "string"
? item.name
: "";
if (!name) return null;
const args = item.function?.arguments ?? item.arguments;
if (typeof args === "string" && args.trim()) return `${name}(${args})`;
if (args && typeof args === "object") return `${name}(${JSON.stringify(args)})`;
return `${name}()`;
}
export function canonicalToolTrace(line: string): string {
const trimmed = line.trim();
const match = /^([a-zA-Z0-9_.-]+)\((.*)\)$/.exec(trimmed);
if (!match) return trimmed;
const args = match[2].trim();
if (!args) return `${match[1]}()`;
try {
return `${match[1]}(${JSON.stringify(JSON.parse(args))})`;
} catch {
return trimmed;
}
}
const VALID_PHASES = new Set(["start", "end", "error"]);
const PHASE_RANK: Record<string, number> = { start: 1, end: 2, error: 3 };
export function normalizeToolProgressEvents(events: unknown): ToolProgressEvent[] {
if (!Array.isArray(events)) return [];
const out: ToolProgressEvent[] = [];
for (const event of events) {
if (!event || typeof event !== "object") continue;
const record = event as ToolProgressEvent;
const phase = record.phase;
if (!(phase && typeof phase === "string" && VALID_PHASES.has(phase))) continue;
const name = typeof record.name === "string" ? record.name : "";
const functionName =
typeof (record as { function?: { name?: unknown } }).function?.name === "string"
? String((record as { function?: { name?: unknown } }).function?.name)
: "";
if (!name && !functionName) continue;
out.push(record);
}
return out;
}
function toolEventKey(event: ToolProgressEvent): string {
if (event.call_id) return `call:${event.call_id}`;
return formatToolCallTrace(event) ?? JSON.stringify(event);
}
export function mergeToolProgressEvents(
previous: ToolProgressEvent[] | undefined,
incoming: ToolProgressEvent[],
): ToolProgressEvent[] {
if (!previous?.length) return incoming;
if (!incoming.length) return previous;
const next = [...previous];
const indexByKey = new Map(next.map((event, index) => [toolEventKey(event), index]));
for (const event of incoming) {
const key = toolEventKey(event);
const existingIndex = indexByKey.get(key);
if (existingIndex === undefined) {
indexByKey.set(key, next.length);
next.push(event);
continue;
}
const existing = next[existingIndex];
const incomingRank = PHASE_RANK[String(event.phase)] ?? 0;
const existingRank = PHASE_RANK[String(existing.phase)] ?? 0;
next[existingIndex] = incomingRank >= existingRank ? { ...existing, ...event } : existing;
}
return next;
}
export function toolTraceLinesFromEvents(events: unknown): string[] {
const seen = new Set<string>();
const lines: string[] = [];
for (const event of normalizeToolProgressEvents(events)) {
const callId = (event as { call_id?: unknown }).call_id;
if (callId && typeof callId === "string") {
if (seen.has(callId)) continue;
seen.add(callId);
}
const line = formatToolCallTrace(event);
if (!line) continue;
lines.push(line);
}
return lines;
}
export function mergeUniqueToolTraceLines(
previousTraces: string[],
lines: string[],
): { traces: string[]; added: boolean } {
const seen = new Set(previousTraces.map(canonicalToolTrace));
const traces = [...previousTraces];
let added = false;
for (const line of lines) {
const key = canonicalToolTrace(line);
if (seen.has(key)) continue;
seen.add(key);
traces.push(line);
added = true;
}
return { traces, added };
}