ai-functions
Version:
Core AI primitives for building intelligent applications
763 lines • 27 kB
JavaScript
/**
* 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