/** * The agent turn loop. Given a model `complete` fn, the conversation, the tool * declarations, and an `execute` that actually runs a tool, it drives the * call→tool→result→call cycle until the model answers in prose (or a step * budget is hit). Pure orchestration with injected I/O — the model and the tool * executor are both stubbable, so the loop is fully unit-testable offline. */ import type { ChatMessage, CompletionResult, ToolDecl } from './chat-client.js' /** A tool the loop ran, with the raw args and its stringified result — for the UI's activity trail. */ export interface AgentStep { tool: string arguments: string result: string } export type ToolExecutor = (name: string, args: unknown) => Promise export interface AgentTurn { content: string steps: AgentStep[] /** The full conversation including this turn's assistant/tool messages. */ messages: ChatMessage[] } const DEFAULT_MAX_STEPS = 4 function stringify(result: unknown): string { return typeof result === 'string' ? result : JSON.stringify(result) } export async function runAgentTurn(opts: { complete: (messages: ChatMessage[], tools?: ToolDecl[], onToken?: (delta: string) => void) => Promise messages: ChatMessage[] tools: ToolDecl[] execute: ToolExecutor maxSteps?: number /** Streams content deltas as the model produces prose (final-answer streaming). */ onToken?: (delta: string) => void }): Promise { const maxSteps = opts.maxSteps ?? DEFAULT_MAX_STEPS const convo: ChatMessage[] = [...opts.messages] const steps: AgentStep[] = [] for (let step = 0; step < maxSteps; step++) { const { content, toolCalls } = await opts.complete(convo, opts.tools, opts.onToken) if (toolCalls.length === 0) { convo.push({ role: 'assistant', content }) return { content, steps, messages: convo } } convo.push({ role: 'assistant', content, toolCalls }) for (const tc of toolCalls) { let result: unknown try { const args = tc.arguments ? JSON.parse(tc.arguments) : {} result = await opts.execute(tc.name, args) } catch (e) { result = { error: e instanceof Error ? e.message : String(e) } } const resultStr = stringify(result) steps.push({ tool: tc.name, arguments: tc.arguments, result: resultStr }) convo.push({ role: 'tool', toolCallId: tc.id, name: tc.name, content: resultStr }) } } // Out of tool budget — force a final prose answer with tools withheld. const final = await opts.complete(convo, [], opts.onToken) convo.push({ role: 'assistant', content: final.content }) return { content: final.content, steps, messages: convo } }