194 lines
5.1 KiB
TypeScript
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 };
|
|
}
|
|
}
|