UNPKG

ai-functions

Version:

Core AI primitives for building intelligent applications

763 lines 27 kB
/** * Agentic Tool Orchestration * * Provides multi-turn model→tools→model loop orchestration for complex AI workflows. * * Key components: * - AgenticLoop: Orchestrates multi-turn conversations with tool execution * - ToolRouter: Routes tool calls to registered handlers * - ToolValidator: Validates tool arguments before execution * * @packageDocumentation */ import { z } from 'zod'; // ============================================================================ // ToolValidator // ============================================================================ /** * Validates tool arguments before execution */ export class ToolValidator { tools = new Map(); /** * Register a tool for validation */ register(tool) { this.tools.set(tool.name, tool); } /** * Validate arguments for a tool */ validate(toolName, args) { const tool = this.tools.get(toolName); if (!tool) { return { valid: false, errors: [`Tool '${toolName}' not registered`], }; } try { const parsed = tool.parameters.parse(args); return { valid: true, parsedArgs: parsed, }; } catch (error) { if (error instanceof z.ZodError) { return { valid: false, errors: error.errors.map((e) => `${e.path.join('.')}: ${e.message}`), }; } return { valid: false, errors: [error.message], }; } } /** * Validate multiple tool calls at once */ validateAll(calls) { return calls.map((call) => this.validate(call.name, call.arguments)); } } // ============================================================================ // ToolRouter // ============================================================================ /** * Routes tool calls to registered handlers * * @deprecated Phase C Week 2 — `ToolRouter` has zero production callers in * primitives.org.ai (audited 2026-05-06; see `bd show aip-ibid`). Only the * `ai-primitives` umbrella re-export tests reference it. AI SDK 6's native * tool-routing under `generateText({ tools })` and `Agent` / `ToolLoopAgent` * cover the same surface. Will be removed in the Phase C semver bump * alongside `AgenticLoop` and `createAgenticLoop`. */ export class ToolRouter { tools = new Map(); validator = new ToolValidator(); /** * Register a tool */ register(tool) { this.tools.set(tool.name, tool); this.validator.register(tool); } /** * Route a single tool call */ async route(call) { const tool = this.tools.get(call.name); if (!tool) { return { success: false, error: `Tool '${call.name}' not found`, toolCall: call, }; } const validation = this.validator.validate(call.name, call.arguments); if (!validation.valid) { return { success: false, error: `Validation failed: ${validation.errors?.join(', ')}`, toolCall: call, }; } try { const result = await tool.execute(validation.parsedArgs); return { success: true, result, toolCall: call, }; } catch (error) { return { success: false, error: error.message, toolCall: call, }; } } /** * Route multiple tool calls sequentially */ async routeAll(calls) { const results = []; for (const call of calls) { results.push(await this.route(call)); } return results; } /** * Route multiple tool calls in parallel */ async routeAllParallel(calls) { return Promise.all(calls.map((call) => this.route(call))); } /** * Format a tool result for model consumption */ formatResult(result) { if (result.success) { return { role: 'tool', content: JSON.stringify(result.result), ...(result.toolCall?.id !== undefined && { tool_call_id: result.toolCall.id }), }; } return { role: 'tool', content: JSON.stringify({ error: result.error }), ...(result.toolCall?.id !== undefined && { tool_call_id: result.toolCall.id }), isError: true, }; } } // ============================================================================ // AgenticLoop // ============================================================================ /** * Orchestrates multi-turn model→tools→model loops * * @deprecated Phase C Week 2 — `AgenticLoop` has zero production callers in * primitives.org.ai (audited 2026-05-06; see `bd show aip-ibid`). Only the * `ai-primitives` umbrella re-export tests reference it. The production * cascade walker (`services-as-software/v3/invoke/cascade-walker.ts:178`) * already uses AI SDK 6's `generateText({ tools, maxSteps: 10 })` directly * for agentic steps — no consumer code paths through this class. AI SDK 6's * `Agent` / `ToolLoopAgent` (`stopWhen: stepCountIs(N)`) are the going- * forward primitives. Will be removed in the Phase C semver bump. */ export class AgenticLoop { options; router; validator; constructor(options) { this.options = { strictMaxSteps: false, parallelExecution: false, maxParallelCalls: 10, retryFailedTools: false, maxToolRetries: 3, continueOnError: false, trackUsage: false, ...options, }; this.router = new ToolRouter(); this.validator = new ToolValidator(); // Register all tools for (const tool of options.tools) { this.router.register(tool); this.validator.register(tool); } } /** * Get tools in AI SDK format */ getToolsForSDK() { const tools = {}; for (const tool of this.options.tools) { tools[tool.name] = { description: tool.description, parameters: tool.parameters, execute: tool.execute, }; } return tools; } /** * Execute a tool call with timeout and retry support */ async executeToolCall(call, abortSignal) { const { toolTimeout, retryFailedTools, maxToolRetries = 3 } = this.options; let lastError; let retryCount = 0; const maxAttempts = retryFailedTools ? maxToolRetries : 1; for (let attempt = 0; attempt < maxAttempts; attempt++) { // Check abort signal if (abortSignal?.aborted) { throw new Error('Aborted'); } try { // Create a promise for the tool execution const executePromise = this.router.route(call); // Apply timeout if configured let result; if (toolTimeout) { let timeoutId; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error('Tool execution timeout')), toolTimeout); }); try { result = await Promise.race([executePromise, timeoutPromise]); } finally { clearTimeout(timeoutId); } } else { result = await executePromise; } if (result.success) { return { name: call.name, arguments: call.arguments, result: result.result, retryCount, }; } lastError = result.error; retryCount = attempt + 1; } catch (error) { lastError = error.message; if (lastError === 'Aborted') throw error; retryCount = attempt + 1; } } return { name: call.name, arguments: call.arguments, ...(lastError !== undefined && { error: lastError }), retryCount: retryCount > 0 ? retryCount - 1 : 0, }; } /** * Execute multiple tool calls */ async executeToolCalls(calls, abortSignal) { const { parallelExecution, maxParallelCalls = 10 } = this.options; if (!parallelExecution) { // Sequential execution const results = []; for (const call of calls) { results.push(await this.executeToolCall(call, abortSignal)); } return results; } // Parallel execution with concurrency limit const results = []; const chunks = []; for (let i = 0; i < calls.length; i += maxParallelCalls) { chunks.push(calls.slice(i, i + maxParallelCalls)); } for (const chunk of chunks) { const chunkResults = await Promise.all(chunk.map((call) => this.executeToolCall(call, abortSignal))); results.push(...chunkResults); } return results; } /** * Build messages for the next model call */ buildMessages(prompt, system, conversationMessages, toolResults) { const messages = []; // Add system message if provided if (system) { messages.push({ role: 'system', content: system }); } // Add conversation history messages.push(...conversationMessages); // Add tool results as tool messages for (const result of toolResults) { if (result.error) { messages.push({ role: 'tool', content: JSON.stringify({ error: result.error }), isError: true, }); } else { messages.push({ role: 'tool', content: JSON.stringify(result.result), }); } } return messages; } /** * Run the agentic loop */ async run(runOptions) { const { model, prompt, system, abortSignal } = runOptions; const { maxSteps, strictMaxSteps, continueOnError, trackUsage, onStep } = this.options; const allToolCalls = []; const allToolResults = []; const messages = [{ role: 'user', content: prompt }]; let steps = 0; let stopReason = 'stop'; let finalText = ''; let totalUsage = trackUsage ? { promptTokens: 0, completionTokens: 0, totalTokens: 0 } : undefined; try { while (steps < maxSteps) { // Check abort signal if (abortSignal?.aborted) { stopReason = 'aborted'; throw new Error('Aborted'); } steps++; // Call the model const response = await model.generate({ messages: this.buildMessages(prompt, system, messages.slice(1), []), tools: this.getToolsForSDK(), }); // Track usage if (trackUsage && response.usage) { totalUsage.promptTokens += response.usage.promptTokens; totalUsage.completionTokens += response.usage.completionTokens; totalUsage.totalTokens += response.usage.totalTokens; } // If no tool calls, we're done if (!response.toolCalls || response.toolCalls.length === 0) { finalText = response.text || ''; messages.push({ role: 'assistant', content: finalText }); stopReason = 'stop'; if (onStep) { onStep({ stepNumber: steps, toolCalls: [], response, messages: [...messages], }); } break; } // Execute tool calls const toolResults = await this.executeToolCalls(response.toolCalls, abortSignal); // Check for errors const hasErrors = toolResults.some((r) => r.error); if (hasErrors && !continueOnError) { // Still record the results but note the errors } // Record tool calls and results for (const result of toolResults) { allToolCalls.push(result); allToolResults.push({ toolName: result.name, result: result.result, }); // Add tool result to messages if (result.error) { messages.push({ role: 'tool', content: JSON.stringify({ error: result.error }), isError: true, }); } else { messages.push({ role: 'tool', content: JSON.stringify(result.result), }); } } // Call onStep callback if (onStep) { onStep({ stepNumber: steps, toolCalls: response.toolCalls.map((tc, i) => ({ ...tc, ...(toolResults[i]?.result !== undefined && { result: toolResults[i]?.result }), ...(toolResults[i]?.error !== undefined && { error: toolResults[i]?.error }), })), response, messages: [...messages], }); } // Add assistant message with tool calls messages.push({ role: 'assistant', content: '', tool_calls: response.toolCalls, }); } // Check if we hit max steps if (steps >= maxSteps && stopReason === 'stop') { stopReason = 'max_steps'; if (strictMaxSteps) { throw new Error('Max steps exceeded'); } } } catch (error) { if (error.message === 'Aborted') { stopReason = 'aborted'; throw error; } if (error.message === 'Max steps exceeded') { throw error; } stopReason = 'error'; throw error; } return { text: finalText, steps, toolCalls: allToolCalls, toolResults: allToolResults, stopReason, ...(totalUsage !== undefined && { usage: totalUsage }), messages, }; } /** * Run the agentic loop with streaming support * * Returns an async generator that yields step events as they occur. */ async *stream(runOptions) { const { model, prompt, system, abortSignal } = runOptions; const { maxSteps, strictMaxSteps, continueOnError, trackUsage } = this.options; const allToolCalls = []; const allToolResults = []; const messages = [{ role: 'user', content: prompt }]; let steps = 0; let stopReason = 'stop'; let finalText = ''; let totalUsage = trackUsage ? { promptTokens: 0, completionTokens: 0, totalTokens: 0 } : undefined; yield { type: 'start', prompt, timestamp: Date.now() }; try { while (steps < maxSteps) { if (abortSignal?.aborted) { yield { type: 'aborted', steps, timestamp: Date.now() }; throw new Error('Aborted'); } steps++; yield { type: 'step_start', stepNumber: steps, timestamp: Date.now() }; const response = await model.generate({ messages: this.buildMessages(prompt, system, messages.slice(1), []), tools: this.getToolsForSDK(), }); if (trackUsage && response.usage) { totalUsage.promptTokens += response.usage.promptTokens; totalUsage.completionTokens += response.usage.completionTokens; totalUsage.totalTokens += response.usage.totalTokens; } if (!response.toolCalls || response.toolCalls.length === 0) { finalText = response.text || ''; messages.push({ role: 'assistant', content: finalText }); yield { type: 'text', text: finalText, stepNumber: steps, timestamp: Date.now() }; yield { type: 'step_end', stepNumber: steps, hasToolCalls: false, timestamp: Date.now() }; break; } yield { type: 'tool_calls', toolCalls: response.toolCalls, stepNumber: steps, timestamp: Date.now(), }; const toolResults = await this.executeToolCalls(response.toolCalls, abortSignal); for (const result of toolResults) { allToolCalls.push(result); allToolResults.push({ toolName: result.name, result: result.result }); yield { type: 'tool_result', toolName: result.name, ...(result.result !== undefined && { result: result.result }), ...(result.error !== undefined && { error: result.error }), stepNumber: steps, timestamp: Date.now(), }; if (result.error) { messages.push({ role: 'tool', content: JSON.stringify({ error: result.error }), isError: true, }); } else { messages.push({ role: 'tool', content: JSON.stringify(result.result), }); } } yield { type: 'step_end', stepNumber: steps, hasToolCalls: true, timestamp: Date.now() }; messages.push({ role: 'assistant', content: '', tool_calls: response.toolCalls, }); } if (steps >= maxSteps && stopReason === 'stop') { stopReason = 'max_steps'; yield { type: 'max_steps', steps, timestamp: Date.now() }; if (strictMaxSteps) throw new Error('Max steps exceeded'); } } catch (error) { if (error.message === 'Aborted') { stopReason = 'aborted'; throw error; } if (error.message === 'Max steps exceeded') { throw error; } yield { type: 'error', error: error.message, timestamp: Date.now() }; stopReason = 'error'; throw error; } yield { type: 'end', steps, stopReason, timestamp: Date.now() }; return { text: finalText, steps, toolCalls: allToolCalls, toolResults: allToolResults, stopReason, ...(totalUsage !== undefined && { usage: totalUsage }), messages, }; } } // ============================================================================ // Tool Composition Patterns // ============================================================================ /** * Create a tool from a simple function */ export function createTool(config) { return { name: config.name, description: config.description, parameters: z.object(config.parameters), execute: config.execute, }; } /** * Compose multiple tools into a single toolset */ export function createToolset(...tools) { return tools; } /** * Create a tool that wraps another tool with middleware */ export function wrapTool(tool, middleware) { return { ...tool, execute: async (params) => { try { const modifiedParams = middleware.before ? await middleware.before(params) : params; const result = await tool.execute(modifiedParams); return middleware.after ? await middleware.after(result) : result; } catch (error) { if (middleware.onError) { return middleware.onError(error); } throw error; } }, }; } /** * Create a tool with caching support * * Features: * - TTL-based expiration * - Optional periodic cleanup of expired entries (prevents memory leaks) * - Optional max size with LRU eviction * - Manual cache control (clear, destroy) */ export function cachedTool(tool, options = {}) { const { ttl = 60000, keyFn = JSON.stringify, cleanupIntervalMs = 0, maxSize = 0 } = options; const cache = new Map(); let cleanupTimer = null; let destroyed = false; // Cleanup function to remove expired entries const cleanupExpired = () => { const now = Date.now(); for (const [key, entry] of cache) { if (entry.expires <= now) { cache.delete(key); } } }; // Start periodic cleanup if configured if (cleanupIntervalMs > 0) { cleanupTimer = setInterval(cleanupExpired, cleanupIntervalMs); // Unref the timer so it doesn't keep the process alive (Node.js) if (typeof cleanupTimer === 'object' && 'unref' in cleanupTimer) { cleanupTimer.unref(); } } // Evict oldest entries based on lastAccessed (LRU) const evictOldest = () => { if (maxSize <= 0 || cache.size < maxSize) return; // Find the entry with oldest lastAccessed let oldestKey = null; let oldestTime = Infinity; for (const [key, entry] of cache) { if (entry.lastAccessed < oldestTime) { oldestTime = entry.lastAccessed; oldestKey = key; } } if (oldestKey) { cache.delete(oldestKey); } }; const cachedToolInstance = { ...tool, execute: async (params) => { if (destroyed) { // If destroyed, just execute without caching return tool.execute(params); } const key = keyFn(params); const cached = cache.get(key); const now = Date.now(); if (cached && cached.expires > now) { // Cache hit - update last accessed time for LRU cached.lastAccessed = now; return cached.value; } // Cache miss or expired - remove expired entry if present if (cached) { cache.delete(key); } const result = await tool.execute(params); // Evict oldest if we're at max size if (maxSize > 0 && cache.size >= maxSize) { evictOldest(); } cache.set(key, { value: result, expires: now + ttl, lastAccessed: now, }); return result; }, cacheSize() { return cache.size; }, clearCache() { cache.clear(); }, destroy() { destroyed = true; if (cleanupTimer !== null) { clearInterval(cleanupTimer); cleanupTimer = null; } cache.clear(); }, }; return cachedToolInstance; } /** * Create a tool with rate limiting */ export function rateLimitedTool(tool, options) { const calls = []; const { maxCalls, windowMs } = options; return { ...tool, execute: async (params) => { const now = Date.now(); // Remove expired calls while (calls.length > 0 && calls[0] < now - windowMs) { calls.shift(); } if (calls.length >= maxCalls) { throw new Error(`Rate limit exceeded: max ${maxCalls} calls per ${windowMs}ms`); } calls.push(now); return tool.execute(params); }, }; } /** * Create a tool that times out after a specified duration */ export function timeoutTool(tool, timeoutMs) { return { ...tool, execute: async (params) => { let timeoutId; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error(`Tool '${tool.name}' timed out after ${timeoutMs}ms`)), timeoutMs); }); try { return await Promise.race([tool.execute(params), timeoutPromise]); } finally { if (timeoutId !== undefined) { clearTimeout(timeoutId); } } }, }; } /** * Create an agentic loop with sensible defaults * * @deprecated Phase C Week 2 — `createAgenticLoop` has zero production * callers in primitives.org.ai (only `ai-primitives` umbrella re-export * tests). Use AI SDK 6's `Agent` / `ToolLoopAgent` with * `stopWhen: stepCountIs(N)` instead. Will be removed alongside * `AgenticLoop` in the Phase C semver bump. See `bd show aip-ibid`. */ export function createAgenticLoop(options) { return new AgenticLoop({ maxSteps: 10, parallelExecution: true, maxParallelCalls: 5, continueOnError: true, trackUsage: true, ...options, }); } //# sourceMappingURL=tool-orchestration.js.map