/** * WorthDoing.ai * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: src/lib/agent/tools.ts * Description: Agent tool contract — Zod schemas for validation and Anthropic tool definitions. */ import { z } from "zod"; import type Anthropic from "@anthropic-ai/sdk"; export const TOOL_SCHEMA_VERSION = "1.0.0"; /* ------------------------------ Zod schemas ------------------------------ */ export const searchWebSchema = z.object({ query: z.string().min(2).max(400), intent: z.enum(["explore", "verify", "falsify", "market", "technical", "competition"]), limit: z.number().int().min(3).max(10).optional(), }); export const scrapePageSchema = z.object({ url: z.string().url(), reason: z.string().min(3).max(300), }); export const crawlSiteSchema = z.object({ url: z.string().url(), limit: z.number().int().min(2).max(10), reason: z.string().min(3).max(300), }); export const extractStructuredSchema = z.object({ urls: z.array(z.string().url()).min(1).max(5), prompt: z.string().min(5).max(1000), schema: z.record(z.string(), z.unknown()), }); export const createHypothesisSchema = z.object({ title: z.string().min(3).max(160), statement: z.string().min(10).max(1000), rationale: z.string().min(5).max(1000), confidence: z.number().min(0).max(1), parent_hypothesis_id: z.string().uuid().optional(), }); export const updateHypothesisSchema = z.object({ hypothesis_id: z.string().uuid(), status: z.enum(["investigating", "supported", "weakened", "validated"]).optional(), confidence: z.number().min(0).max(1), rationale: z.string().min(5).max(1000), adversarial_checked: z.boolean().optional(), }); export const rejectHypothesisSchema = z.object({ hypothesis_id: z.string().uuid(), reason: z.string().min(5).max(1000), }); export const saveEvidenceSchema = z.object({ source_id: z.string().uuid(), kind: z.enum(["support", "contradict", "context"]), quote: z.string().min(10).max(2000), summary: z.string().min(5).max(500), strength: z.number().min(0).max(1), links: z .array( z.object({ hypothesis_id: z.string().uuid(), relation: z.enum(["supports", "contradicts"]), weight: z.number().min(0).max(1), }), ) .max(5) .optional(), }); const scoreDimension = z.enum([ "demand", "neglectedness", "feasibility", "why_now", "impact", "competition", "risk", ]); export const createOpportunitySchema = z.object({ hypothesis_id: z.string().uuid().optional(), title: z.string().min(3).max(200), summary: z.string().min(20).max(1500), problem: z.string().min(20).max(3000), why_now: z.string().min(20).max(3000), risks: z.string().min(20).max(3000), skeptic_case: z.string().min(40).max(3000), evidence: z .array( z.object({ evidence_id: z.string().uuid(), role: z.enum(["demand", "neglect", "feasibility", "why_now", "risk", "competition", "context"]), }), ) .min(2) .max(30), competitors: z .array( z.object({ name: z.string().min(1).max(120), url: z.string().url().optional(), description: z.string().max(400).optional(), note: z.string().max(400).optional(), }), ) .max(15) .optional(), scores: z .array( z.object({ dimension: scoreDimension, score: z.number().min(0).max(100), confidence: z.number().min(0).max(1), reasoning: z.string().min(10).max(1200), evidence_ids: z.array(z.string().uuid()).min(1).max(15), }), ) .min(5) .max(7), }); export const synthesizeSchema = z.object({ opportunity_id: z.string().uuid(), }); export const finishInvestigationSchema = z.object({ conclusion: z.string().min(20).max(4000), outcome: z.enum(["opportunities_found", "insufficient_evidence", "no_strong_opportunity"]), }); export const toolSchemas = { search_web: searchWebSchema, scrape_page: scrapePageSchema, crawl_site: crawlSiteSchema, extract_structured: extractStructuredSchema, create_hypothesis: createHypothesisSchema, update_hypothesis: updateHypothesisSchema, reject_hypothesis: rejectHypothesisSchema, save_evidence: saveEvidenceSchema, create_opportunity: createOpportunitySchema, synthesize: synthesizeSchema, finish_investigation: finishInvestigationSchema, } as const; export type ToolName = keyof typeof toolSchemas; /* --------------------------- Anthropic tool defs -------------------------- */ const obj = ( properties: Record, required: string[], ): { type: "object"; properties: Record; required: string[]; additionalProperties: false } => ({ type: "object", properties, required, additionalProperties: false, }); export const anthropicTools: Anthropic.Tool[] = [ { name: "search_web", description: "Search the web via Firecrawl. Use varied, precise queries. intent classifies the goal: explore (open discovery), verify (confirm a hypothesis), falsify (find counterevidence — mandatory for every high-confidence hypothesis), market (demand/willingness-to-pay signals), technical (feasibility), competition (existing solutions). Returns result URLs with titles and descriptions — it does NOT scrape pages.", input_schema: obj( { query: { type: "string", description: "The search query" }, intent: { type: "string", enum: ["explore", "verify", "falsify", "market", "technical", "competition"], }, limit: { type: "integer", minimum: 3, maximum: 10, description: "Result count (default 8)" }, }, ["query", "intent"], ), }, { name: "scrape_page", description: "Fetch one page's main content as markdown (cached 24h; identical URLs are deduplicated automatically). Returns a source_id you must use when saving evidence from this page, plus a content excerpt. Scrape only pages likely to contain decision-relevant signal.", input_schema: obj( { url: { type: "string", description: "Absolute URL to scrape" }, reason: { type: "string", description: "One line: what signal you expect this page to contain" }, }, ["url", "reason"], ), }, { name: "crawl_site", description: "Crawl a small set of pages from one site (max 10 pages). Expensive and slow — use only when one site clearly holds multiple relevant pages (e.g. a changelog + pricing + docs). Never crawl entire domains.", input_schema: obj( { url: { type: "string" }, limit: { type: "integer", minimum: 2, maximum: 10 }, reason: { type: "string" }, }, ["url", "limit", "reason"], ), }, { name: "extract_structured", description: "Extract structured data from up to 5 URLs against a JSON schema (Firecrawl extract). Use for comparable facts across pages (pricing, feature lists, launch dates). Slower than scraping — prefer scrape_page unless you need cross-page structure.", input_schema: obj( { urls: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 5 }, prompt: { type: "string", description: "What to extract" }, schema: { type: "object", description: "JSON schema of the desired output" }, }, ["urls", "prompt", "schema"], ), }, { name: "create_hypothesis", description: "Create a falsifiable hypothesis about something potentially worth doing. Statement must be specific enough that evidence could contradict it. Start confidence honestly (usually 0.3–0.6). Use parent_hypothesis_id when branching a variant of an existing hypothesis.", input_schema: obj( { title: { type: "string" }, statement: { type: "string", description: "Falsifiable statement" }, rationale: { type: "string" }, confidence: { type: "number", minimum: 0, maximum: 1 }, parent_hypothesis_id: { type: "string" }, }, ["title", "statement", "rationale", "confidence"], ), }, { name: "update_hypothesis", description: "Update a hypothesis's confidence and status as evidence accumulates. Lowering confidence when counterevidence appears is progress, not failure. Set adversarial_checked=true only after you have genuinely searched for counterevidence (intent=falsify) for this hypothesis.", input_schema: obj( { hypothesis_id: { type: "string" }, status: { type: "string", enum: ["investigating", "supported", "weakened", "validated"] }, confidence: { type: "number", minimum: 0, maximum: 1 }, rationale: { type: "string", description: "What changed and why" }, adversarial_checked: { type: "boolean" }, }, ["hypothesis_id", "confidence", "rationale"], ), }, { name: "reject_hypothesis", description: "Reject a hypothesis the evidence does not support. A rejected hypothesis is valuable output — reject decisively rather than letting weak hypotheses linger.", input_schema: obj( { hypothesis_id: { type: "string" }, reason: { type: "string" }, }, ["hypothesis_id", "reason"], ), }, { name: "save_evidence", description: "Save a piece of evidence from a scraped source. quote must be an excerpt from the actual page content (near-verbatim). Link it to hypotheses it supports or contradicts. Duplicate evidence (same source + same quote) is rejected automatically.", input_schema: obj( { source_id: { type: "string", description: "source_id returned by scrape_page/crawl_site" }, kind: { type: "string", enum: ["support", "contradict", "context"] }, quote: { type: "string", description: "Near-verbatim excerpt from the source" }, summary: { type: "string", description: "One-line interpretation" }, strength: { type: "number", minimum: 0, maximum: 1 }, links: { type: "array", maxItems: 5, items: { type: "object", properties: { hypothesis_id: { type: "string" }, relation: { type: "string", enum: ["supports", "contradicts"] }, weight: { type: "number", minimum: 0, maximum: 1 }, }, required: ["hypothesis_id", "relation", "weight"], additionalProperties: false, }, }, }, ["source_id", "kind", "quote", "summary", "strength"], ), }, { name: "create_opportunity", description: "Create an opportunity from a hypothesis that survived the skeptic phase. Every field must be defensible from saved evidence. skeptic_case is the strongest honest argument AGAINST pursuing this. scores must cover at least: demand, neglectedness, feasibility, why_now, impact (competition and risk encouraged). Each score cites the evidence_ids that justify it — never invent scores without evidence.", input_schema: obj( { hypothesis_id: { type: "string" }, title: { type: "string" }, summary: { type: "string" }, problem: { type: "string" }, why_now: { type: "string" }, risks: { type: "string" }, skeptic_case: { type: "string" }, evidence: { type: "array", minItems: 2, items: { type: "object", properties: { evidence_id: { type: "string" }, role: { type: "string", enum: ["demand", "neglect", "feasibility", "why_now", "risk", "competition", "context"], }, }, required: ["evidence_id", "role"], additionalProperties: false, }, }, competitors: { type: "array", items: { type: "object", properties: { name: { type: "string" }, url: { type: "string" }, description: { type: "string" }, note: { type: "string" }, }, required: ["name"], additionalProperties: false, }, }, scores: { type: "array", minItems: 5, items: { type: "object", properties: { dimension: { type: "string", enum: ["demand", "neglectedness", "feasibility", "why_now", "impact", "competition", "risk"], }, score: { type: "number", minimum: 0, maximum: 100 }, confidence: { type: "number", minimum: 0, maximum: 1 }, reasoning: { type: "string" }, evidence_ids: { type: "array", items: { type: "string" }, minItems: 1 }, }, required: ["dimension", "score", "confidence", "reasoning", "evidence_ids"], additionalProperties: false, }, }, }, ["title", "summary", "problem", "why_now", "risks", "skeptic_case", "evidence", "scores"], ), }, { name: "synthesize", description: "Generate the full streamed report for an opportunity you already created. The backend writes the report from the opportunity's saved state and evidence — call this once per opportunity, after its evidence and scores are complete.", input_schema: obj({ opportunity_id: { type: "string" } }, ["opportunity_id"]), }, { name: "finish_investigation", description: "End the investigation with a conclusion. Blocked while high-confidence hypotheses remain adversarially unchecked. 'The evidence does not justify a strong opportunity' is a valid, valuable outcome — use outcome=no_strong_opportunity or insufficient_evidence honestly.", input_schema: obj( { conclusion: { type: "string" }, outcome: { type: "string", enum: ["opportunities_found", "insufficient_evidence", "no_strong_opportunity"], }, }, ["conclusion", "outcome"], ), }, ];