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%
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