/** * Tool Executor Node — executes research plan steps */ import { ResearchState, ResearchPlan, ResearchStep, appendReasoning } from "../state"; /** * Executes one step at a time (the step at `currentStepIndex`). * After each execution the graph loops back through the router; * when all steps are complete it advances to synthesizer. */ export async function toolExecutorNode( state: ResearchState ): Promise> { if (!state.plan) throw new Error("toolExecutorNode called with no plan"); const step = state.plan.steps[state.currentStepIndex]; if (!step) throw new Error(`No step at index ${state.currentStepIndex}`); const output = await dispatchTool(step.tool, step.input); const updatedSteps = state.plan.steps.map((s, i) => i === state.currentStepIndex ? { ...s, output, executedAt: new Date().toISOString() } : s ); const updatedPlan = ResearchPlan.parse({ ...state.plan, steps: updatedSteps, }); const reasoning = appendReasoning(state, { node: "tool_executor", summary: `Executed step ${state.currentStepIndex + 1}/${state.plan.steps.length}: [${step.tool}] "${step.input.slice(0, 80)}…"`, rawThought: output, }); return { plan: updatedPlan, currentStepIndex: state.currentStepIndex + 1, status: "executing", reasoning, updatedAt: new Date().toISOString(), }; } async function dispatchTool( tool: ResearchStep["tool"], input: string ): Promise { switch (tool) { case "web_search": return webSearch(input); default: { const _exhaustive: never = tool; throw new Error(`Unknown tool: ${_exhaustive}`); } } } // ─── Web Search ────────────────────────────────────────────── interface SearchResult { title: string; url: string; snippet: string; } const MAX_RESULTS = 5; const SEARCH_TIMEOUT_MS = 15_000; /** * Search the web using Google Custom Search (if configured) or * DuckDuckGo HTML as a zero-config fallback. */ async function webSearch(query: string): Promise { const apiKey = process.env.GOOGLE_CSE_API_KEY; const cx = process.env.GOOGLE_CSE_CX; let results: SearchResult[] = []; try { results = apiKey && cx ? await googleCseSearch(query, apiKey, cx) : await duckDuckGoSearch(query); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`[web_search] Network error for "${query}": ${msg}`); return `Search failed (network error): ${msg}`; } if (results.length === 0) { return `No results found for: "${query}"`; } return results .map((r, i) => `[${i + 1}] ${r.title}\n ${r.url}\n ${r.snippet}`) .join("\n\n"); } async function googleCseSearch( query: string, apiKey: string, cx: string ): Promise { const url = new URL("https://www.googleapis.com/customsearch/v1"); url.searchParams.set("key", apiKey); url.searchParams.set("cx", cx); url.searchParams.set("q", query); url.searchParams.set("num", String(MAX_RESULTS)); const res = await fetch(url.toString(), { signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS), }); if (!res.ok) { console.error(`Google CSE error ${res.status}, falling back to DuckDuckGo`); return duckDuckGoSearch(query); } const data = (await res.json()) as { items?: { title: string; link: string; snippet: string }[]; }; return (data.items ?? []).slice(0, MAX_RESULTS).map((item) => ({ title: item.title, url: item.link, snippet: item.snippet, })); } function decodeHtmlEntities(text: string): string { return text .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/'/g, "'") .replace(/ /g, " ") .replace(///g, "/"); } function stripHtmlTags(text: string): string { return text.replace(/<[^>]*>/g, ""); } async function duckDuckGoSearch(query: string): Promise { const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`; const res = await fetch(url, { method: "POST", headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Content-Type": "application/x-www-form-urlencoded", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.5", }, body: `q=${encodeURIComponent(query)}`, signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS), }); if (!res.ok) { console.error(`[web_search] DuckDuckGo returned HTTP ${res.status} for "${query}"`); return []; } const html = await res.text(); const results: SearchResult[] = []; const resultBlocks = html.split(/class="result\s/); if (resultBlocks.length <= 1) { console.warn( `[web_search] DuckDuckGo returned HTML (${html.length} chars) but no result blocks for "${query}". ` + `Possible captcha/block. First 300 chars: ${html.slice(0, 300).replace(/\n/g, " ")}` ); } for (const block of resultBlocks.slice(1)) { if (results.length >= MAX_RESULTS) break; const urlMatch = block.match(/href="([^"]+)"/); const titleMatch = block.match(/class="result__a"[^>]*>([\s\S]*?)<\/a>/); const snippetMatch = block.match( /class="result__snippet"[^>]*>([\s\S]*?)<\/(?:td|div|span)>/ ); const rawUrl = urlMatch?.[1] ?? ""; // DuckDuckGo wraps URLs in a redirect; extract the real URL const realUrlMatch = rawUrl.match(/uddg=([^&]+)/); const finalUrl = realUrlMatch ? decodeURIComponent(realUrlMatch[1]) : rawUrl; if (!finalUrl || finalUrl.startsWith("/") || !titleMatch) continue; results.push({ title: decodeHtmlEntities(stripHtmlTags(titleMatch[1]).trim()), url: finalUrl, snippet: snippetMatch ? decodeHtmlEntities(stripHtmlTags(snippetMatch[1]).trim()) : "", }); } return results; }