SPB Git

spb/worthdoing Public

Autonomous investigation agent that discovers, challenges, and ranks things genuinely worth doing — Claude + Firecrawl, Next.js 16, PostgreSQL

TypeScript 91.5% SQL 5.8% CSS 2.2%
21.7 KB · 573 lines typescript
Raw Blame History
1/**2 * WorthDoing.ai3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: src/lib/agent/executors.ts6 * Description: Tool executors — validate, enforce budgets, run real actions, persist state, emit events.7 */8import { and, eq, inArray } from "drizzle-orm";9import { z } from "zod";10import { db } from "@/lib/db/client";11import {12  investigations,13  searches,14  hypotheses,15  evidence,16  hypothesisEvidence,17  opportunities,18  opportunityEvidence,19  opportunityScores,20  competitors,21  opportunityCompetitors,22  sources,23  type BudgetUsed,24} from "@/lib/db/schema";25import { searchWeb, crawlSite, extractStructured, FirecrawlError } from "@/lib/firecrawl/client";26import { scrapeWithCache, persistScrapedPage, sha256, canonicalizeUrl } from "@/lib/firecrawl/cache";27import { emitEvent } from "./events";28import { toolSchemas, type ToolName } from "./tools";29import { computeWorthScore } from "./scoring";30import { synthesizeOpportunityReport } from "./synthesis";31import { loadState } from "./state";3233export type ToolOutcome = { content: string; isError: boolean; finished?: boolean };3435const SCRAPE_EXCERPT_CHARS = 6000;36const CRAWL_EXCERPT_CHARS = 1200;3738function wrapUntrusted(markdown: string, cap: number): string {39  const truncated = markdown.length > cap ? `${markdown.slice(0, cap)}\n…[truncated]` : markdown;40  return `<untrusted_source>\n${truncated}\n</untrusted_source>`;41}4243async function bumpBudget(investigationId: string, patch: Partial<BudgetUsed>): Promise<BudgetUsed> {44  const [inv] = await db.select().from(investigations).where(eq(investigations.id, investigationId));45  const used: BudgetUsed = { ...inv.budgetUsed };46  for (const [k, v] of Object.entries(patch)) {47    (used as unknown as Record<string, number>)[k] =48      ((used as unknown as Record<string, number>)[k] ?? 0) + (v as number);49  }50  await db.update(investigations).set({ budgetUsed: used }).where(eq(investigations.id, investigationId));51  await emitEvent(investigationId, "budget.updated", { used: used as unknown as Record<string, unknown> });52  return used;53}5455/** Execute one validated tool call. Never throws — failures come back as structured error results. */56export async function executeTool(57  investigationId: string,58  toolName: string,59  rawInput: unknown,60): Promise<ToolOutcome> {61  if (!(toolName in toolSchemas)) {62    return { content: `Unknown tool "${toolName}".`, isError: true };63  }64  const name = toolName as ToolName;65  const parsed = toolSchemas[name].safeParse(rawInput);66  if (!parsed.success) {67    const issues = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");68    return { content: `Invalid parameters for ${name}: ${issues}`, isError: true };69  }7071  try {72    switch (name) {73      case "search_web":74        return await execSearch(investigationId, parsed.data as z.infer<typeof toolSchemas.search_web>);75      case "scrape_page":76        return await execScrape(investigationId, parsed.data as z.infer<typeof toolSchemas.scrape_page>);77      case "crawl_site":78        return await execCrawl(investigationId, parsed.data as z.infer<typeof toolSchemas.crawl_site>);79      case "extract_structured":80        return await execExtract(investigationId, parsed.data as z.infer<typeof toolSchemas.extract_structured>);81      case "create_hypothesis":82        return await execCreateHypothesis(investigationId, parsed.data as z.infer<typeof toolSchemas.create_hypothesis>);83      case "update_hypothesis":84        return await execUpdateHypothesis(investigationId, parsed.data as z.infer<typeof toolSchemas.update_hypothesis>);85      case "reject_hypothesis":86        return await execRejectHypothesis(investigationId, parsed.data as z.infer<typeof toolSchemas.reject_hypothesis>);87      case "save_evidence":88        return await execSaveEvidence(investigationId, parsed.data as z.infer<typeof toolSchemas.save_evidence>);89      case "create_opportunity":90        return await execCreateOpportunity(investigationId, parsed.data as z.infer<typeof toolSchemas.create_opportunity>);91      case "synthesize":92        return await execSynthesize(investigationId, parsed.data as z.infer<typeof toolSchemas.synthesize>);93      case "finish_investigation":94        return await execFinish(investigationId, parsed.data as z.infer<typeof toolSchemas.finish_investigation>);95    }96  } catch (err) {97    const message =98      err instanceof FirecrawlError99        ? `${err.message} (endpoint ${err.endpoint}, status ${err.status ?? "network"})`100        : err instanceof Error101          ? err.message102          : String(err);103    await emitEvent(investigationId, "agent.error", { tool: name, message });104    return { content: `Tool ${name} failed: ${message}`, isError: true };105  }106}107108/* ------------------------------- web tools ------------------------------- */109110async function checkBudget(111  investigationId: string,112  kind: "searches" | "scrapes" | "crawls",113  amount = 1,114): Promise<string | null> {115  const [inv] = await db.select().from(investigations).where(eq(investigations.id, investigationId));116  const limits = { searches: inv.budget.maxSearches, scrapes: inv.budget.maxScrapes, crawls: inv.budget.maxCrawls };117  const used = inv.budgetUsed[kind];118  if (used + amount > limits[kind]) {119    return `Budget exhausted: ${kind} (${used}/${limits[kind]} used). Work with the evidence you already have — synthesize and finish.`;120  }121  return null;122}123124async function execSearch(125  investigationId: string,126  input: z.infer<typeof toolSchemas.search_web>,127): Promise<ToolOutcome> {128  const blocked = await checkBudget(investigationId, "searches");129  if (blocked) return { content: blocked, isError: true };130131  await emitEvent(investigationId, "search.started", { query: input.query, intent: input.intent });132  const results = await searchWeb(input.query, input.limit ?? 8);133  await db.insert(searches).values({134    investigationId,135    query: input.query,136    intent: input.intent,137    resultCount: results.length,138    results,139  });140  await bumpBudget(investigationId, { searches: 1 });141  await emitEvent(investigationId, "search.completed", {142    query: input.query,143    intent: input.intent,144    resultCount: results.length,145    results: results.slice(0, 8),146  });147148  if (results.length === 0) {149    return { content: `No results for "${input.query}". Try different phrasing.`, isError: false };150  }151  const lines = results.map((r, i) => `${i + 1}. ${r.url}\n   ${r.title}\n   ${r.description}`);152  return { content: `Results for "${input.query}":\n${lines.join("\n")}`, isError: false };153}154155async function execScrape(156  investigationId: string,157  input: z.infer<typeof toolSchemas.scrape_page>,158): Promise<ToolOutcome> {159  const blocked = await checkBudget(investigationId, "scrapes");160  if (blocked) return { content: blocked, isError: true };161162  await emitEvent(investigationId, "scrape.started", { url: input.url, reason: input.reason });163  try {164    const page = await scrapeWithCache(input.url, investigationId);165    if (!page.fromCache) await bumpBudget(investigationId, { scrapes: 1 });166    await emitEvent(investigationId, "scrape.completed", {167      url: page.canonicalUrl,168      title: page.title,169      sourceId: page.sourceId,170      wordCount: page.wordCount,171      fromCache: page.fromCache,172    });173    return {174      content: [175        `source_id: ${page.sourceId}`,176        `title: ${page.title ?? "(untitled)"}`,177        `url: ${page.canonicalUrl}`,178        `words: ${page.wordCount}${page.fromCache ? " (served from cache)" : ""}`,179        wrapUntrusted(page.markdown, SCRAPE_EXCERPT_CHARS),180      ].join("\n"),181      isError: false,182    };183  } catch (err) {184    await emitEvent(investigationId, "scrape.failed", {185      url: input.url,186      message: err instanceof Error ? err.message : String(err),187    });188    throw err;189  }190}191192async function execCrawl(193  investigationId: string,194  input: z.infer<typeof toolSchemas.crawl_site>,195): Promise<ToolOutcome> {196  const blocked = await checkBudget(investigationId, "crawls");197  if (blocked) return { content: blocked, isError: true };198199  await emitEvent(investigationId, "crawl.started", { url: input.url, limit: input.limit, reason: input.reason });200  try {201    const pages = await crawlSite(input.url, input.limit);202    await bumpBudget(investigationId, { crawls: 1 });203    const persisted = [];204    for (const p of pages) {205      persisted.push(await persistScrapedPage(p.sourceUrl, p, investigationId));206    }207    await emitEvent(investigationId, "crawl.completed", {208      url: input.url,209      pageCount: persisted.length,210      pages: persisted.map((p) => ({ sourceId: p.sourceId, url: p.canonicalUrl, title: p.title })),211    });212    const blocks = persisted.map(213      (p) =>214        `source_id: ${p.sourceId} | ${p.title ?? "(untitled)"} | ${p.canonicalUrl}\n${wrapUntrusted(p.markdown, CRAWL_EXCERPT_CHARS)}`,215    );216    return {217      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.`,218      isError: false,219    };220  } catch (err) {221    await emitEvent(investigationId, "crawl.failed", {222      url: input.url,223      message: err instanceof Error ? err.message : String(err),224    });225    throw err;226  }227}228229async function execExtract(230  investigationId: string,231  input: z.infer<typeof toolSchemas.extract_structured>,232): Promise<ToolOutcome> {233  const blocked = await checkBudget(investigationId, "scrapes", input.urls.length);234  if (blocked) return { content: blocked, isError: true };235236  await emitEvent(investigationId, "extract.started", { urls: input.urls, prompt: input.prompt });237  try {238    const data = await extractStructured(input.urls, input.prompt, input.schema);239    await bumpBudget(investigationId, { scrapes: input.urls.length });240    await emitEvent(investigationId, "extract.completed", { urls: input.urls });241    const json = JSON.stringify(data, null, 2);242    return {243      content: `Extraction result:\n<untrusted_source>\n${json.slice(0, 8000)}\n</untrusted_source>\nNote: to cite this as evidence, scrape the underlying page and save a quote with its source_id.`,244      isError: false,245    };246  } catch (err) {247    await emitEvent(investigationId, "extract.failed", {248      urls: input.urls,249      message: err instanceof Error ? err.message : String(err),250    });251    throw err;252  }253}254255/* ----------------------------- hypothesis tools --------------------------- */256257async function execCreateHypothesis(258  investigationId: string,259  input: z.infer<typeof toolSchemas.create_hypothesis>,260): Promise<ToolOutcome> {261  const [h] = await db262    .insert(hypotheses)263    .values({264      investigationId,265      title: input.title,266      statement: input.statement,267      rationale: input.rationale,268      confidence: input.confidence,269      parentHypothesisId: input.parent_hypothesis_id ?? null,270      status: "proposed",271    })272    .returning();273  await emitEvent(investigationId, "hypothesis.created", {274    id: h.id,275    title: h.title,276    statement: h.statement,277    confidence: h.confidence,278    parentHypothesisId: h.parentHypothesisId,279  });280  return { content: `Hypothesis created: [${h.id}] "${h.title}" at confidence ${input.confidence}.`, isError: false };281}282283async function execUpdateHypothesis(284  investigationId: string,285  input: z.infer<typeof toolSchemas.update_hypothesis>,286): Promise<ToolOutcome> {287  const [h] = await db288    .select()289    .from(hypotheses)290    .where(and(eq(hypotheses.id, input.hypothesis_id), eq(hypotheses.investigationId, investigationId)));291  if (!h) return { content: `Hypothesis ${input.hypothesis_id} not found in this investigation.`, isError: true };292  if (h.status === "rejected") return { content: `Hypothesis ${h.id} is already rejected.`, isError: true };293294  const delta = input.confidence - h.confidence;295  const status =296    input.status ?? (delta <= -0.1 ? "weakened" : delta >= 0.1 ? "supported" : h.status === "proposed" ? "investigating" : h.status);297  await db298    .update(hypotheses)299    .set({300      confidence: input.confidence,301      status,302      rationale: input.rationale,303      adversarialChecked: input.adversarial_checked ?? h.adversarialChecked,304      updatedAt: new Date(),305    })306    .where(eq(hypotheses.id, h.id));307  await emitEvent(investigationId, "hypothesis.updated", {308    id: h.id,309    title: h.title,310    status,311    confidence: input.confidence,312    previousConfidence: h.confidence,313    confidenceDelta: Math.round(delta * 1000) / 1000,314    rationale: input.rationale,315    adversarialChecked: input.adversarial_checked ?? h.adversarialChecked,316  });317  return {318    content: `Hypothesis [${h.id}] updated: ${h.confidence.toFixed(2)} → ${input.confidence.toFixed(2)} (${status}).`,319    isError: false,320  };321}322323async function execRejectHypothesis(324  investigationId: string,325  input: z.infer<typeof toolSchemas.reject_hypothesis>,326): Promise<ToolOutcome> {327  const [h] = await db328    .select()329    .from(hypotheses)330    .where(and(eq(hypotheses.id, input.hypothesis_id), eq(hypotheses.investigationId, investigationId)));331  if (!h) return { content: `Hypothesis ${input.hypothesis_id} not found in this investigation.`, isError: true };332333  await db334    .update(hypotheses)335    .set({ status: "rejected", rationale: input.reason, updatedAt: new Date() })336    .where(eq(hypotheses.id, h.id));337  await emitEvent(investigationId, "hypothesis.rejected", {338    id: h.id,339    title: h.title,340    reason: input.reason,341    previousConfidence: h.confidence,342  });343  return { content: `Hypothesis [${h.id}] "${h.title}" rejected. This is useful progress.`, isError: false };344}345346/* ------------------------------ evidence tool ----------------------------- */347348async function execSaveEvidence(349  investigationId: string,350  input: z.infer<typeof toolSchemas.save_evidence>,351): Promise<ToolOutcome> {352  const [src] = await db.select().from(sources).where(eq(sources.id, input.source_id));353  if (!src) return { content: `source_id ${input.source_id} does not exist. Use the id returned by scrape_page.`, isError: true };354355  const fingerprint = sha256(`${input.source_id}:${input.quote.toLowerCase().replace(/\s+/g, " ").trim()}`);356  const existing = await db357    .select({ id: evidence.id })358    .from(evidence)359    .where(and(eq(evidence.investigationId, investigationId), eq(evidence.fingerprint, fingerprint)));360  if (existing.length > 0) {361    return { content: `Duplicate evidence — this quote from this source is already saved as [${existing[0].id}].`, isError: true };362  }363364  const [e] = await db365    .insert(evidence)366    .values({367      investigationId,368      sourceId: input.source_id,369      kind: input.kind,370      quote: input.quote,371      summary: input.summary,372      strength: input.strength,373      fingerprint,374    })375    .returning();376377  if (input.links?.length) {378    const hypIds = input.links.map((l) => l.hypothesis_id);379    const valid = await db380      .select({ id: hypotheses.id })381      .from(hypotheses)382      .where(and(eq(hypotheses.investigationId, investigationId), inArray(hypotheses.id, hypIds)));383    const validSet = new Set(valid.map((v) => v.id));384    const rows = input.links385      .filter((l) => validSet.has(l.hypothesis_id))386      .map((l) => ({ hypothesisId: l.hypothesis_id, evidenceId: e.id, relation: l.relation, weight: l.weight }));387    if (rows.length) await db.insert(hypothesisEvidence).values(rows).onConflictDoNothing();388  }389390  await emitEvent(investigationId, "evidence.saved", {391    id: e.id,392    kind: e.kind,393    summary: e.summary,394    quote: e.quote.slice(0, 280),395    strength: e.strength,396    sourceId: src.id,397    sourceUrl: src.canonicalUrl,398    sourceTitle: src.title,399    links: input.links ?? [],400  });401  return { content: `Evidence saved: [${e.id}] (${input.kind}).`, isError: false };402}403404/* ---------------------------- opportunity tools --------------------------- */405406async function execCreateOpportunity(407  investigationId: string,408  input: z.infer<typeof toolSchemas.create_opportunity>,409): Promise<ToolOutcome> {410  // Validate every referenced evidence id belongs to this investigation.411  const allEvidenceIds = [412    ...new Set([...input.evidence.map((e) => e.evidence_id), ...input.scores.flatMap((s) => s.evidence_ids)]),413  ];414  const found = await db415    .select({ id: evidence.id })416    .from(evidence)417    .where(and(eq(evidence.investigationId, investigationId), inArray(evidence.id, allEvidenceIds)));418  const foundSet = new Set(found.map((f) => f.id));419  const missing = allEvidenceIds.filter((id) => !foundSet.has(id));420  if (missing.length) {421    return { content: `These evidence ids do not exist in this investigation: ${missing.join(", ")}. Scores must cite real saved evidence.`, isError: true };422  }423424  if (input.hypothesis_id) {425    const [h] = await db426      .select()427      .from(hypotheses)428      .where(and(eq(hypotheses.id, input.hypothesis_id), eq(hypotheses.investigationId, investigationId)));429    if (!h) return { content: `hypothesis_id ${input.hypothesis_id} not found.`, isError: true };430    if (!h.adversarialChecked) {431      return {432        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.`,433        isError: true,434      };435    }436  }437438  const { worthScore, evidenceConfidence } = computeWorthScore(439    input.scores.map((s) => ({ dimension: s.dimension, score: s.score, confidence: s.confidence })),440  );441442  const [opp] = await db443    .insert(opportunities)444    .values({445      investigationId,446      hypothesisId: input.hypothesis_id ?? null,447      title: input.title,448      summary: input.summary,449      problem: input.problem,450      whyNow: input.why_now,451      risks: input.risks,452      skepticCase: input.skeptic_case,453      status: "candidate",454      worthScore,455      evidenceConfidence,456    })457    .returning();458459  await db.insert(opportunityEvidence).values(460    input.evidence.map((e) => ({ opportunityId: opp.id, evidenceId: e.evidence_id, role: e.role })),461  ).onConflictDoNothing();462463  await db.insert(opportunityScores).values(464    input.scores.map((s) => ({465      opportunityId: opp.id,466      dimension: s.dimension,467      score: s.score,468      confidence: s.confidence,469      reasoning: s.reasoning,470      evidenceIds: s.evidence_ids,471    })),472  );473474  if (input.competitors?.length) {475    for (const c of input.competitors) {476      const [comp] = await db477        .insert(competitors)478        .values({ name: c.name, url: c.url ?? null, description: c.description ?? null })479        .onConflictDoUpdate({ target: competitors.name, set: { url: c.url ?? undefined, description: c.description ?? undefined } })480        .returning({ id: competitors.id });481      await db482        .insert(opportunityCompetitors)483        .values({ opportunityId: opp.id, competitorId: comp.id, note: c.note ?? null })484        .onConflictDoNothing();485    }486  }487488  await emitEvent(investigationId, "opportunity.created", {489    id: opp.id,490    title: opp.title,491    summary: opp.summary,492    worthScore,493    evidenceConfidence,494    scores: input.scores.map((s) => ({ dimension: s.dimension, score: s.score, confidence: s.confidence })),495  });496  return {497    content: `Opportunity created: [${opp.id}] "${opp.title}" — Worth Score ${worthScore} at ${Math.round(evidenceConfidence * 100)}% evidence confidence. Now call synthesize to write its report.`,498    isError: false,499  };500}501502async function execSynthesize(503  investigationId: string,504  input: z.infer<typeof toolSchemas.synthesize>,505): Promise<ToolOutcome> {506  const [opp] = await db507    .select()508    .from(opportunities)509    .where(and(eq(opportunities.id, input.opportunity_id), eq(opportunities.investigationId, investigationId)));510  if (!opp) return { content: `Opportunity ${input.opportunity_id} not found.`, isError: true };511512  const report = await synthesizeOpportunityReport(investigationId, opp.id);513  return { content: `Report synthesized for [${opp.id}] (${report.length} chars).`, isError: false };514}515516async function execFinish(517  investigationId: string,518  input: z.infer<typeof toolSchemas.finish_investigation>,519): Promise<ToolOutcome> {520  const state = await loadState(investigationId);521  const inv = state.investigation;522  const stepsLeft = inv.budget.maxAgentSteps - inv.budgetUsed.agentSteps;523  const searchesLeft = inv.budget.maxSearches - inv.budgetUsed.searches;524525  // Falsification gate: high-confidence live hypotheses must survive the skeptic first.526  const unchecked = state.hypotheses.filter(527    (h) => h.status !== "rejected" && h.confidence >= 0.65 && !h.adversarialChecked,528  );529  if (unchecked.length > 0 && stepsLeft > 2 && searchesLeft > 0) {530    return {531      content: `Cannot finish yet — these high-confidence hypotheses have not been adversarially checked: ${unchecked532        .map((h) => `[${h.id}] ${h.title}`)533        .join("; ")}. Run falsify searches against them first.`,534      isError: true,535    };536  }537538  // Every created opportunity needs its report before finishing (if budget allows).539  const unsynthesized = state.opportunities.filter((o) => !o.reportMd);540  if (unsynthesized.length > 0 && stepsLeft > 1) {541    return {542      content: `Cannot finish yet — synthesize reports for: ${unsynthesized.map((o) => `[${o.id}] ${o.title}`).join("; ")}.`,543      isError: true,544    };545  }546547  await db548    .update(investigations)549    .set({550      status: "completed",551      phase: "done",552      stopReason: "agent_finished",553      conclusion: input.conclusion,554      outcome: input.outcome,555      completedAt: new Date(),556    })557    .where(eq(investigations.id, investigationId));558  await emitEvent(investigationId, "investigation.completed", {559    conclusion: input.conclusion,560    outcome: input.outcome,561    stats: {562      hypotheses: state.hypotheses.length,563      rejected: state.hypotheses.filter((h) => h.status === "rejected").length,564      evidence: state.evidence.length,565      searches: state.searches.length,566      opportunities: state.opportunities.length,567    },568  });569  return { content: "Investigation completed.", isError: false, finished: true };570}571572export { canonicalizeUrl };573