SPB Git

spb/search-box Public

Agentic web research engine — hypotheses, verbatim evidence, contradictions, sourced answers streamed live. Claude Opus 5 + Firecrawl + PostgreSQL.

TypeScript 76.9% CSS 18.7% SQL 2.1% JavaScript 1.8% Shell 0.5%
3.6 KB · 115 lines typescript
Raw Blame History
1/**2 * Search-box.ai3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: packages/agent/src/orchestrator.ts6 * Description: Single-agent research loop — Claude decides strategy, the app enforces safety.7 */89import type Anthropic from "@anthropic-ai/sdk";10import { getModels, orchestratorTurn } from "@search-box/anthropic";11import type { ResearchState } from "@search-box/research";12import { budgetExceeded, newBudgetUsage, type Budgets } from "@search-box/shared";13import { ORCHESTRATOR_SYSTEM, PROMPT_VERSION } from "./prompts.js";14import { TOOLS, executeTool, type ToolContext } from "./tools.js";1516export interface OrchestrationResult {17  finishedReason: "model_finished" | "budget" | "no_tools" | "max_turns";18  readinessSummary: string | null;19}2021/**22 * Runs the agentic research loop until the model calls finish_research,23 * budgets run out, or the model stops calling tools.24 */25export async function runOrchestration(26  state: ResearchState,27  question: string,28  budgets: Budgets29): Promise<OrchestrationResult> {30  const models = getModels();31  const usage = newBudgetUsage();32  const ctx: ToolContext = { state, budgets, usage, finished: { value: null } };3334  const system: Anthropic.TextBlockParam[] = [35    {36      type: "text",37      text: ORCHESTRATOR_SYSTEM,38      cache_control: { type: "ephemeral" }39    }40  ];4142  const messages: Anthropic.MessageParam[] = [43    {44      role: "user",45      content: `Research question: ${question}\n\nBudgets for this session: up to ${budgets.maxSearches} searches, ${budgets.maxScrapes} page fetches, ${budgets.maxModelTurns} reasoning turns. Begin.`46    }47  ];4849  let warnedBudget = false;5051  while (true) {52    usage.modelTurns++;53    if (usage.modelTurns > budgets.maxModelTurns) {54      return { finishedReason: "max_turns", readinessSummary: ctx.finished.value };55    }5657    const response = await orchestratorTurn({58      model: models.orchestrator,59      system,60      messages,61      tools: TOOLS62    });6364    if (response.stop_reason === "refusal") {65      throw new Error("model declined the request (safety refusal)");66    }6768    // Echo assistant content back verbatim (thinking blocks included).69    messages.push({ role: "assistant", content: response.content });7071    const toolUses = response.content.filter(72      (b): b is Anthropic.ToolUseBlock => b.type === "tool_use"73    );7475    if (toolUses.length === 0) {76      // Model concluded in prose without finish_research — accept as done.77      return { finishedReason: "no_tools", readinessSummary: ctx.finished.value };78    }7980    const toolResults: Anthropic.ToolResultBlockParam[] = [];81    for (const tu of toolUses) {82      const outcome = await executeTool(tu.name, tu.input, ctx);83      toolResults.push({84        type: "tool_result",85        tool_use_id: tu.id,86        content: outcome.result,87        is_error: outcome.isError88      });89    }9091    const content: Anthropic.ContentBlockParam[] = [...toolResults];9293    if (ctx.finished.value !== null) {94      return { finishedReason: "model_finished", readinessSummary: ctx.finished.value };95    }9697    // Budget enforcement: warn once, then hard-stop on the following turn.98    const exceeded = budgetExceeded(budgets, usage);99    if (exceeded) {100      if (warnedBudget) {101        return { finishedReason: "budget", readinessSummary: ctx.finished.value };102      }103      warnedBudget = true;104      content.push({105        type: "text",106        text: `[system] ${exceeded}. Stop gathering: record any remaining claim updates now and call finish_research in this next turn.`107      });108    }109110    messages.push({ role: "user", content });111  }112}113114export { PROMPT_VERSION };115