import type { ToolCall, ToolResult, RetryConfig } from "../core/types.js"; const DEFAULT_RETRY: RetryConfig = { maxRetries: 3, baseDelayMs: 1000, backoffFactor: 2, maxDelayMs: 30_000, }; type ToolHandler = (call: ToolCall) => Promise; interface ToolRegistration { name: string; handler: ToolHandler; validator?: (args: Record) => string | null; timeoutMs: number; } /** * Step 3: Tool Orchestrator * Reliable tool dispatch with retry, exponential backoff, timeout enforcement, * and pluggable result validators. * * "Tool-use reliability at scale" — Fable 5's practical superpower. */ export class ToolOrchestrator { private tools: Map = new Map(); private retryConfig: RetryConfig; constructor(retryConfig?: Partial) { this.retryConfig = { ...DEFAULT_RETRY, ...retryConfig }; } // ── Registration ────────────────────────────────────────── /** Register a tool with optional validator and timeout */ register( name: string, handler: ToolHandler, options?: { validator?: (args: Record) => string | null; timeoutMs?: number; } ): void { this.tools.set(name, { name, handler, validator: options?.validator, timeoutMs: options?.timeoutMs ?? 30_000, }); } /** Register multiple tools at once */ registerBatch( tools: Array<{ name: string; handler: ToolHandler; validator?: (args: Record) => string | null; timeoutMs?: number; }> ): void { for (const t of tools) { this.register(t.name, t.handler, { validator: t.validator, timeoutMs: t.timeoutMs, }); } } // ── Execution ───────────────────────────────────────────── /** Execute a tool call with retry logic */ async execute(call: ToolCall): Promise { const tool = this.tools.get(call.name); if (!tool) { return { callId: call.id, success: false, output: null, error: `Unknown tool: ${call.name}`, durationMs: 0, retries: 0, }; } // Validate args if (tool.validator) { const validationError = tool.validator(call.args); if (validationError) { return { callId: call.id, success: false, output: null, error: `Validation error: ${validationError}`, durationMs: 0, retries: 0, }; } } const timeout = tool.timeoutMs; let lastError: string | undefined; const startTime = Date.now(); let retries = 0; for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) { try { const result = await this.executeWithTimeout( () => tool.handler(call), call.timeoutMs || timeout ); return { callId: call.id, success: true, output: result, durationMs: Date.now() - startTime, retries, }; } catch (err) { lastError = err instanceof Error ? err.message : String(err); retries = attempt; if (attempt < this.retryConfig.maxRetries) { const delay = this.calculateBackoff(attempt); await this.sleep(delay); } } } return { callId: call.id, success: false, output: null, error: lastError, durationMs: Date.now() - startTime, retries, }; } /** Execute multiple tool calls (potentially in parallel) */ async executeBatch(calls: ToolCall[]): Promise { return Promise.all(calls.map((call) => this.execute(call))); } // ── Internal ────────────────────────────────────────────── private executeWithTimeout( fn: () => Promise, timeoutMs: number ): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error(`Tool execution timed out after ${timeoutMs}ms`)); }, timeoutMs); fn() .then((result) => { clearTimeout(timer); resolve(result); }) .catch((err) => { clearTimeout(timer); reject(err); }); }); } private calculateBackoff(attempt: number): number { const delay = this.retryConfig.baseDelayMs * Math.pow(this.retryConfig.backoffFactor, attempt); return Math.min(delay, this.retryConfig.maxDelayMs); } private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } // ── Queries ─────────────────────────────────────────────── listTools(): string[] { return [...this.tools.keys()]; } hasTool(name: string): boolean { return this.tools.has(name); } getRetryConfig(): RetryConfig { return { ...this.retryConfig }; } }