UNPKG

ai-functions

Version:

Core AI primitives for building intelligent applications

1,174 lines (1,049 loc) 32.2 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, type ZodTypeAny } from 'zod' // ============================================================================ // Types // ============================================================================ /** * A tool that can be executed by the agentic loop */ export interface Tool<TParams extends ZodTypeAny = ZodTypeAny, TResult = unknown> { /** Unique name for the tool */ name: string /** Human-readable description */ description: string /** Zod schema for parameters */ parameters: TParams /** Execute the tool with validated parameters */ execute: (params: z.infer<TParams>) => Promise<TResult> } /** * A tool call from the model */ export interface ToolCall { /** Name of the tool to call */ name: string /** Arguments for the tool */ arguments: Record<string, unknown> /** Optional ID for tracking */ id?: string } /** * Result of a tool execution */ export interface ToolResult<T = unknown> { /** Whether execution succeeded */ success: boolean /** The result value if successful */ result?: T /** Error message if failed */ error?: string /** The original tool call */ toolCall?: ToolCall /** Number of retries attempted */ retryCount?: number } /** * Formatted tool result for model consumption */ export interface FormattedToolResult { /** Role is always 'tool' */ role: 'tool' /** String content of the result */ content: string /** Tool call ID for correlation */ tool_call_id?: string /** Whether this is an error result */ isError?: boolean } /** * Validation result for tool arguments */ export interface ValidationResult { /** Whether validation passed */ valid: boolean /** Validation errors if any */ errors?: string[] /** Validated and parsed arguments */ parsedArgs?: unknown } /** * Model response from a generation */ export interface ModelResponse { /** Generated text (if no tool calls) */ text?: string /** Tool calls requested by the model */ toolCalls?: ToolCall[] /** Why generation stopped */ finishReason: 'stop' | 'tool_call' | 'length' | 'content_filter' | 'error' /** Token usage */ usage?: { promptTokens: number completionTokens: number totalTokens: number } } /** * Message in the conversation */ export interface Message { role: 'user' | 'assistant' | 'system' | 'tool' content: string tool_calls?: ToolCall[] tool_call_id?: string isError?: boolean } /** * Step information for callbacks */ export interface StepInfo { /** Step number (1-indexed) */ stepNumber: number /** Tool calls in this step */ toolCalls: Array<ToolCall & { result?: unknown; error?: string }> /** Model response */ response: ModelResponse /** Current messages */ messages: Message[] } /** * Options for creating an AgenticLoop */ export interface LoopOptions { /** Available tools */ tools: Tool[] /** Maximum number of steps before stopping */ maxSteps: number /** Whether to throw error when maxSteps is exceeded */ strictMaxSteps?: boolean /** Whether to execute tool calls in parallel */ parallelExecution?: boolean /** Maximum concurrent tool calls when parallel execution is enabled */ maxParallelCalls?: number /** Whether to retry failed tool calls */ retryFailedTools?: boolean /** Maximum retries per tool call */ maxToolRetries?: number /** Whether to continue when a tool fails */ continueOnError?: boolean /** Timeout for individual tool execution (ms) */ toolTimeout?: number /** Track token usage across steps */ trackUsage?: boolean /** Callback for each step */ onStep?: (step: StepInfo) => void } /** * Model generation options passed to the model */ export interface ModelGenerationOptions { /** Messages for the conversation */ messages: Message[] /** Tools available for use */ tools: Record< string, { description: string; parameters: unknown; execute: (args: unknown) => Promise<unknown> } > } /** * Options for running the loop */ export interface RunOptions { /** Model to use for generation */ model: { generate: (options: ModelGenerationOptions) => Promise<ModelResponse> } /** Initial prompt */ prompt: string /** System message */ system?: string /** Abort signal */ abortSignal?: AbortSignal } /** * Extended tool call result with metadata */ export interface ToolCallResult { /** Tool name */ name: string /** Arguments passed */ arguments: Record<string, unknown> /** Result if successful */ result?: unknown /** Error if failed */ error?: string /** Number of retries */ retryCount?: number } /** * Tool result for SDK compatibility */ export interface SDKToolResult { /** Tool name */ toolName: string /** Tool call ID */ toolCallId?: string /** Result value */ result: unknown } /** * Result of running the agentic loop */ export interface LoopResult { /** Final text output */ text: string /** Number of steps executed */ steps: number /** All tool calls made */ toolCalls: ToolCallResult[] /** Tool results in SDK format */ toolResults: SDKToolResult[] /** Why the loop stopped */ stopReason: 'stop' | 'max_steps' | 'error' | 'aborted' /** Token usage if tracked */ usage?: { promptTokens: number completionTokens: number totalTokens: number } /** Conversation messages */ messages: Message[] } // ============================================================================ // ToolValidator // ============================================================================ /** * Validates tool arguments before execution */ export class ToolValidator { private tools = new Map<string, Tool>() /** * Register a tool for validation */ register(tool: Tool): void { this.tools.set(tool.name, tool) } /** * Validate arguments for a tool */ validate(toolName: string, args: unknown): ValidationResult { 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 as Error).message], } } } /** * Validate multiple tool calls at once */ validateAll(calls: ToolCall[]): ValidationResult[] { 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 { private tools = new Map<string, Tool>() private validator = new ToolValidator() /** * Register a tool */ register(tool: Tool): void { this.tools.set(tool.name, tool) this.validator.register(tool) } /** * Route a single tool call */ async route(call: ToolCall): Promise<ToolResult> { 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 as Error).message, toolCall: call, } } } /** * Route multiple tool calls sequentially */ async routeAll(calls: ToolCall[]): Promise<ToolResult[]> { const results: ToolResult[] = [] for (const call of calls) { results.push(await this.route(call)) } return results } /** * Route multiple tool calls in parallel */ async routeAllParallel(calls: ToolCall[]): Promise<ToolResult[]> { return Promise.all(calls.map((call) => this.route(call))) } /** * Format a tool result for model consumption */ formatResult(result: ToolResult): FormattedToolResult { 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 { private options: LoopOptions private router: ToolRouter private validator: ToolValidator constructor(options: LoopOptions) { 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(): Record< string, { description: string; parameters: unknown; execute: (args: unknown) => Promise<unknown> } > { const tools: Record< string, { description: string; parameters: unknown; execute: (args: unknown) => Promise<unknown> } > = {} 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 */ private async executeToolCall( call: ToolCall, abortSignal?: AbortSignal ): Promise<ToolCallResult> { const { toolTimeout, retryFailedTools, maxToolRetries = 3 } = this.options let lastError: string | undefined 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: ToolResult if (toolTimeout) { let timeoutId: NodeJS.Timeout const timeoutPromise = new Promise<never>((_, 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 as 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 */ private async executeToolCalls( calls: ToolCall[], abortSignal?: AbortSignal ): Promise<ToolCallResult[]> { const { parallelExecution, maxParallelCalls = 10 } = this.options if (!parallelExecution) { // Sequential execution const results: ToolCallResult[] = [] for (const call of calls) { results.push(await this.executeToolCall(call, abortSignal)) } return results } // Parallel execution with concurrency limit const results: ToolCallResult[] = [] const chunks: ToolCall[][] = [] 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 */ private buildMessages( prompt: string, system: string | undefined, conversationMessages: Message[], toolResults: ToolCallResult[] ): Message[] { const messages: Message[] = [] // 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: RunOptions): Promise<LoopResult> { const { model, prompt, system, abortSignal } = runOptions const { maxSteps, strictMaxSteps, continueOnError, trackUsage, onStep } = this.options const allToolCalls: ToolCallResult[] = [] const allToolResults: SDKToolResult[] = [] const messages: Message[] = [{ role: 'user', content: prompt }] let steps = 0 let stopReason: LoopResult['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 as Error).message === 'Aborted') { stopReason = 'aborted' throw error } if ((error as 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: RunOptions): AsyncGenerator<LoopStreamEvent, LoopResult> { const { model, prompt, system, abortSignal } = runOptions const { maxSteps, strictMaxSteps, continueOnError, trackUsage } = this.options const allToolCalls: ToolCallResult[] = [] const allToolResults: SDKToolResult[] = [] const messages: Message[] = [{ role: 'user', content: prompt }] let steps = 0 let stopReason: LoopResult['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 as Error).message === 'Aborted') { stopReason = 'aborted' throw error } if ((error as Error).message === 'Max steps exceeded') { throw error } yield { type: 'error', error: (error as 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, } } } // ============================================================================ // Streaming Types // ============================================================================ /** * Events emitted during streaming loop execution */ export type LoopStreamEvent = | { type: 'start'; prompt: string; timestamp: number } | { type: 'step_start'; stepNumber: number; timestamp: number } | { type: 'step_end'; stepNumber: number; hasToolCalls: boolean; timestamp: number } | { type: 'text'; text: string; stepNumber: number; timestamp: number } | { type: 'tool_calls'; toolCalls: ToolCall[]; stepNumber: number; timestamp: number } | { type: 'tool_result' toolName: string result?: unknown error?: string stepNumber: number timestamp: number } | { type: 'max_steps'; steps: number; timestamp: number } | { type: 'aborted'; steps: number; timestamp: number } | { type: 'error'; error: string; timestamp: number } | { type: 'end'; steps: number; stopReason: LoopResult['stopReason']; timestamp: number } // ============================================================================ // Tool Composition Patterns // ============================================================================ /** * Create a tool from a simple function */ export function createTool<TParams extends z.ZodRawShape, TResult>(config: { name: string description: string parameters: TParams execute: (params: z.infer<z.ZodObject<TParams>>) => Promise<TResult> }): Tool<z.ZodObject<TParams>, TResult> { 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: Tool[]): Tool[] { return tools } /** * Create a tool that wraps another tool with middleware */ export function wrapTool<T extends Tool>( tool: T, middleware: { before?: (params: unknown) => Promise<unknown> | unknown after?: (result: unknown) => Promise<unknown> | unknown onError?: (error: Error) => Promise<unknown> | unknown } ): Tool { return { ...tool, execute: async (params: unknown) => { 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 as Error) } throw error } }, } } /** * Options for cachedTool */ export interface CachedToolOptions { /** Time-to-live in milliseconds (default: 60000) */ ttl?: number /** Function to generate cache key from params (default: JSON.stringify) */ keyFn?: (params: unknown) => string /** Interval in ms for automatic cleanup of expired entries (default: 0 = disabled) */ cleanupIntervalMs?: number /** Maximum cache size before LRU eviction kicks in (default: 0 = unlimited) */ maxSize?: number } /** * Extended tool interface with cache management methods */ export interface CachedTool extends Tool { /** Get the current number of entries in the cache */ cacheSize(): number /** Clear all cache entries */ clearCache(): void /** Stop cleanup timer and clear cache */ destroy(): void } /** * 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<T extends Tool>(tool: T, options: CachedToolOptions = {}): CachedTool { const { ttl = 60000, keyFn = JSON.stringify, cleanupIntervalMs = 0, maxSize = 0 } = options interface CacheEntry { value: unknown expires: number lastAccessed: number } const cache = new Map<string, CacheEntry>() let cleanupTimer: ReturnType<typeof setInterval> | null = 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: string | null = 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: CachedTool = { ...tool, execute: async (params: unknown) => { 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(): number { return cache.size }, clearCache(): void { cache.clear() }, destroy(): void { destroyed = true if (cleanupTimer !== null) { clearInterval(cleanupTimer) cleanupTimer = null } cache.clear() }, } return cachedToolInstance } /** * Create a tool with rate limiting */ export function rateLimitedTool<T extends Tool>( tool: T, options: { maxCalls: number windowMs: number } ): Tool { const calls: number[] = [] const { maxCalls, windowMs } = options return { ...tool, execute: async (params: unknown) => { 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<T extends Tool>(tool: T, timeoutMs: number): Tool { return { ...tool, execute: async (params: unknown) => { let timeoutId: ReturnType<typeof setTimeout> | undefined const timeoutPromise = new Promise<never>((_, 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: Partial<LoopOptions> & { tools: Tool[] }): AgenticLoop { return new AgenticLoop({ maxSteps: 10, parallelExecution: true, maxParallelCalls: 5, continueOnError: true, trackUsage: true, ...options, }) }