import { convert as htmlToText } from "html-to-text"; import type OpenAI from "openai"; import { z } from "zod"; import { env } from "../env.js"; import { exaClient } from "../search/exa.js"; import { searchSearxng } from "../search/searxng.js"; import type { ChatMessage } from "./types.js"; const MAX_TOOL_ROUNDS = 4; const DEFAULT_WEB_RESULTS = 5; const MAX_WEB_RESULTS = 10; const DEFAULT_FETCH_MAX_CHARACTERS = 12_000; const MAX_FETCH_MAX_CHARACTERS = 50_000; const FETCH_TIMEOUT_MS = 12_000; const WebSearchArgsSchema = z .object({ query: z.string().trim().min(1), numResults: z.coerce.number().int().min(1).max(MAX_WEB_RESULTS).optional(), type: z.enum(["auto", "fast", "instant"]).optional(), includeDomains: z.array(z.string().trim().min(1)).max(25).optional(), excludeDomains: z.array(z.string().trim().min(1)).max(25).optional(), }) .strict(); type WebSearchArgs = z.infer; const FetchUrlArgsSchema = z .object({ url: z.string().trim().url(), maxCharacters: z.coerce.number().int().min(500).max(MAX_FETCH_MAX_CHARACTERS).optional(), }) .strict(); const CHAT_TOOLS: any[] = [ { type: "function", function: { name: "web_search", description: "Search the public web for recent or factual information. Returns ranked results with per-result summaries and snippets.", parameters: { type: "object", properties: { query: { type: "string", description: "Search query." }, numResults: { type: "integer", minimum: 1, maximum: MAX_WEB_RESULTS, description: "Number of results to return (default 5).", }, type: { type: "string", enum: ["auto", "fast", "instant"], description: "Search mode.", }, includeDomains: { type: "array", items: { type: "string" }, description: "Only include these domains.", }, excludeDomains: { type: "array", items: { type: "string" }, description: "Exclude these domains.", }, }, required: ["query"], additionalProperties: false, }, }, }, { type: "function", function: { name: "fetch_url", description: "Fetch a webpage by URL and return readable plaintext content extracted from the page for deeper inspection.", parameters: { type: "object", properties: { url: { type: "string", description: "Absolute URL to fetch, including http/https." }, maxCharacters: { type: "integer", minimum: 500, maximum: MAX_FETCH_MAX_CHARACTERS, description: "Maximum response text characters returned (default 12000).", }, }, required: ["url"], additionalProperties: false, }, }, }, ]; export const CHAT_TOOL_SYSTEM_PROMPT = "You can use tools to gather up-to-date web information when needed. " + "Use web_search for discovery and recent facts, and fetch_url to read the full content of a specific page. " + "Prefer tools when the user asks for current events, verification, sources, or details you do not already have. " + "Do not fabricate tool outputs; reason only from provided tool results."; type ToolRunOutcome = { ok: boolean; [key: string]: unknown; }; type ToolAwareUsage = { inputTokens?: number; outputTokens?: number; totalTokens?: number; }; type ToolAwareCompletionResult = { text: string; usage?: ToolAwareUsage; raw: unknown; toolEvents: ToolExecutionEvent[]; }; export type ToolAwareStreamingEvent = | { type: "delta"; text: string } | { type: "tool_call"; event: ToolExecutionEvent } | { type: "done"; result: ToolAwareCompletionResult }; type ToolAwareCompletionParams = { client: OpenAI; model: string; messages: ChatMessage[]; temperature?: number; maxTokens?: number; onToolEvent?: (event: ToolExecutionEvent) => void | Promise; logContext?: { provider: string; model: string; chatId?: string; }; }; export type ToolExecutionEvent = { toolCallId: string; name: string; status: "completed" | "failed"; summary: string; args: Record; startedAt: string; completedAt: string; durationMs: number; error?: string; resultPreview?: string; }; function compactWhitespace(input: string) { return input.replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim(); } function clipText(input: string, maxCharacters: number) { return input.length <= maxCharacters ? input : `${input.slice(0, maxCharacters)}...`; } function toRecord(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; return { ...(value as Record) }; } function toSingleLine(value: string, maxLength = 220) { return clipText( value .replace(/\r?\n+/g, " ") .replace(/\s+/g, " ") .trim(), maxLength ); } function buildToolSummary(name: string, args: Record, status: "completed" | "failed", error?: string) { const errSuffix = status === "failed" && error ? ` Error: ${toSingleLine(error, 140)}` : ""; if (name === "web_search") { const query = typeof args.query === "string" ? args.query.trim() : ""; if (status === "completed") { return query ? `Performed web search for '${toSingleLine(query, 100)}'.` : "Performed web search."; } return query ? `Web search for '${toSingleLine(query, 100)}' failed.${errSuffix}` : `Web search failed.${errSuffix}`; } if (name === "fetch_url") { const url = typeof args.url === "string" ? args.url.trim() : ""; if (status === "completed") { return url ? `Fetched URL ${toSingleLine(url, 140)}.` : "Fetched URL."; } return url ? `Fetching URL ${toSingleLine(url, 140)} failed.${errSuffix}` : `Fetching URL failed.${errSuffix}`; } if (status === "completed") { return `Ran tool '${name}'.`; } return `Tool '${name}' failed.${errSuffix}`; } function logToolEvent(event: ToolExecutionEvent, context?: ToolAwareCompletionParams["logContext"]) { const payload = { kind: "tool_call", ...context, ...event, }; const line = `[tool_call] ${JSON.stringify(payload)}`; if (event.status === "failed") console.error(line); else console.info(line); } function buildResultPreview(toolResult: ToolRunOutcome) { const serialized = JSON.stringify(toolResult); return serialized ? clipText(serialized, 400) : undefined; } export function buildToolLogMessageData(chatId: string, event: ToolExecutionEvent) { return { chatId, role: "tool" as const, content: event.summary, name: event.name, metadata: { kind: "tool_call", toolCallId: event.toolCallId, toolName: event.name, status: event.status, summary: event.summary, args: event.args, startedAt: event.startedAt, completedAt: event.completedAt, durationMs: event.durationMs, error: event.error ?? null, resultPreview: event.resultPreview ?? null, }, }; } function extractHtmlTitle(html: string) { const match = html.match(/]*>([\s\S]*?)<\/title>/i); if (!match?.[1]) return null; return compactWhitespace( match[1] .replace(/ /gi, " ") .replace(/&/gi, "&") .replace(/</gi, "<") .replace(/>/gi, ">") .replace(/"/gi, '"') .replace(/'/gi, "'") ); } function normalizeIncomingMessages(messages: ChatMessage[]) { const normalized = messages.map((m) => { if (m.role === "tool") { const name = m.name?.trim() || "tool"; return { role: "user", content: `Tool output (${name}):\n${m.content}`, }; } if (m.role === "assistant" || m.role === "system" || m.role === "user") { const out: any = { role: m.role, content: m.content }; if (m.name && (m.role === "assistant" || m.role === "user")) { out.name = m.name; } return out; } return { role: "user", content: m.content }; }); return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized]; } async function runExaWebSearchTool(args: WebSearchArgs): Promise { const exa = exaClient(); const response = await exa.search(args.query, { type: args.type ?? "auto", numResults: args.numResults ?? DEFAULT_WEB_RESULTS, includeDomains: args.includeDomains, excludeDomains: args.excludeDomains, moderation: true, userLocation: "US", contents: { summary: { query: args.query }, highlights: { query: args.query, maxCharacters: 320, numSentences: 2, highlightsPerUrl: 2, }, text: { maxCharacters: 1_000 }, }, } as any); const results = Array.isArray(response?.results) ? response.results : []; return { ok: true, searchEngine: "exa", query: args.query, requestId: response?.requestId ?? null, results: results.map((result: any, index: number) => ({ rank: index + 1, title: typeof result?.title === "string" ? result.title : null, url: typeof result?.url === "string" ? result.url : null, publishedDate: typeof result?.publishedDate === "string" ? result.publishedDate : null, author: typeof result?.author === "string" ? result.author : null, summary: typeof result?.summary === "string" ? clipText(result.summary, 1_400) : null, text: typeof result?.text === "string" ? clipText(result.text, 700) : null, highlights: Array.isArray(result?.highlights) ? result.highlights.filter((h: unknown) => typeof h === "string").slice(0, 3).map((h: string) => clipText(h, 280)) : [], })), }; } async function runSearxngWebSearchTool(args: WebSearchArgs): Promise { const response = await searchSearxng(args.query, { numResults: args.numResults ?? DEFAULT_WEB_RESULTS, includeDomains: args.includeDomains, excludeDomains: args.excludeDomains, }); return { ok: true, searchEngine: "searxng", query: args.query, requestId: response.requestId, results: response.results.map((result, index) => ({ rank: index + 1, title: result.title, url: result.url, publishedDate: result.publishedDate, author: null, summary: result.summary, text: result.text, highlights: result.summary ? [clipText(result.summary, 280)] : [], engines: result.engines, })), }; } async function runWebSearchTool(input: unknown): Promise { const args = WebSearchArgsSchema.parse(input); if (env.CHAT_WEB_SEARCH_ENGINE === "searxng") { return runSearxngWebSearchTool(args); } return runExaWebSearchTool(args); } function assertSafeFetchUrl(urlRaw: string) { const parsed = new URL(urlRaw); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw new Error("Only http:// and https:// URLs are supported."); } return parsed; } async function runFetchUrlTool(input: unknown): Promise { const args = FetchUrlArgsSchema.parse(input); const parsed = assertSafeFetchUrl(args.url); const maxCharacters = args.maxCharacters ?? DEFAULT_FETCH_MAX_CHARACTERS; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); let response: Response; try { response = await fetch(parsed.toString(), { redirect: "follow", signal: controller.signal, headers: { "User-Agent": "SybilBot/1.0 (+https://sybil.local)", Accept: "text/html, text/plain, application/json;q=0.9, */*;q=0.5", }, }); } finally { clearTimeout(timeout); } if (!response.ok) { throw new Error(`Fetch failed with status ${response.status}.`); } const contentType = (response.headers.get("content-type") ?? "").toLowerCase(); const body = await response.text(); const isHtml = contentType.includes("text/html") || /]/i.test(body); let extracted = body; if (isHtml) { extracted = htmlToText(body, { wordwrap: false, preserveNewlines: true, selectors: [ { selector: "img", format: "skip" }, { selector: "script", format: "skip" }, { selector: "style", format: "skip" }, { selector: "noscript", format: "skip" }, { selector: "a", options: { ignoreHref: true } }, ], }); } const normalized = compactWhitespace(extracted); const truncated = normalized.length > maxCharacters; const text = truncated ? `${normalized.slice(0, maxCharacters)}\n\n[truncated ${normalized.length - maxCharacters} characters]` : normalized; return { ok: true, url: response.url || parsed.toString(), status: response.status, contentType: contentType || null, title: isHtml ? extractHtmlTitle(body) : null, truncated, text, }; } async function executeTool(name: string, args: unknown): Promise { if (name === "web_search") return runWebSearchTool(args); if (name === "fetch_url") return runFetchUrlTool(args); return { ok: false, error: `Unknown tool: ${name}` }; } function parseToolArgs(raw: unknown) { if (typeof raw !== "string") return {}; const trimmed = raw.trim(); if (!trimmed) return {}; try { return JSON.parse(trimmed); } catch (err: any) { throw new Error(`Invalid JSON arguments: ${err?.message ?? String(err)}`); } } function mergeUsage(acc: Required, usage: any) { if (!usage) return false; acc.inputTokens += usage.prompt_tokens ?? 0; acc.outputTokens += usage.completion_tokens ?? 0; acc.totalTokens += usage.total_tokens ?? 0; return true; } type NormalizedToolCall = { id: string; name: string; arguments: string; }; function normalizeModelToolCalls(toolCalls: any[], round: number): NormalizedToolCall[] { return toolCalls.map((call: any, index: number) => ({ id: call?.id ?? `tool_call_${round}_${index}`, name: call?.function?.name ?? "unknown_tool", arguments: call?.function?.arguments ?? "{}", })); } async function executeToolCallAndBuildEvent( call: NormalizedToolCall, params: ToolAwareCompletionParams ): Promise<{ event: ToolExecutionEvent; toolResult: ToolRunOutcome }> { const startedAtMs = Date.now(); const startedAt = new Date(startedAtMs).toISOString(); let toolResult: ToolRunOutcome; let parsedArgs: Record = {}; try { parsedArgs = toRecord(parseToolArgs(call.arguments)); toolResult = await executeTool(call.name, parsedArgs); } catch (err: any) { toolResult = { ok: false, error: err?.message ?? String(err), }; } const status: "completed" | "failed" = toolResult.ok ? "completed" : "failed"; const error = status === "failed" ? typeof toolResult.error === "string" ? toolResult.error : "Tool execution failed." : undefined; const completedAtMs = Date.now(); const event: ToolExecutionEvent = { toolCallId: call.id, name: call.name, status, summary: buildToolSummary(call.name, parsedArgs, status, error), args: parsedArgs, startedAt, completedAt: new Date(completedAtMs).toISOString(), durationMs: completedAtMs - startedAtMs, error, resultPreview: buildResultPreview(toolResult), }; logToolEvent(event, params.logContext); if (params.onToolEvent) { await params.onToolEvent(event); } return { event, toolResult }; } export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise { const conversation: any[] = normalizeIncomingMessages(params.messages); const rawResponses: unknown[] = []; const toolEvents: ToolExecutionEvent[] = []; const usageAcc: Required = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; let sawUsage = false; let totalToolCalls = 0; for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) { const completion = await params.client.chat.completions.create({ model: params.model, messages: conversation, temperature: params.temperature, max_tokens: params.maxTokens, tools: CHAT_TOOLS, tool_choice: "auto", } as any); rawResponses.push(completion); sawUsage = mergeUsage(usageAcc, completion?.usage) || sawUsage; const message = completion?.choices?.[0]?.message; if (!message) { return { text: "", usage: sawUsage ? usageAcc : undefined, raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, missingMessage: true }, toolEvents, }; } const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; if (!toolCalls.length) { return { text: typeof message.content === "string" ? message.content : "", usage: sawUsage ? usageAcc : undefined, raw: { responses: rawResponses, toolCallsUsed: totalToolCalls }, toolEvents, }; } const normalizedToolCalls = normalizeModelToolCalls(toolCalls, round); totalToolCalls += normalizedToolCalls.length; const assistantToolCallMessage: any = { role: "assistant", tool_calls: normalizedToolCalls.map((call) => ({ id: call.id, type: "function", function: { name: call.name, arguments: call.arguments, }, })), }; if (typeof message.content === "string" && message.content.length) { assistantToolCallMessage.content = message.content; } conversation.push(assistantToolCallMessage); for (const call of normalizedToolCalls) { const { event, toolResult } = await executeToolCallAndBuildEvent(call, params); toolEvents.push(event); conversation.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(toolResult), }); } } return { text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.", usage: sawUsage ? usageAcc : undefined, raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true }, toolEvents, }; } export async function* runToolAwareOpenAIChatStream( params: ToolAwareCompletionParams ): AsyncGenerator { const conversation: any[] = normalizeIncomingMessages(params.messages); const rawResponses: unknown[] = []; const toolEvents: ToolExecutionEvent[] = []; const usageAcc: Required = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; let sawUsage = false; let totalToolCalls = 0; for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) { const stream = await params.client.chat.completions.create({ model: params.model, messages: conversation, temperature: params.temperature, max_tokens: params.maxTokens, tools: CHAT_TOOLS, tool_choice: "auto", stream: true, stream_options: { include_usage: true }, } as any); let roundText = ""; const roundToolCalls = new Map(); for await (const chunk of stream as any as AsyncIterable) { rawResponses.push(chunk); sawUsage = mergeUsage(usageAcc, chunk?.usage) || sawUsage; const choice = chunk?.choices?.[0]; const deltaText = choice?.delta?.content ?? ""; if (typeof deltaText === "string" && deltaText.length) { roundText += deltaText; if (roundToolCalls.size === 0) { yield { type: "delta", text: deltaText }; } } const deltaToolCalls = Array.isArray(choice?.delta?.tool_calls) ? choice.delta.tool_calls : []; for (const toolCall of deltaToolCalls) { const idx = typeof toolCall?.index === "number" ? toolCall.index : 0; const entry = roundToolCalls.get(idx) ?? { arguments: "" }; if (typeof toolCall?.id === "string" && toolCall.id.length) { entry.id = toolCall.id; } if (typeof toolCall?.function?.name === "string" && toolCall.function.name.length) { entry.name = toolCall.function.name; } if (typeof toolCall?.function?.arguments === "string" && toolCall.function.arguments.length) { entry.arguments += toolCall.function.arguments; } roundToolCalls.set(idx, entry); } } const normalizedToolCalls: NormalizedToolCall[] = [...roundToolCalls.entries()] .sort((a, b) => a[0] - b[0]) .map(([_, call], index) => ({ id: call.id ?? `tool_call_${round}_${index}`, name: call.name ?? "unknown_tool", arguments: call.arguments || "{}", })); if (!normalizedToolCalls.length) { yield { type: "done", result: { text: roundText, usage: sawUsage ? usageAcc : undefined, raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls }, toolEvents, }, }; return; } totalToolCalls += normalizedToolCalls.length; conversation.push({ role: "assistant", tool_calls: normalizedToolCalls.map((call) => ({ id: call.id, type: "function", function: { name: call.name, arguments: call.arguments, }, })), }); for (const call of normalizedToolCalls) { const { event, toolResult } = await executeToolCallAndBuildEvent(call, params); toolEvents.push(event); yield { type: "tool_call", event }; conversation.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(toolResult), }); } } yield { type: "done", result: { text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.", usage: sawUsage ? usageAcc : undefined, raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true }, toolEvents, }, }; }