/** * WorthDoing.ai * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: src/lib/agent/executors.ts * Description: Tool executors — validate, enforce budgets, run real actions, persist state, emit events. */ import { and, eq, inArray } from "drizzle-orm"; import { z } from "zod"; import { db } from "@/lib/db/client"; import { investigations, searches, hypotheses, evidence, hypothesisEvidence, opportunities, opportunityEvidence, opportunityScores, competitors, opportunityCompetitors, sources, type BudgetUsed, } from "@/lib/db/schema"; import { searchWeb, crawlSite, extractStructured, FirecrawlError } from "@/lib/firecrawl/client"; import { scrapeWithCache, persistScrapedPage, sha256, canonicalizeUrl } from "@/lib/firecrawl/cache"; import { emitEvent } from "./events"; import { toolSchemas, type ToolName } from "./tools"; import { computeWorthScore } from "./scoring"; import { synthesizeOpportunityReport } from "./synthesis"; import { loadState } from "./state"; export type ToolOutcome = { content: string; isError: boolean; finished?: boolean }; const SCRAPE_EXCERPT_CHARS = 6000; const CRAWL_EXCERPT_CHARS = 1200; function wrapUntrusted(markdown: string, cap: number): string { const truncated = markdown.length > cap ? `${markdown.slice(0, cap)}\n…[truncated]` : markdown; return `\n${truncated}\n`; } async function bumpBudget(investigationId: string, patch: Partial): Promise { const [inv] = await db.select().from(investigations).where(eq(investigations.id, investigationId)); const used: BudgetUsed = { ...inv.budgetUsed }; for (const [k, v] of Object.entries(patch)) { (used as unknown as Record)[k] = ((used as unknown as Record)[k] ?? 0) + (v as number); } await db.update(investigations).set({ budgetUsed: used }).where(eq(investigations.id, investigationId)); await emitEvent(investigationId, "budget.updated", { used: used as unknown as Record }); return used; } /** Execute one validated tool call. Never throws — failures come back as structured error results. */ export async function executeTool( investigationId: string, toolName: string, rawInput: unknown, ): Promise { if (!(toolName in toolSchemas)) { return { content: `Unknown tool "${toolName}".`, isError: true }; } const name = toolName as ToolName; const parsed = toolSchemas[name].safeParse(rawInput); if (!parsed.success) { const issues = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "); return { content: `Invalid parameters for ${name}: ${issues}`, isError: true }; } try { switch (name) { case "search_web": return await execSearch(investigationId, parsed.data as z.infer); case "scrape_page": return await execScrape(investigationId, parsed.data as z.infer); case "crawl_site": return await execCrawl(investigationId, parsed.data as z.infer); case "extract_structured": return await execExtract(investigationId, parsed.data as z.infer); case "create_hypothesis": return await execCreateHypothesis(investigationId, parsed.data as z.infer); case "update_hypothesis": return await execUpdateHypothesis(investigationId, parsed.data as z.infer); case "reject_hypothesis": return await execRejectHypothesis(investigationId, parsed.data as z.infer); case "save_evidence": return await execSaveEvidence(investigationId, parsed.data as z.infer); case "create_opportunity": return await execCreateOpportunity(investigationId, parsed.data as z.infer); case "synthesize": return await execSynthesize(investigationId, parsed.data as z.infer); case "finish_investigation": return await execFinish(investigationId, parsed.data as z.infer); } } catch (err) { const message = err instanceof FirecrawlError ? `${err.message} (endpoint ${err.endpoint}, status ${err.status ?? "network"})` : err instanceof Error ? err.message : String(err); await emitEvent(investigationId, "agent.error", { tool: name, message }); return { content: `Tool ${name} failed: ${message}`, isError: true }; } } /* ------------------------------- web tools ------------------------------- */ async function checkBudget( investigationId: string, kind: "searches" | "scrapes" | "crawls", amount = 1, ): Promise { const [inv] = await db.select().from(investigations).where(eq(investigations.id, investigationId)); const limits = { searches: inv.budget.maxSearches, scrapes: inv.budget.maxScrapes, crawls: inv.budget.maxCrawls }; const used = inv.budgetUsed[kind]; if (used + amount > limits[kind]) { return `Budget exhausted: ${kind} (${used}/${limits[kind]} used). Work with the evidence you already have — synthesize and finish.`; } return null; } async function execSearch( investigationId: string, input: z.infer, ): Promise { const blocked = await checkBudget(investigationId, "searches"); if (blocked) return { content: blocked, isError: true }; await emitEvent(investigationId, "search.started", { query: input.query, intent: input.intent }); const results = await searchWeb(input.query, input.limit ?? 8); await db.insert(searches).values({ investigationId, query: input.query, intent: input.intent, resultCount: results.length, results, }); await bumpBudget(investigationId, { searches: 1 }); await emitEvent(investigationId, "search.completed", { query: input.query, intent: input.intent, resultCount: results.length, results: results.slice(0, 8), }); if (results.length === 0) { return { content: `No results for "${input.query}". Try different phrasing.`, isError: false }; } const lines = results.map((r, i) => `${i + 1}. ${r.url}\n ${r.title}\n ${r.description}`); return { content: `Results for "${input.query}":\n${lines.join("\n")}`, isError: false }; } async function execScrape( investigationId: string, input: z.infer, ): Promise { const blocked = await checkBudget(investigationId, "scrapes"); if (blocked) return { content: blocked, isError: true }; await emitEvent(investigationId, "scrape.started", { url: input.url, reason: input.reason }); try { const page = await scrapeWithCache(input.url, investigationId); if (!page.fromCache) await bumpBudget(investigationId, { scrapes: 1 }); await emitEvent(investigationId, "scrape.completed", { url: page.canonicalUrl, title: page.title, sourceId: page.sourceId, wordCount: page.wordCount, fromCache: page.fromCache, }); return { content: [ `source_id: ${page.sourceId}`, `title: ${page.title ?? "(untitled)"}`, `url: ${page.canonicalUrl}`, `words: ${page.wordCount}${page.fromCache ? " (served from cache)" : ""}`, wrapUntrusted(page.markdown, SCRAPE_EXCERPT_CHARS), ].join("\n"), isError: false, }; } catch (err) { await emitEvent(investigationId, "scrape.failed", { url: input.url, message: err instanceof Error ? err.message : String(err), }); throw err; } } async function execCrawl( investigationId: string, input: z.infer, ): Promise { const blocked = await checkBudget(investigationId, "crawls"); if (blocked) return { content: blocked, isError: true }; await emitEvent(investigationId, "crawl.started", { url: input.url, limit: input.limit, reason: input.reason }); try { const pages = await crawlSite(input.url, input.limit); await bumpBudget(investigationId, { crawls: 1 }); const persisted = []; for (const p of pages) { persisted.push(await persistScrapedPage(p.sourceUrl, p, investigationId)); } await emitEvent(investigationId, "crawl.completed", { url: input.url, pageCount: persisted.length, pages: persisted.map((p) => ({ sourceId: p.sourceId, url: p.canonicalUrl, title: p.title })), }); const blocks = persisted.map( (p) => `source_id: ${p.sourceId} | ${p.title ?? "(untitled)"} | ${p.canonicalUrl}\n${wrapUntrusted(p.markdown, CRAWL_EXCERPT_CHARS)}`, ); return { content: `Crawled ${persisted.length} pages from ${input.url}:\n\n${blocks.join("\n\n")}\n\nUse scrape_page on any of these URLs if you need the full content of a specific page.`, isError: false, }; } catch (err) { await emitEvent(investigationId, "crawl.failed", { url: input.url, message: err instanceof Error ? err.message : String(err), }); throw err; } } async function execExtract( investigationId: string, input: z.infer, ): Promise { const blocked = await checkBudget(investigationId, "scrapes", input.urls.length); if (blocked) return { content: blocked, isError: true }; await emitEvent(investigationId, "extract.started", { urls: input.urls, prompt: input.prompt }); try { const data = await extractStructured(input.urls, input.prompt, input.schema); await bumpBudget(investigationId, { scrapes: input.urls.length }); await emitEvent(investigationId, "extract.completed", { urls: input.urls }); const json = JSON.stringify(data, null, 2); return { content: `Extraction result:\n\n${json.slice(0, 8000)}\n\nNote: to cite this as evidence, scrape the underlying page and save a quote with its source_id.`, isError: false, }; } catch (err) { await emitEvent(investigationId, "extract.failed", { urls: input.urls, message: err instanceof Error ? err.message : String(err), }); throw err; } } /* ----------------------------- hypothesis tools --------------------------- */ async function execCreateHypothesis( investigationId: string, input: z.infer, ): Promise { const [h] = await db .insert(hypotheses) .values({ investigationId, title: input.title, statement: input.statement, rationale: input.rationale, confidence: input.confidence, parentHypothesisId: input.parent_hypothesis_id ?? null, status: "proposed", }) .returning(); await emitEvent(investigationId, "hypothesis.created", { id: h.id, title: h.title, statement: h.statement, confidence: h.confidence, parentHypothesisId: h.parentHypothesisId, }); return { content: `Hypothesis created: [${h.id}] "${h.title}" at confidence ${input.confidence}.`, isError: false }; } async function execUpdateHypothesis( investigationId: string, input: z.infer, ): Promise { const [h] = await db .select() .from(hypotheses) .where(and(eq(hypotheses.id, input.hypothesis_id), eq(hypotheses.investigationId, investigationId))); if (!h) return { content: `Hypothesis ${input.hypothesis_id} not found in this investigation.`, isError: true }; if (h.status === "rejected") return { content: `Hypothesis ${h.id} is already rejected.`, isError: true }; const delta = input.confidence - h.confidence; const status = input.status ?? (delta <= -0.1 ? "weakened" : delta >= 0.1 ? "supported" : h.status === "proposed" ? "investigating" : h.status); await db .update(hypotheses) .set({ confidence: input.confidence, status, rationale: input.rationale, adversarialChecked: input.adversarial_checked ?? h.adversarialChecked, updatedAt: new Date(), }) .where(eq(hypotheses.id, h.id)); await emitEvent(investigationId, "hypothesis.updated", { id: h.id, title: h.title, status, confidence: input.confidence, previousConfidence: h.confidence, confidenceDelta: Math.round(delta * 1000) / 1000, rationale: input.rationale, adversarialChecked: input.adversarial_checked ?? h.adversarialChecked, }); return { content: `Hypothesis [${h.id}] updated: ${h.confidence.toFixed(2)} → ${input.confidence.toFixed(2)} (${status}).`, isError: false, }; } async function execRejectHypothesis( investigationId: string, input: z.infer, ): Promise { const [h] = await db .select() .from(hypotheses) .where(and(eq(hypotheses.id, input.hypothesis_id), eq(hypotheses.investigationId, investigationId))); if (!h) return { content: `Hypothesis ${input.hypothesis_id} not found in this investigation.`, isError: true }; await db .update(hypotheses) .set({ status: "rejected", rationale: input.reason, updatedAt: new Date() }) .where(eq(hypotheses.id, h.id)); await emitEvent(investigationId, "hypothesis.rejected", { id: h.id, title: h.title, reason: input.reason, previousConfidence: h.confidence, }); return { content: `Hypothesis [${h.id}] "${h.title}" rejected. This is useful progress.`, isError: false }; } /* ------------------------------ evidence tool ----------------------------- */ async function execSaveEvidence( investigationId: string, input: z.infer, ): Promise { const [src] = await db.select().from(sources).where(eq(sources.id, input.source_id)); if (!src) return { content: `source_id ${input.source_id} does not exist. Use the id returned by scrape_page.`, isError: true }; const fingerprint = sha256(`${input.source_id}:${input.quote.toLowerCase().replace(/\s+/g, " ").trim()}`); const existing = await db .select({ id: evidence.id }) .from(evidence) .where(and(eq(evidence.investigationId, investigationId), eq(evidence.fingerprint, fingerprint))); if (existing.length > 0) { return { content: `Duplicate evidence — this quote from this source is already saved as [${existing[0].id}].`, isError: true }; } const [e] = await db .insert(evidence) .values({ investigationId, sourceId: input.source_id, kind: input.kind, quote: input.quote, summary: input.summary, strength: input.strength, fingerprint, }) .returning(); if (input.links?.length) { const hypIds = input.links.map((l) => l.hypothesis_id); const valid = await db .select({ id: hypotheses.id }) .from(hypotheses) .where(and(eq(hypotheses.investigationId, investigationId), inArray(hypotheses.id, hypIds))); const validSet = new Set(valid.map((v) => v.id)); const rows = input.links .filter((l) => validSet.has(l.hypothesis_id)) .map((l) => ({ hypothesisId: l.hypothesis_id, evidenceId: e.id, relation: l.relation, weight: l.weight })); if (rows.length) await db.insert(hypothesisEvidence).values(rows).onConflictDoNothing(); } await emitEvent(investigationId, "evidence.saved", { id: e.id, kind: e.kind, summary: e.summary, quote: e.quote.slice(0, 280), strength: e.strength, sourceId: src.id, sourceUrl: src.canonicalUrl, sourceTitle: src.title, links: input.links ?? [], }); return { content: `Evidence saved: [${e.id}] (${input.kind}).`, isError: false }; } /* ---------------------------- opportunity tools --------------------------- */ async function execCreateOpportunity( investigationId: string, input: z.infer, ): Promise { // Validate every referenced evidence id belongs to this investigation. const allEvidenceIds = [ ...new Set([...input.evidence.map((e) => e.evidence_id), ...input.scores.flatMap((s) => s.evidence_ids)]), ]; const found = await db .select({ id: evidence.id }) .from(evidence) .where(and(eq(evidence.investigationId, investigationId), inArray(evidence.id, allEvidenceIds))); const foundSet = new Set(found.map((f) => f.id)); const missing = allEvidenceIds.filter((id) => !foundSet.has(id)); if (missing.length) { return { content: `These evidence ids do not exist in this investigation: ${missing.join(", ")}. Scores must cite real saved evidence.`, isError: true }; } if (input.hypothesis_id) { const [h] = await db .select() .from(hypotheses) .where(and(eq(hypotheses.id, input.hypothesis_id), eq(hypotheses.investigationId, investigationId))); if (!h) return { content: `hypothesis_id ${input.hypothesis_id} not found.`, isError: true }; if (!h.adversarialChecked) { return { content: `Hypothesis [${h.id}] has not survived the skeptic phase yet. Run falsify searches against it, update it with adversarial_checked=true (or reject it), then create the opportunity.`, isError: true, }; } } const { worthScore, evidenceConfidence } = computeWorthScore( input.scores.map((s) => ({ dimension: s.dimension, score: s.score, confidence: s.confidence })), ); const [opp] = await db .insert(opportunities) .values({ investigationId, hypothesisId: input.hypothesis_id ?? null, title: input.title, summary: input.summary, problem: input.problem, whyNow: input.why_now, risks: input.risks, skepticCase: input.skeptic_case, status: "candidate", worthScore, evidenceConfidence, }) .returning(); await db.insert(opportunityEvidence).values( input.evidence.map((e) => ({ opportunityId: opp.id, evidenceId: e.evidence_id, role: e.role })), ).onConflictDoNothing(); await db.insert(opportunityScores).values( input.scores.map((s) => ({ opportunityId: opp.id, dimension: s.dimension, score: s.score, confidence: s.confidence, reasoning: s.reasoning, evidenceIds: s.evidence_ids, })), ); if (input.competitors?.length) { for (const c of input.competitors) { const [comp] = await db .insert(competitors) .values({ name: c.name, url: c.url ?? null, description: c.description ?? null }) .onConflictDoUpdate({ target: competitors.name, set: { url: c.url ?? undefined, description: c.description ?? undefined } }) .returning({ id: competitors.id }); await db .insert(opportunityCompetitors) .values({ opportunityId: opp.id, competitorId: comp.id, note: c.note ?? null }) .onConflictDoNothing(); } } await emitEvent(investigationId, "opportunity.created", { id: opp.id, title: opp.title, summary: opp.summary, worthScore, evidenceConfidence, scores: input.scores.map((s) => ({ dimension: s.dimension, score: s.score, confidence: s.confidence })), }); return { content: `Opportunity created: [${opp.id}] "${opp.title}" — Worth Score ${worthScore} at ${Math.round(evidenceConfidence * 100)}% evidence confidence. Now call synthesize to write its report.`, isError: false, }; } async function execSynthesize( investigationId: string, input: z.infer, ): Promise { const [opp] = await db .select() .from(opportunities) .where(and(eq(opportunities.id, input.opportunity_id), eq(opportunities.investigationId, investigationId))); if (!opp) return { content: `Opportunity ${input.opportunity_id} not found.`, isError: true }; const report = await synthesizeOpportunityReport(investigationId, opp.id); return { content: `Report synthesized for [${opp.id}] (${report.length} chars).`, isError: false }; } async function execFinish( investigationId: string, input: z.infer, ): Promise { const state = await loadState(investigationId); const inv = state.investigation; const stepsLeft = inv.budget.maxAgentSteps - inv.budgetUsed.agentSteps; const searchesLeft = inv.budget.maxSearches - inv.budgetUsed.searches; // Falsification gate: high-confidence live hypotheses must survive the skeptic first. const unchecked = state.hypotheses.filter( (h) => h.status !== "rejected" && h.confidence >= 0.65 && !h.adversarialChecked, ); if (unchecked.length > 0 && stepsLeft > 2 && searchesLeft > 0) { return { content: `Cannot finish yet — these high-confidence hypotheses have not been adversarially checked: ${unchecked .map((h) => `[${h.id}] ${h.title}`) .join("; ")}. Run falsify searches against them first.`, isError: true, }; } // Every created opportunity needs its report before finishing (if budget allows). const unsynthesized = state.opportunities.filter((o) => !o.reportMd); if (unsynthesized.length > 0 && stepsLeft > 1) { return { content: `Cannot finish yet — synthesize reports for: ${unsynthesized.map((o) => `[${o.id}] ${o.title}`).join("; ")}.`, isError: true, }; } await db .update(investigations) .set({ status: "completed", phase: "done", stopReason: "agent_finished", conclusion: input.conclusion, outcome: input.outcome, completedAt: new Date(), }) .where(eq(investigations.id, investigationId)); await emitEvent(investigationId, "investigation.completed", { conclusion: input.conclusion, outcome: input.outcome, stats: { hypotheses: state.hypotheses.length, rejected: state.hypotheses.filter((h) => h.status === "rejected").length, evidence: state.evidence.length, searches: state.searches.length, opportunities: state.opportunities.length, }, }); return { content: "Investigation completed.", isError: false, finished: true }; } export { canonicalizeUrl };