/** * runner.ts — Core agentic loop: turn-by-turn execution, tool dispatch, * and event emission. This module owns the *how* of a single agent turn: * 1. Receive messages from the orchestrator * 2. Call the LLM backend (via backendAdapter) * 3. Parse tool calls from the response * 4. Dispatch each tool and collect results * 5. Emit events back to the orchestrator * * The orchestrator owns the *what* and *when*: session lifecycle, state * transitions, compaction decisions, and multi-agent coordination. */ import type { LLMBackend, LLMEvent } from './backendAdapter.js'; // ─── Public types ──────────────────────────────────────────────────────────── /** Result returned by a single run cycle */ export interface RunResult { /** Final assistant text (concatenated chunks) */ text: string; /** Tool calls that were made during this turn */ toolCalls: Array<{ id: string; name: string; args: string }>; /** Tool results that were collected */ toolResults: Array<{ id: string; content: string; error?: string }>; /** Whether the run completed normally */ finished: boolean; /** Error message if the run failed */ error?: string; } /** Configuration for a single run cycle */ export interface RunnerConfig { /** LLM backend to use for completions */ backend: LLMBackend; /** Registered tools keyed by name */ tools: Map; /** Maximum turns before forced stop */ maxTurns: number; /** Whether to stream events to the orchestrator */ streamEvents: boolean; } /** A tool handler that can be invoked by the runner */ export interface ToolHandler { /** Unique tool name */ name: string; /** Description of what the tool does */ description: string; /** Schema describing the tool's parameters */ parameters: Record; /** Execute the tool with the given arguments */ execute(args: string): Promise; } /** Event emitted by the runner during execution */ export interface RunnerEvent { type: 'chunk' | 'tool-call' | 'tool-result' | 'finish' | 'error' | 'turn-start' | 'turn-end'; content?: string; toolCall?: { id: string; name: string; args: string }; toolResult?: { id: string; content: string; error?: string }; turnNumber?: number; error?: string; } // ─── Core runner ───────────────────────────────────────────────────────────── export class AgenticRunner { private config: RunnerConfig; private turnCount = 0; private currentText = ''; private currentToolCalls: Array<{ id: string; name: string; args: string }> = []; private currentToolResults: Array<{ id: string; content: string; error?: string }> = []; private onEvent?: (event: RunnerEvent) => void; constructor(config: RunnerConfig, onEvent?: (event: RunnerEvent) => void) { this.config = config; this.onEvent = onEvent; } /** * Run one complete agentic cycle: prompt LLM → parse response → execute tools → return result. * This is the core turn loop that the orchestrator calls repeatedly. */ async run(messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>): Promise { this.turnCount++; this.currentText = ''; this.currentToolCalls = []; this.currentToolResults = []; // Emit turn-start event this.emit({ type: 'turn-start', turnNumber: this.turnCount }); // Call the LLM backend const response = await this.callLLM(messages); // Parse the response into tool calls and text const { text, toolCalls } = this.parseResponse(response); this.currentText = text; this.currentToolCalls = toolCalls; // Execute any tool calls if (toolCalls.length > 0) { const results = await this.executeTools(toolCalls); this.currentToolResults = results; // Build follow-up messages with tool results const toolResultMessages = this.buildToolResultMessages(results); const finalResponse = await this.callLLM([...messages, ...toolResultMessages]); const { text: finalText } = this.parseResponse(finalResponse); this.currentText = finalText; } // Emit finish event this.emit({ type: 'finish', content: this.currentText, turnNumber: this.turnCount, }); return { text: this.currentText, toolCalls: this.currentToolCalls, toolResults: this.currentToolResults, finished: true, }; } /** Call the LLM backend and collect the full response */ private async callLLM(messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>): Promise { const stream = this.config.backend.complete(messages, this.buildToolDefinitions()); let fullResponse = ''; for await (const event of stream) { if (event.type === 'chunk' && event.content) { this.emit({ type: 'chunk', content: event.content }); fullResponse += event.content; } else if (event.type === 'error') { this.emit({ type: 'error', error: event.error }); throw new Error(event.error ?? 'Unknown backend error'); } } return fullResponse; } /** Build tool definitions for the LLM backend */ private buildToolDefinitions(): Array<{ name: string; description?: string; parameters: Record }> { const definitions: Array<{ name: string; description?: string; parameters: Record }> = []; for (const tool of this.config.tools.values()) { definitions.push({ name: tool.name, description: tool.description, parameters: tool.parameters, }); } return definitions; } /** Parse the LLM response into text and tool calls */ private parseResponse(response: string): { text: string; toolCalls: Array<{ id: string; name: string; args: string }> } { const toolCalls: Array<{ id: string; name: string; args: string }> = []; let text = response; // Extract tool calls using regex pattern matching const toolCallRegex = /\s*({[\s\S]*?})\s*<\/tool_call>/g; let match; while ((match = toolCallRegex.exec(response)) !== null) { try { const args = JSON.parse(match[1]); const id = `tool_${Date.now()}_${Math.random().toString(36).slice(2)}`; const name = args.name || args.function?.name || 'unknown'; const toolArgs = args.arguments || args.args || args.function?.arguments || '{}'; const parsedArgs = typeof toolArgs === 'string' ? toolArgs : JSON.stringify(toolArgs); toolCalls.push({ id, name, args: parsedArgs }); // Remove the tool call from the text text = text.replace(match[0], '').trim(); } catch { // Invalid JSON — skip this match } } // Emit tool-call events for (const tc of toolCalls) { this.emit({ type: 'tool-call', toolCall: tc }); } return { text, toolCalls }; } /** Execute all tool calls and collect results */ private async executeTools(toolCalls: Array<{ id: string; name: string; args: string }>): Promise> { const results: Array<{ id: string; content: string; error?: string }> = []; for (const tc of toolCalls) { const tool = this.config.tools.get(tc.name); if (!tool) { results.push({ id: tc.id, content: `Error: Tool "${tc.name}" not found`, error: 'tool_not_found' }); this.emit({ type: 'tool-result', toolResult: { id: tc.id, content: `Tool "${tc.name}" not found`, error: 'tool_not_found' }, }); continue; } try { const result = await tool.execute(tc.args); results.push({ id: tc.id, content: result }); this.emit({ type: 'tool-result', toolResult: { id: tc.id, content: result }, }); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); results.push({ id: tc.id, content: `Error: ${errorMsg}`, error: 'execution_error' }); this.emit({ type: 'tool-result', toolResult: { id: tc.id, content: `Error: ${errorMsg}`, error: 'execution_error' }, }); } } return results; } /** Build messages from tool results for the follow-up LLM call */ private buildToolResultMessages(results: Array<{ id: string; content: string; error?: string }>): Array<{ role: 'system' | 'user' | 'assistant'; content: string }> { return results.map((r) => ({ role: 'system', content: `\n${r.id}\n${r.content}\n`, })); } /** Emit an event if a handler is registered */ private emit(event: RunnerEvent): void { if (this.onEvent) { this.onEvent(event); } } /** Get the current turn count */ getTurnCount(): number { return this.turnCount; } /** Reset the runner state */ reset(): void { this.turnCount = 0; this.currentText = ''; this.currentToolCalls = []; this.currentToolResults = []; } }