fable-agent/src/tier1-foundation/tool-orchestrator.ts

194 lines
5.1 KiB
TypeScript

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<unknown>;
interface ToolRegistration {
name: string;
handler: ToolHandler;
validator?: (args: Record<string, unknown>) => 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<string, ToolRegistration> = new Map();
private retryConfig: RetryConfig;
constructor(retryConfig?: Partial<RetryConfig>) {
this.retryConfig = { ...DEFAULT_RETRY, ...retryConfig };
}
// ── Registration ──────────────────────────────────────────
/** Register a tool with optional validator and timeout */
register(
name: string,
handler: ToolHandler,
options?: {
validator?: (args: Record<string, unknown>) => 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, unknown>) => 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<ToolResult> {
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<ToolResult[]> {
return Promise.all(calls.map((call) => this.execute(call)));
}
// ── Internal ──────────────────────────────────────────────
private executeWithTimeout<T>(
fn: () => Promise<T>,
timeoutMs: number
): Promise<T> {
return new Promise<T>((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<void> {
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 };
}
}