import { canonicalToolTrace, formatToolCallTrace } from "@/lib/tool-traces"; import type { ToolProgressEvent } from "@/lib/types"; import { redactActivityText, safeActivityDetail } from "./activity-text"; import { displayWebHost, formatCompactWebUrl, parseSafeActivityHttpUrl } from "./web-url"; export type WebSearchStatus = "running" | "done" | "error"; export interface WebSearchSource { title: string; href: string; host: string; displayUrl: string; } export interface WebSearchRunModel { key: string; query: string; status: WebSearchStatus; sources: WebSearchSource[]; error?: string; } interface WebSearchQueryPresentation { query: string; scope?: string; } const WEB_SEARCH_STATUS_RANK: Record = { running: 1, done: 2, error: 3, }; const MAX_VISIBLE_SOURCES = 8; export function webSearchRunsByTraceLine( events: ToolProgressEvent[], ): Map { const runs = new Map(); for (const event of events) { const run = webSearchRunFromEvent(event); const line = run ? formatToolCallTrace(event) : null; if (!run || !line) continue; const key = canonicalToolTrace(line); runs.set(key, mergeWebSearchRun(runs.get(key), run)); } return runs; } function webSearchRunFromEvent(event: ToolProgressEvent): WebSearchRunModel | null { const name = compactToolName(toolEventName(event)); if (name !== "web_search") return null; const args = toolEventArguments(event); const query = stringField(args, ["query", "q", "text"]); const status: WebSearchStatus = event.phase === "error" ? "error" : event.phase === "end" ? "done" : "running"; return { key: event.call_id ? `call:${event.call_id}` : formatToolCallTrace(event) ?? `web_search:${query}`, query, status, sources: status === "done" ? webSearchSources(event.result) : [], error: status === "error" ? readableError(event.error) : undefined, }; } function presentWebSearchQuery(query: string): WebSearchQueryPresentation { const scopes: string[] = []; const safeQuery = redactActivityText(query); const cleanQuery = safeQuery .replace(/(?:^|\s)site:([^\s]+)/gi, (_match, rawSite: string) => { const scope = webSearchScope(rawSite); if (scope && !scopes.includes(scope)) scopes.push(scope); return " "; }) .replace(/\s+/g, " ") .trim(); return { query: cleanQuery || safeQuery.trim(), ...(scopes.length === 1 ? { scope: scopes[0] } : {}), }; } export function presentWebSearchAction( query: string, status: WebSearchStatus, ): string { const presentation = presentWebSearchQuery(query); const verb = status === "error" ? "Could not search" : status === "running" ? "Searching" : "Searched"; const target = [presentation.scope, presentation.query].filter(Boolean).join(" · "); return target ? `${verb} ${target}` : verb; } function mergeWebSearchRun( existing: WebSearchRunModel | undefined, incoming: WebSearchRunModel, ): WebSearchRunModel { if (!existing) return incoming; if (WEB_SEARCH_STATUS_RANK[incoming.status] < WEB_SEARCH_STATUS_RANK[existing.status]) { return existing; } return { ...existing, ...incoming, query: incoming.query || existing.query, sources: incoming.sources.length ? incoming.sources : existing.sources, }; } function webSearchSources(result: unknown): WebSearchSource[] { const candidates = structuredCandidates(result); if (typeof result === "string") candidates.push(...textCandidates(result)); if (result && typeof result === "object" && !Array.isArray(result)) { const record = result as Record; for (const key of ["content", "text", "result"]) { if (typeof record[key] === "string") candidates.push(...textCandidates(record[key])); } } const seen = new Set(); const sources: WebSearchSource[] = []; for (const candidate of candidates) { const url = parseSafeActivityHttpUrl(candidate.url); if (!url || seen.has(url.href)) continue; seen.add(url.href); sources.push({ title: cleanTitle(candidate.title) || displayWebHost(url.hostname), href: url.href, host: displayWebHost(url.hostname), displayUrl: formatCompactWebUrl(url), }); if (sources.length >= MAX_VISIBLE_SOURCES) break; } return sources; } function structuredCandidates(value: unknown): Array<{ title: string; url: string }> { const items: unknown[] = []; if (Array.isArray(value)) items.push(...value); if (value && typeof value === "object" && !Array.isArray(value)) { const record = value as Record; for (const key of ["results", "items", "sources", "data"]) { if (Array.isArray(record[key])) items.push(...record[key]); } } return items.flatMap((item) => { if (!item || typeof item !== "object" || Array.isArray(item)) return []; const record = item as Record; const title = stringField(record, ["title", "name", "label"]); const url = stringField(record, ["url", "href", "link", "uri"]); return url ? [{ title, url }] : []; }); } function textCandidates(text: string): Array<{ title: string; url: string }> { const lines = text.split(/\r?\n/).map((line) => line.trim()); const candidates: Array<{ title: string; url: string }> = []; for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; if (!line) continue; const markdownLink = /^\s*(?:\d+[.)]\s*)?\[([^\]]+)]\((https?:\/\/[^)]+)\)\s*$/.exec(line); if (markdownLink) { candidates.push({ title: markdownLink[1], url: markdownLink[2] }); continue; } const numberedTitle = /^\d+[.)]\s+(.+)$/.exec(line); if (!numberedTitle) continue; const inlineUrl = firstHttpUrl(numberedTitle[1]); if (inlineUrl) { candidates.push({ title: numberedTitle[1].replace(inlineUrl, "").replace(/[\s:|\-–—]+$/, ""), url: inlineUrl, }); continue; } for (let next = index + 1; next < lines.length; next += 1) { if (/^\d+[.)]\s+/.test(lines[next])) break; const url = firstHttpUrl(lines[next]); if (!url) continue; candidates.push({ title: numberedTitle[1], url }); break; } } return candidates; } function firstHttpUrl(value: string): string { return value.match(/https?:\/\/[^\s<>"']+/i)?.[0]?.replace(/[),.;\]}]+$/, "") ?? ""; } function cleanTitle(value: string): string { return redactActivityText(value) .replace(/^#+\s*/, "") .replace(/^\*\*(.*)\*\*$/, "$1") .replace(/^__(.*)__$/, "$1") .trim(); } function compactToolName(name: string): string { return name.toLowerCase().split(".").pop() || name.toLowerCase(); } function webSearchScope(rawSite: string): string | undefined { const candidate = rawSite.replace(/^https?:\/\//i, "").replace(/^www\./i, ""); let host = candidate.split("/")[0]?.toLowerCase(); if (!host) return undefined; if (host.startsWith("www.")) host = host.slice(4); const knownScope = WEB_SEARCH_SCOPE_NAMES[host]; return knownScope ?? displayWebHost(host); } const WEB_SEARCH_SCOPE_NAMES: Record = { "anthropic.com": "Anthropic", "crunchbase.com": "Crunchbase", "github.com": "GitHub", "linkedin.com": "LinkedIn", "openai.com": "OpenAI", "reddit.com": "Reddit", "x.com": "X", "youtube.com": "YouTube", }; function toolEventName(event: ToolProgressEvent): string { const functionName = (event as { function?: { name?: unknown } }).function?.name; if (typeof functionName === "string") return functionName; return typeof event.name === "string" ? event.name : ""; } function toolEventArguments(event: ToolProgressEvent): unknown { const functionArgs = (event as { function?: { arguments?: unknown } }).function?.arguments; const raw = functionArgs ?? event.arguments; if (typeof raw !== "string") return raw ?? {}; try { return raw.trim() ? JSON.parse(raw) : {}; } catch { return {}; } } function stringField(value: unknown, keys: string[]): string { if (!value || typeof value !== "object" || Array.isArray(value)) return ""; const record = value as Record; for (const key of keys) { const field = record[key]; if (typeof field === "string" && field.trim()) return field.trim(); } return ""; } function readableError(error: unknown): string | undefined { if (typeof error === "string" && error.trim()) return safeErrorText(error); if (!error) return undefined; try { return safeErrorText(JSON.stringify(error)); } catch { return "Web search failed"; } } function safeErrorText(value: string): string { return safeActivityDetail(value, 240); }