/** * Search-box.ai * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: packages/agent/src/orchestrator.ts * Description: Single-agent research loop — Claude decides strategy, the app enforces safety. */ import type Anthropic from "@anthropic-ai/sdk"; import { getModels, orchestratorTurn } from "@search-box/anthropic"; import type { ResearchState } from "@search-box/research"; import { budgetExceeded, newBudgetUsage, type Budgets } from "@search-box/shared"; import { ORCHESTRATOR_SYSTEM, PROMPT_VERSION } from "./prompts.js"; import { TOOLS, executeTool, type ToolContext } from "./tools.js"; export interface OrchestrationResult { finishedReason: "model_finished" | "budget" | "no_tools" | "max_turns"; readinessSummary: string | null; } /** * Runs the agentic research loop until the model calls finish_research, * budgets run out, or the model stops calling tools. */ export async function runOrchestration( state: ResearchState, question: string, budgets: Budgets ): Promise { const models = getModels(); const usage = newBudgetUsage(); const ctx: ToolContext = { state, budgets, usage, finished: { value: null } }; const system: Anthropic.TextBlockParam[] = [ { type: "text", text: ORCHESTRATOR_SYSTEM, cache_control: { type: "ephemeral" } } ]; const messages: Anthropic.MessageParam[] = [ { role: "user", content: `Research question: ${question}\n\nBudgets for this session: up to ${budgets.maxSearches} searches, ${budgets.maxScrapes} page fetches, ${budgets.maxModelTurns} reasoning turns. Begin.` } ]; let warnedBudget = false; while (true) { usage.modelTurns++; if (usage.modelTurns > budgets.maxModelTurns) { return { finishedReason: "max_turns", readinessSummary: ctx.finished.value }; } const response = await orchestratorTurn({ model: models.orchestrator, system, messages, tools: TOOLS }); if (response.stop_reason === "refusal") { throw new Error("model declined the request (safety refusal)"); } // Echo assistant content back verbatim (thinking blocks included). messages.push({ role: "assistant", content: response.content }); const toolUses = response.content.filter( (b): b is Anthropic.ToolUseBlock => b.type === "tool_use" ); if (toolUses.length === 0) { // Model concluded in prose without finish_research — accept as done. return { finishedReason: "no_tools", readinessSummary: ctx.finished.value }; } const toolResults: Anthropic.ToolResultBlockParam[] = []; for (const tu of toolUses) { const outcome = await executeTool(tu.name, tu.input, ctx); toolResults.push({ type: "tool_result", tool_use_id: tu.id, content: outcome.result, is_error: outcome.isError }); } const content: Anthropic.ContentBlockParam[] = [...toolResults]; if (ctx.finished.value !== null) { return { finishedReason: "model_finished", readinessSummary: ctx.finished.value }; } // Budget enforcement: warn once, then hard-stop on the following turn. const exceeded = budgetExceeded(budgets, usage); if (exceeded) { if (warnedBudget) { return { finishedReason: "budget", readinessSummary: ctx.finished.value }; } warnedBudget = true; content.push({ type: "text", text: `[system] ${exceeded}. Stop gathering: record any remaining claim updates now and call finish_research in this next turn.` }); } messages.push({ role: "user", content }); } } export { PROMPT_VERSION };