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%
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