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%
13.7 KB · 380 lines typescript
Raw Blame History
1/**2 * WorthDoing.ai3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: src/lib/agent/tools.ts6 * Description: Agent tool contract — Zod schemas for validation and Anthropic tool definitions.7 */8import { z } from "zod";9import type Anthropic from "@anthropic-ai/sdk";1011export const TOOL_SCHEMA_VERSION = "1.0.0";1213/* ------------------------------ Zod schemas ------------------------------ */1415export const searchWebSchema = z.object({16  query: z.string().min(2).max(400),17  intent: z.enum(["explore", "verify", "falsify", "market", "technical", "competition"]),18  limit: z.number().int().min(3).max(10).optional(),19});2021export const scrapePageSchema = z.object({22  url: z.string().url(),23  reason: z.string().min(3).max(300),24});2526export const crawlSiteSchema = z.object({27  url: z.string().url(),28  limit: z.number().int().min(2).max(10),29  reason: z.string().min(3).max(300),30});3132export const extractStructuredSchema = z.object({33  urls: z.array(z.string().url()).min(1).max(5),34  prompt: z.string().min(5).max(1000),35  schema: z.record(z.string(), z.unknown()),36});3738export const createHypothesisSchema = z.object({39  title: z.string().min(3).max(160),40  statement: z.string().min(10).max(1000),41  rationale: z.string().min(5).max(1000),42  confidence: z.number().min(0).max(1),43  parent_hypothesis_id: z.string().uuid().optional(),44});4546export const updateHypothesisSchema = z.object({47  hypothesis_id: z.string().uuid(),48  status: z.enum(["investigating", "supported", "weakened", "validated"]).optional(),49  confidence: z.number().min(0).max(1),50  rationale: z.string().min(5).max(1000),51  adversarial_checked: z.boolean().optional(),52});5354export const rejectHypothesisSchema = z.object({55  hypothesis_id: z.string().uuid(),56  reason: z.string().min(5).max(1000),57});5859export const saveEvidenceSchema = z.object({60  source_id: z.string().uuid(),61  kind: z.enum(["support", "contradict", "context"]),62  quote: z.string().min(10).max(2000),63  summary: z.string().min(5).max(500),64  strength: z.number().min(0).max(1),65  links: z66    .array(67      z.object({68        hypothesis_id: z.string().uuid(),69        relation: z.enum(["supports", "contradicts"]),70        weight: z.number().min(0).max(1),71      }),72    )73    .max(5)74    .optional(),75});7677const scoreDimension = z.enum([78  "demand",79  "neglectedness",80  "feasibility",81  "why_now",82  "impact",83  "competition",84  "risk",85]);8687export const createOpportunitySchema = z.object({88  hypothesis_id: z.string().uuid().optional(),89  title: z.string().min(3).max(200),90  summary: z.string().min(20).max(1500),91  problem: z.string().min(20).max(3000),92  why_now: z.string().min(20).max(3000),93  risks: z.string().min(20).max(3000),94  skeptic_case: z.string().min(40).max(3000),95  evidence: z96    .array(97      z.object({98        evidence_id: z.string().uuid(),99        role: z.enum(["demand", "neglect", "feasibility", "why_now", "risk", "competition", "context"]),100      }),101    )102    .min(2)103    .max(30),104  competitors: z105    .array(106      z.object({107        name: z.string().min(1).max(120),108        url: z.string().url().optional(),109        description: z.string().max(400).optional(),110        note: z.string().max(400).optional(),111      }),112    )113    .max(15)114    .optional(),115  scores: z116    .array(117      z.object({118        dimension: scoreDimension,119        score: z.number().min(0).max(100),120        confidence: z.number().min(0).max(1),121        reasoning: z.string().min(10).max(1200),122        evidence_ids: z.array(z.string().uuid()).min(1).max(15),123      }),124    )125    .min(5)126    .max(7),127});128129export const synthesizeSchema = z.object({130  opportunity_id: z.string().uuid(),131});132133export const finishInvestigationSchema = z.object({134  conclusion: z.string().min(20).max(4000),135  outcome: z.enum(["opportunities_found", "insufficient_evidence", "no_strong_opportunity"]),136});137138export const toolSchemas = {139  search_web: searchWebSchema,140  scrape_page: scrapePageSchema,141  crawl_site: crawlSiteSchema,142  extract_structured: extractStructuredSchema,143  create_hypothesis: createHypothesisSchema,144  update_hypothesis: updateHypothesisSchema,145  reject_hypothesis: rejectHypothesisSchema,146  save_evidence: saveEvidenceSchema,147  create_opportunity: createOpportunitySchema,148  synthesize: synthesizeSchema,149  finish_investigation: finishInvestigationSchema,150} as const;151152export type ToolName = keyof typeof toolSchemas;153154/* --------------------------- Anthropic tool defs -------------------------- */155156const obj = (157  properties: Record<string, unknown>,158  required: string[],159): { type: "object"; properties: Record<string, unknown>; required: string[]; additionalProperties: false } => ({160  type: "object",161  properties,162  required,163  additionalProperties: false,164});165166export const anthropicTools: Anthropic.Tool[] = [167  {168    name: "search_web",169    description:170      "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.",171    input_schema: obj(172      {173        query: { type: "string", description: "The search query" },174        intent: {175          type: "string",176          enum: ["explore", "verify", "falsify", "market", "technical", "competition"],177        },178        limit: { type: "integer", minimum: 3, maximum: 10, description: "Result count (default 8)" },179      },180      ["query", "intent"],181    ),182  },183  {184    name: "scrape_page",185    description:186      "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.",187    input_schema: obj(188      {189        url: { type: "string", description: "Absolute URL to scrape" },190        reason: { type: "string", description: "One line: what signal you expect this page to contain" },191      },192      ["url", "reason"],193    ),194  },195  {196    name: "crawl_site",197    description:198      "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.",199    input_schema: obj(200      {201        url: { type: "string" },202        limit: { type: "integer", minimum: 2, maximum: 10 },203        reason: { type: "string" },204      },205      ["url", "limit", "reason"],206    ),207  },208  {209    name: "extract_structured",210    description:211      "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.",212    input_schema: obj(213      {214        urls: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 5 },215        prompt: { type: "string", description: "What to extract" },216        schema: { type: "object", description: "JSON schema of the desired output" },217      },218      ["urls", "prompt", "schema"],219    ),220  },221  {222    name: "create_hypothesis",223    description:224      "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.",225    input_schema: obj(226      {227        title: { type: "string" },228        statement: { type: "string", description: "Falsifiable statement" },229        rationale: { type: "string" },230        confidence: { type: "number", minimum: 0, maximum: 1 },231        parent_hypothesis_id: { type: "string" },232      },233      ["title", "statement", "rationale", "confidence"],234    ),235  },236  {237    name: "update_hypothesis",238    description:239      "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.",240    input_schema: obj(241      {242        hypothesis_id: { type: "string" },243        status: { type: "string", enum: ["investigating", "supported", "weakened", "validated"] },244        confidence: { type: "number", minimum: 0, maximum: 1 },245        rationale: { type: "string", description: "What changed and why" },246        adversarial_checked: { type: "boolean" },247      },248      ["hypothesis_id", "confidence", "rationale"],249    ),250  },251  {252    name: "reject_hypothesis",253    description:254      "Reject a hypothesis the evidence does not support. A rejected hypothesis is valuable output — reject decisively rather than letting weak hypotheses linger.",255    input_schema: obj(256      {257        hypothesis_id: { type: "string" },258        reason: { type: "string" },259      },260      ["hypothesis_id", "reason"],261    ),262  },263  {264    name: "save_evidence",265    description:266      "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.",267    input_schema: obj(268      {269        source_id: { type: "string", description: "source_id returned by scrape_page/crawl_site" },270        kind: { type: "string", enum: ["support", "contradict", "context"] },271        quote: { type: "string", description: "Near-verbatim excerpt from the source" },272        summary: { type: "string", description: "One-line interpretation" },273        strength: { type: "number", minimum: 0, maximum: 1 },274        links: {275          type: "array",276          maxItems: 5,277          items: {278            type: "object",279            properties: {280              hypothesis_id: { type: "string" },281              relation: { type: "string", enum: ["supports", "contradicts"] },282              weight: { type: "number", minimum: 0, maximum: 1 },283            },284            required: ["hypothesis_id", "relation", "weight"],285            additionalProperties: false,286          },287        },288      },289      ["source_id", "kind", "quote", "summary", "strength"],290    ),291  },292  {293    name: "create_opportunity",294    description:295      "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.",296    input_schema: obj(297      {298        hypothesis_id: { type: "string" },299        title: { type: "string" },300        summary: { type: "string" },301        problem: { type: "string" },302        why_now: { type: "string" },303        risks: { type: "string" },304        skeptic_case: { type: "string" },305        evidence: {306          type: "array",307          minItems: 2,308          items: {309            type: "object",310            properties: {311              evidence_id: { type: "string" },312              role: {313                type: "string",314                enum: ["demand", "neglect", "feasibility", "why_now", "risk", "competition", "context"],315              },316            },317            required: ["evidence_id", "role"],318            additionalProperties: false,319          },320        },321        competitors: {322          type: "array",323          items: {324            type: "object",325            properties: {326              name: { type: "string" },327              url: { type: "string" },328              description: { type: "string" },329              note: { type: "string" },330            },331            required: ["name"],332            additionalProperties: false,333          },334        },335        scores: {336          type: "array",337          minItems: 5,338          items: {339            type: "object",340            properties: {341              dimension: {342                type: "string",343                enum: ["demand", "neglectedness", "feasibility", "why_now", "impact", "competition", "risk"],344              },345              score: { type: "number", minimum: 0, maximum: 100 },346              confidence: { type: "number", minimum: 0, maximum: 1 },347              reasoning: { type: "string" },348              evidence_ids: { type: "array", items: { type: "string" }, minItems: 1 },349            },350            required: ["dimension", "score", "confidence", "reasoning", "evidence_ids"],351            additionalProperties: false,352          },353        },354      },355      ["title", "summary", "problem", "why_now", "risks", "skeptic_case", "evidence", "scores"],356    ),357  },358  {359    name: "synthesize",360    description:361      "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.",362    input_schema: obj({ opportunity_id: { type: "string" } }, ["opportunity_id"]),363  },364  {365    name: "finish_investigation",366    description:367      "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.",368    input_schema: obj(369      {370        conclusion: { type: "string" },371        outcome: {372          type: "string",373          enum: ["opportunities_found", "insufficient_evidence", "no_strong_opportunity"],374        },375      },376      ["conclusion", "outcome"],377    ),378  },379];380