/**
* 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 };