/** * WorthDoing.ai * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: src/lib/agent/engine.ts * Description: The explicit agent loop — Claude decides actions, the backend executes, state persists, repeat. */ import type Anthropic from "@anthropic-ai/sdk"; import { eq } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { investigations, investigationSteps } from "@/lib/db/schema"; import { anthropic, agentModel, estimateCostUsd } from "@/lib/anthropic/client"; import { AGENT_SYSTEM_PROMPT } from "./prompts"; import { anthropicTools } from "./tools"; import { executeTool } from "./executors"; import { loadState, buildStateDigest, computePhase } from "./state"; import { emitEvent } from "./events"; const MAX_TOKENS_PER_STEP = 16000; const KEEP_RECENT_MESSAGES = 8; // messages kept verbatim; older tool results are compressed const OLD_TOOL_RESULT_CAP = 400; // chars kept from old tool results after compression type MessageParam = Anthropic.MessageParam; /** Compress old tool results in-place so scraped pages are never resent in full. */ function compressHistory(messages: MessageParam[]): MessageParam[] { if (messages.length <= KEEP_RECENT_MESSAGES) return messages; const cutoff = messages.length - KEEP_RECENT_MESSAGES; return messages.map((m, i) => { if (i >= cutoff || m.role !== "user" || typeof m.content === "string") return m; const content = m.content.map((block) => { if (block.type === "tool_result" && typeof block.content === "string" && block.content.length > OLD_TOOL_RESULT_CAP) { return { ...block, content: `${block.content.slice(0, OLD_TOOL_RESULT_CAP)}\n…[older result compressed — durable facts live in the state digest]`, }; } if (block.type === "text" && block.text.startsWith("CURRENT INVESTIGATION STATE")) { return { ...block, text: "[superseded state digest omitted]" }; } return block; }); return { ...m, content }; }); } /** * Run an investigation to completion. Resumable: rebuilds context from * persisted state, so a restart continues rather than restarts. */ export async function runInvestigation(investigationId: string): Promise { const model = agentModel(); let state = await loadState(investigationId); const inv = state.investigation; if (inv.status === "completed" || inv.status === "cancelled") return; const startedFresh = inv.status === "pending"; await db .update(investigations) .set({ status: "running", startedAt: inv.startedAt ?? new Date() }) .where(eq(investigations.id, investigationId)); if (startedFresh) { await emitEvent(investigationId, "investigation.started", { objective: inv.objective, budget: inv.budget as unknown as Record, model, }); } const wallDeadline = (inv.startedAt?.getTime() ?? Date.now()) + inv.budget.maxWallTimeMs; let messages: MessageParam[] = [ { role: "user", content: `CURRENT INVESTIGATION STATE\n${buildStateDigest(state)}\n\nBegin (or continue) the investigation. Narrate briefly, then act with tools.`, }, ]; try { for (;;) { state = await loadState(investigationId); const used = state.investigation.budgetUsed; const budget = state.investigation.budget; if (state.investigation.status === "cancelled") return; if (used.agentSteps >= budget.maxAgentSteps || Date.now() > wallDeadline) { const reason = Date.now() > wallDeadline ? "budget_wall_time" : "budget_steps"; await forceStop(investigationId, reason); return; } // Phase bookkeeping — emitted only on real transitions. const phase = computePhase(state); if (phase !== state.investigation.phase) { await db.update(investigations).set({ phase }).where(eq(investigations.id, investigationId)); await emitEvent(investigationId, "phase.changed", { from: state.investigation.phase, to: phase }); } const stepNumber = used.agentSteps + 1; const t0 = Date.now(); const stream = anthropic().messages.stream({ model, max_tokens: MAX_TOKENS_PER_STEP, system: [{ type: "text", text: AGENT_SYSTEM_PROMPT, cache_control: { type: "ephemeral" } }], tools: anthropicTools, messages: compressHistory(messages), }); const response = await stream.finalMessage(); const latencyMs = Date.now() - t0; // Count the step + tokens. const inputTokens = response.usage.input_tokens + (response.usage.cache_read_input_tokens ?? 0) + (response.usage.cache_creation_input_tokens ?? 0); const outputTokens = response.usage.output_tokens; const costUsd = estimateCostUsd(model, response.usage.input_tokens, outputTokens); await db .update(investigations) .set({ budgetUsed: { ...used, agentSteps: used.agentSteps + 1, inputTokens: used.inputTokens + inputTokens, outputTokens: used.outputTokens + outputTokens, costUsd: Math.round((used.costUsd + costUsd) * 10000) / 10000, }, }) .where(eq(investigations.id, investigationId)); const textBlocks = response.content.filter((b): b is Anthropic.TextBlock => b.type === "text"); const planText = textBlocks.map((b) => b.text).join("\n").trim(); if (planText) { await emitEvent(investigationId, "agent.plan", { step: stepNumber, text: planText }); } await db.insert(investigationSteps).values({ investigationId, stepNumber, model, inputTokens, outputTokens, costUsd, latencyMs, decisionSummary: planText.slice(0, 2000) || null, }); if (response.stop_reason === "refusal") { await failInvestigation(investigationId, "Model declined a step (safety classifiers)."); return; } // Keep assistant content verbatim (thinking blocks must round-trip unchanged). messages.push({ role: "assistant", content: response.content }); if (response.stop_reason === "pause_turn") continue; const toolUses = response.content.filter((b): b is Anthropic.ToolUseBlock => b.type === "tool_use"); if (toolUses.length === 0) { // No tool call — nudge once toward action; the loop's only exit is finish_investigation. messages.push({ role: "user", content: "You ended your turn without a tool call. Continue with tool calls, or end the investigation properly with finish_investigation.", }); continue; } const toolResults: Anthropic.ToolResultBlockParam[] = []; let finished = false; for (const call of toolUses) { const execT0 = Date.now(); const outcome = await executeTool(investigationId, call.name, call.input); await db.insert(investigationSteps).values({ investigationId, stepNumber, model, toolName: call.name, toolArgs: call.input, toolResultSummary: outcome.content.slice(0, 1500), latencyMs: Date.now() - execT0, error: outcome.isError ? outcome.content.slice(0, 500) : null, }); toolResults.push({ type: "tool_result", tool_use_id: call.id, content: outcome.content, is_error: outcome.isError, }); if (outcome.finished) finished = true; } if (finished) return; // Fresh digest rides along with the tool results each turn. state = await loadState(investigationId); messages.push({ role: "user", content: [ ...toolResults, { type: "text", text: `CURRENT INVESTIGATION STATE\n${buildStateDigest(state)}` }, ], }); } } catch (err) { const message = err instanceof Error ? err.message : String(err); await failInvestigation(investigationId, message); } } async function forceStop(investigationId: string, stopReason: "budget_steps" | "budget_wall_time"): Promise { const state = await loadState(investigationId); const conclusion = `Investigation stopped: ${ stopReason === "budget_steps" ? "agent step budget exhausted" : "wall-time budget exhausted" }. ${state.opportunities.length} opportunit${state.opportunities.length === 1 ? "y" : "ies"} and ${state.evidence.length} evidence items were gathered before the stop.`; await db .update(investigations) .set({ status: "completed", phase: "done", stopReason, conclusion, outcome: state.opportunities.length > 0 ? "opportunities_found" : "insufficient_evidence", completedAt: new Date(), }) .where(eq(investigations.id, investigationId)); await emitEvent(investigationId, "investigation.completed", { conclusion, outcome: state.opportunities.length > 0 ? "opportunities_found" : "insufficient_evidence", stopReason, 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, }, }); } async function failInvestigation(investigationId: string, message: string): Promise { await db .update(investigations) .set({ status: "failed", stopReason: "error", error: message.slice(0, 1000), completedAt: new Date() }) .where(eq(investigations.id, investigationId)); await emitEvent(investigationId, "investigation.failed", { message: message.slice(0, 500) }); }