ai-functions 2.1.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (286) hide show
  1. package/.turbo/turbo-build.log +1 -4
  2. package/CHANGELOG.md +68 -1
  3. package/README.md +397 -157
  4. package/dist/ai-promise.d.ts +50 -3
  5. package/dist/ai-promise.d.ts.map +1 -1
  6. package/dist/ai-promise.js +410 -51
  7. package/dist/ai-promise.js.map +1 -1
  8. package/dist/ai-schemas.d.ts +56 -0
  9. package/dist/ai-schemas.d.ts.map +1 -0
  10. package/dist/ai-schemas.js +53 -0
  11. package/dist/ai-schemas.js.map +1 -0
  12. package/dist/ai.d.ts +16 -242
  13. package/dist/ai.d.ts.map +1 -1
  14. package/dist/ai.js +54 -837
  15. package/dist/ai.js.map +1 -1
  16. package/dist/batch/anthropic.d.ts +6 -4
  17. package/dist/batch/anthropic.d.ts.map +1 -1
  18. package/dist/batch/anthropic.js +83 -145
  19. package/dist/batch/anthropic.js.map +1 -1
  20. package/dist/batch/bedrock.d.ts +8 -30
  21. package/dist/batch/bedrock.d.ts.map +1 -1
  22. package/dist/batch/bedrock.js +155 -338
  23. package/dist/batch/bedrock.js.map +1 -1
  24. package/dist/batch/cloudflare.d.ts +8 -20
  25. package/dist/batch/cloudflare.d.ts.map +1 -1
  26. package/dist/batch/cloudflare.js +68 -189
  27. package/dist/batch/cloudflare.js.map +1 -1
  28. package/dist/batch/google.d.ts +6 -20
  29. package/dist/batch/google.d.ts.map +1 -1
  30. package/dist/batch/google.js +70 -238
  31. package/dist/batch/google.js.map +1 -1
  32. package/dist/batch/index.d.ts +4 -1
  33. package/dist/batch/index.d.ts.map +1 -1
  34. package/dist/batch/index.js +4 -1
  35. package/dist/batch/index.js.map +1 -1
  36. package/dist/batch/memory.d.ts +1 -1
  37. package/dist/batch/memory.d.ts.map +1 -1
  38. package/dist/batch/memory.js +14 -10
  39. package/dist/batch/memory.js.map +1 -1
  40. package/dist/batch/openai.d.ts +11 -14
  41. package/dist/batch/openai.d.ts.map +1 -1
  42. package/dist/batch/openai.js +52 -156
  43. package/dist/batch/openai.js.map +1 -1
  44. package/dist/batch/provider.d.ts +111 -0
  45. package/dist/batch/provider.d.ts.map +1 -0
  46. package/dist/batch/provider.js +233 -0
  47. package/dist/batch/provider.js.map +1 -0
  48. package/dist/batch-map.d.ts.map +1 -1
  49. package/dist/batch-map.js +23 -17
  50. package/dist/batch-map.js.map +1 -1
  51. package/dist/batch-queue.d.ts +65 -0
  52. package/dist/batch-queue.d.ts.map +1 -1
  53. package/dist/batch-queue.js +169 -14
  54. package/dist/batch-queue.js.map +1 -1
  55. package/dist/budget.d.ts +272 -0
  56. package/dist/budget.d.ts.map +1 -0
  57. package/dist/budget.js +513 -0
  58. package/dist/budget.js.map +1 -0
  59. package/dist/cache.d.ts +295 -0
  60. package/dist/cache.d.ts.map +1 -0
  61. package/dist/cache.js +433 -0
  62. package/dist/cache.js.map +1 -0
  63. package/dist/context.d.ts +42 -8
  64. package/dist/context.d.ts.map +1 -1
  65. package/dist/context.js +64 -62
  66. package/dist/context.js.map +1 -1
  67. package/dist/digital-objects-registry.d.ts +229 -0
  68. package/dist/digital-objects-registry.d.ts.map +1 -0
  69. package/dist/digital-objects-registry.js +617 -0
  70. package/dist/digital-objects-registry.js.map +1 -0
  71. package/dist/embeddings.d.ts +2 -2
  72. package/dist/embeddings.d.ts.map +1 -1
  73. package/dist/errors.d.ts +22 -0
  74. package/dist/errors.d.ts.map +1 -0
  75. package/dist/errors.js +35 -0
  76. package/dist/errors.js.map +1 -0
  77. package/dist/eval/runner.d.ts +10 -1
  78. package/dist/eval/runner.d.ts.map +1 -1
  79. package/dist/eval/runner.js +41 -35
  80. package/dist/eval/runner.js.map +1 -1
  81. package/dist/eval-log/in-memory.d.ts +34 -0
  82. package/dist/eval-log/in-memory.d.ts.map +1 -0
  83. package/dist/eval-log/in-memory.js +84 -0
  84. package/dist/eval-log/in-memory.js.map +1 -0
  85. package/dist/eval-log/index.d.ts +29 -0
  86. package/dist/eval-log/index.d.ts.map +1 -0
  87. package/dist/eval-log/index.js +39 -0
  88. package/dist/eval-log/index.js.map +1 -0
  89. package/dist/eval-log/types.d.ts +101 -0
  90. package/dist/eval-log/types.d.ts.map +1 -0
  91. package/dist/eval-log/types.js +16 -0
  92. package/dist/eval-log/types.js.map +1 -0
  93. package/dist/function-registry.d.ts +116 -0
  94. package/dist/function-registry.d.ts.map +1 -0
  95. package/dist/function-registry.js +546 -0
  96. package/dist/function-registry.js.map +1 -0
  97. package/dist/generate.d.ts +9 -3
  98. package/dist/generate.d.ts.map +1 -1
  99. package/dist/generate.js +18 -22
  100. package/dist/generate.js.map +1 -1
  101. package/dist/index.d.ts +35 -20
  102. package/dist/index.d.ts.map +1 -1
  103. package/dist/index.js +89 -42
  104. package/dist/index.js.map +1 -1
  105. package/dist/logger.d.ts +118 -0
  106. package/dist/logger.d.ts.map +1 -0
  107. package/dist/logger.js +187 -0
  108. package/dist/logger.js.map +1 -0
  109. package/dist/middleware/budget.d.ts +84 -0
  110. package/dist/middleware/budget.d.ts.map +1 -0
  111. package/dist/middleware/budget.js +110 -0
  112. package/dist/middleware/budget.js.map +1 -0
  113. package/dist/middleware/cache.d.ts +103 -0
  114. package/dist/middleware/cache.d.ts.map +1 -0
  115. package/dist/middleware/cache.js +228 -0
  116. package/dist/middleware/cache.js.map +1 -0
  117. package/dist/middleware/embed-cache.d.ts +99 -0
  118. package/dist/middleware/embed-cache.d.ts.map +1 -0
  119. package/dist/middleware/embed-cache.js +128 -0
  120. package/dist/middleware/embed-cache.js.map +1 -0
  121. package/dist/middleware/index.d.ts +11 -0
  122. package/dist/middleware/index.d.ts.map +1 -0
  123. package/dist/middleware/index.js +11 -0
  124. package/dist/middleware/index.js.map +1 -0
  125. package/dist/middleware/trace.d.ts +103 -0
  126. package/dist/middleware/trace.d.ts.map +1 -0
  127. package/dist/middleware/trace.js +176 -0
  128. package/dist/middleware/trace.js.map +1 -0
  129. package/dist/primitives.d.ts +120 -1
  130. package/dist/primitives.d.ts.map +1 -1
  131. package/dist/primitives.js +398 -26
  132. package/dist/primitives.js.map +1 -1
  133. package/dist/retry.d.ts +368 -0
  134. package/dist/retry.d.ts.map +1 -0
  135. package/dist/retry.js +646 -0
  136. package/dist/retry.js.map +1 -0
  137. package/dist/schema.d.ts.map +1 -1
  138. package/dist/schema.js +2 -10
  139. package/dist/schema.js.map +1 -1
  140. package/dist/telemetry.d.ts +128 -0
  141. package/dist/telemetry.d.ts.map +1 -0
  142. package/dist/telemetry.js +285 -0
  143. package/dist/telemetry.js.map +1 -0
  144. package/dist/template.d.ts.map +1 -1
  145. package/dist/template.js +6 -1
  146. package/dist/template.js.map +1 -1
  147. package/dist/tool-orchestration.d.ts +453 -0
  148. package/dist/tool-orchestration.d.ts.map +1 -0
  149. package/dist/tool-orchestration.js +763 -0
  150. package/dist/tool-orchestration.js.map +1 -0
  151. package/dist/type-guards.d.ts +28 -0
  152. package/dist/type-guards.d.ts.map +1 -0
  153. package/dist/type-guards.js +29 -0
  154. package/dist/type-guards.js.map +1 -0
  155. package/dist/types.d.ts +135 -17
  156. package/dist/types.d.ts.map +1 -1
  157. package/dist/types.js +36 -1
  158. package/dist/types.js.map +1 -1
  159. package/dist/wrap-for-v3.d.ts +80 -0
  160. package/dist/wrap-for-v3.d.ts.map +1 -0
  161. package/dist/wrap-for-v3.js +89 -0
  162. package/dist/wrap-for-v3.js.map +1 -0
  163. package/examples/00-quickstart.ts +232 -0
  164. package/examples/01-rag-chatbot.ts +212 -0
  165. package/examples/02-multi-agent-research.ts +290 -0
  166. package/examples/03-email-classification.ts +379 -0
  167. package/examples/04-content-moderation.ts +400 -0
  168. package/examples/05-document-extraction.ts +455 -0
  169. package/examples/06-streaming-chat-nextjs.ts +437 -0
  170. package/examples/07-cloudflare-worker.ts +483 -0
  171. package/examples/08-batch-processing.ts +491 -0
  172. package/examples/09-budget-constrained.ts +527 -0
  173. package/examples/10-tool-orchestration.ts +565 -0
  174. package/examples/11-retry-resilience.ts +403 -0
  175. package/examples/12-caching-strategies.ts +422 -0
  176. package/examples/README.md +145 -0
  177. package/package.json +10 -6
  178. package/src/ai-promise.ts +528 -99
  179. package/src/ai-schemas.ts +122 -0
  180. package/src/ai.ts +69 -1153
  181. package/src/batch/anthropic.ts +96 -161
  182. package/src/batch/bedrock.ts +203 -454
  183. package/src/batch/cloudflare.ts +99 -282
  184. package/src/batch/google.ts +91 -297
  185. package/src/batch/index.ts +4 -1
  186. package/src/batch/memory.ts +15 -10
  187. package/src/batch/openai.ts +65 -193
  188. package/src/batch/provider.ts +336 -0
  189. package/src/batch-map.ts +29 -24
  190. package/src/batch-queue.ts +200 -11
  191. package/src/budget.ts +740 -0
  192. package/src/cache.ts +681 -0
  193. package/src/context.ts +122 -76
  194. package/src/digital-objects-registry.ts +750 -0
  195. package/src/errors.ts +37 -0
  196. package/src/eval/runner.ts +63 -38
  197. package/src/eval-log/in-memory.ts +90 -0
  198. package/src/eval-log/index.ts +46 -0
  199. package/src/eval-log/types.ts +110 -0
  200. package/src/function-registry.ts +671 -0
  201. package/src/generate.ts +33 -33
  202. package/src/index.ts +325 -49
  203. package/src/logger.ts +232 -0
  204. package/src/middleware/budget.ts +171 -0
  205. package/src/middleware/cache.ts +299 -0
  206. package/src/middleware/embed-cache.ts +195 -0
  207. package/src/middleware/index.ts +23 -0
  208. package/src/middleware/trace.ts +248 -0
  209. package/src/primitives.ts +589 -62
  210. package/src/retry.ts +902 -0
  211. package/src/schema.ts +8 -17
  212. package/src/telemetry.ts +403 -0
  213. package/src/template.ts +8 -4
  214. package/src/tool-orchestration.ts +1173 -0
  215. package/src/type-guards.ts +31 -0
  216. package/src/types.ts +164 -25
  217. package/src/wrap-for-v3.ts +105 -0
  218. package/test/ai-promise.test.ts +1080 -0
  219. package/test/ai-proxy.test.ts +1 -1
  220. package/test/backward-compat.test.ts +147 -0
  221. package/test/batch-autosubmit-errors.test.ts +610 -0
  222. package/test/batch-blog-posts.test.ts +87 -129
  223. package/test/budget-tracking.test.ts +800 -0
  224. package/test/cache.test.ts +712 -0
  225. package/test/context-isolation.test.ts +687 -0
  226. package/test/core-functions.test.ts +183 -579
  227. package/test/decide.test.ts +154 -322
  228. package/test/define.test.ts +211 -8
  229. package/test/digital-objects-registry.test.ts +760 -0
  230. package/test/embedding-cache-middleware.test.ts +140 -0
  231. package/test/evals/deterministic.eval.test.ts +376 -0
  232. package/test/generate-core.test.ts +140 -229
  233. package/test/implicit-batch.test.ts +22 -65
  234. package/test/json-parse-error-handling.test.ts +463 -0
  235. package/test/retry-policy-integration.test.ts +117 -0
  236. package/test/retry.test.ts +1016 -0
  237. package/test/schema.test.ts +55 -19
  238. package/test/streaming.test.ts +316 -0
  239. package/test/template.test.ts +1164 -0
  240. package/test/tool-orchestration.test.ts +1040 -0
  241. package/test/wrap-for-v3.test.ts +612 -0
  242. package/vitest.config.js +6 -0
  243. package/vitest.config.ts +20 -0
  244. package/dist/rpc/auth.d.ts +0 -69
  245. package/dist/rpc/auth.d.ts.map +0 -1
  246. package/dist/rpc/auth.js +0 -136
  247. package/dist/rpc/auth.js.map +0 -1
  248. package/dist/rpc/client.d.ts +0 -62
  249. package/dist/rpc/client.d.ts.map +0 -1
  250. package/dist/rpc/client.js +0 -103
  251. package/dist/rpc/client.js.map +0 -1
  252. package/dist/rpc/deferred.d.ts +0 -60
  253. package/dist/rpc/deferred.d.ts.map +0 -1
  254. package/dist/rpc/deferred.js +0 -96
  255. package/dist/rpc/deferred.js.map +0 -1
  256. package/dist/rpc/index.d.ts +0 -22
  257. package/dist/rpc/index.d.ts.map +0 -1
  258. package/dist/rpc/index.js +0 -38
  259. package/dist/rpc/index.js.map +0 -1
  260. package/dist/rpc/local.d.ts +0 -42
  261. package/dist/rpc/local.d.ts.map +0 -1
  262. package/dist/rpc/local.js +0 -50
  263. package/dist/rpc/local.js.map +0 -1
  264. package/dist/rpc/server.d.ts +0 -165
  265. package/dist/rpc/server.d.ts.map +0 -1
  266. package/dist/rpc/server.js +0 -405
  267. package/dist/rpc/server.js.map +0 -1
  268. package/dist/rpc/session.d.ts +0 -32
  269. package/dist/rpc/session.d.ts.map +0 -1
  270. package/dist/rpc/session.js +0 -43
  271. package/dist/rpc/session.js.map +0 -1
  272. package/dist/rpc/transport.d.ts +0 -306
  273. package/dist/rpc/transport.d.ts.map +0 -1
  274. package/dist/rpc/transport.js +0 -731
  275. package/dist/rpc/transport.js.map +0 -1
  276. package/src/batch/anthropic.js +0 -256
  277. package/src/batch/bedrock.js +0 -584
  278. package/src/batch/cloudflare.js +0 -287
  279. package/src/batch/google.js +0 -359
  280. package/src/batch/index.js +0 -30
  281. package/src/batch/memory.js +0 -187
  282. package/src/batch/openai.js +0 -402
  283. package/src/eval/index.js +0 -7
  284. package/src/eval/models.js +0 -119
  285. package/src/eval/runner.js +0 -147
  286. package/test/schema.test.js +0 -96
@@ -0,0 +1,1173 @@
1
+ /**
2
+ * Agentic Tool Orchestration
3
+ *
4
+ * Provides multi-turn model→tools→model loop orchestration for complex AI workflows.
5
+ *
6
+ * Key components:
7
+ * - AgenticLoop: Orchestrates multi-turn conversations with tool execution
8
+ * - ToolRouter: Routes tool calls to registered handlers
9
+ * - ToolValidator: Validates tool arguments before execution
10
+ *
11
+ * @packageDocumentation
12
+ */
13
+
14
+ import { z, type ZodTypeAny } from 'zod'
15
+
16
+ // ============================================================================
17
+ // Types
18
+ // ============================================================================
19
+
20
+ /**
21
+ * A tool that can be executed by the agentic loop
22
+ */
23
+ export interface Tool<TParams extends ZodTypeAny = ZodTypeAny, TResult = unknown> {
24
+ /** Unique name for the tool */
25
+ name: string
26
+ /** Human-readable description */
27
+ description: string
28
+ /** Zod schema for parameters */
29
+ parameters: TParams
30
+ /** Execute the tool with validated parameters */
31
+ execute: (params: z.infer<TParams>) => Promise<TResult>
32
+ }
33
+
34
+ /**
35
+ * A tool call from the model
36
+ */
37
+ export interface ToolCall {
38
+ /** Name of the tool to call */
39
+ name: string
40
+ /** Arguments for the tool */
41
+ arguments: Record<string, unknown>
42
+ /** Optional ID for tracking */
43
+ id?: string
44
+ }
45
+
46
+ /**
47
+ * Result of a tool execution
48
+ */
49
+ export interface ToolResult<T = unknown> {
50
+ /** Whether execution succeeded */
51
+ success: boolean
52
+ /** The result value if successful */
53
+ result?: T
54
+ /** Error message if failed */
55
+ error?: string
56
+ /** The original tool call */
57
+ toolCall?: ToolCall
58
+ /** Number of retries attempted */
59
+ retryCount?: number
60
+ }
61
+
62
+ /**
63
+ * Formatted tool result for model consumption
64
+ */
65
+ export interface FormattedToolResult {
66
+ /** Role is always 'tool' */
67
+ role: 'tool'
68
+ /** String content of the result */
69
+ content: string
70
+ /** Tool call ID for correlation */
71
+ tool_call_id?: string
72
+ /** Whether this is an error result */
73
+ isError?: boolean
74
+ }
75
+
76
+ /**
77
+ * Validation result for tool arguments
78
+ */
79
+ export interface ValidationResult {
80
+ /** Whether validation passed */
81
+ valid: boolean
82
+ /** Validation errors if any */
83
+ errors?: string[]
84
+ /** Validated and parsed arguments */
85
+ parsedArgs?: unknown
86
+ }
87
+
88
+ /**
89
+ * Model response from a generation
90
+ */
91
+ export interface ModelResponse {
92
+ /** Generated text (if no tool calls) */
93
+ text?: string
94
+ /** Tool calls requested by the model */
95
+ toolCalls?: ToolCall[]
96
+ /** Why generation stopped */
97
+ finishReason: 'stop' | 'tool_call' | 'length' | 'content_filter' | 'error'
98
+ /** Token usage */
99
+ usage?: {
100
+ promptTokens: number
101
+ completionTokens: number
102
+ totalTokens: number
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Message in the conversation
108
+ */
109
+ export interface Message {
110
+ role: 'user' | 'assistant' | 'system' | 'tool'
111
+ content: string
112
+ tool_calls?: ToolCall[]
113
+ tool_call_id?: string
114
+ isError?: boolean
115
+ }
116
+
117
+ /**
118
+ * Step information for callbacks
119
+ */
120
+ export interface StepInfo {
121
+ /** Step number (1-indexed) */
122
+ stepNumber: number
123
+ /** Tool calls in this step */
124
+ toolCalls: Array<ToolCall & { result?: unknown; error?: string }>
125
+ /** Model response */
126
+ response: ModelResponse
127
+ /** Current messages */
128
+ messages: Message[]
129
+ }
130
+
131
+ /**
132
+ * Options for creating an AgenticLoop
133
+ */
134
+ export interface LoopOptions {
135
+ /** Available tools */
136
+ tools: Tool[]
137
+ /** Maximum number of steps before stopping */
138
+ maxSteps: number
139
+ /** Whether to throw error when maxSteps is exceeded */
140
+ strictMaxSteps?: boolean
141
+ /** Whether to execute tool calls in parallel */
142
+ parallelExecution?: boolean
143
+ /** Maximum concurrent tool calls when parallel execution is enabled */
144
+ maxParallelCalls?: number
145
+ /** Whether to retry failed tool calls */
146
+ retryFailedTools?: boolean
147
+ /** Maximum retries per tool call */
148
+ maxToolRetries?: number
149
+ /** Whether to continue when a tool fails */
150
+ continueOnError?: boolean
151
+ /** Timeout for individual tool execution (ms) */
152
+ toolTimeout?: number
153
+ /** Track token usage across steps */
154
+ trackUsage?: boolean
155
+ /** Callback for each step */
156
+ onStep?: (step: StepInfo) => void
157
+ }
158
+
159
+ /**
160
+ * Model generation options passed to the model
161
+ */
162
+ export interface ModelGenerationOptions {
163
+ /** Messages for the conversation */
164
+ messages: Message[]
165
+ /** Tools available for use */
166
+ tools: Record<
167
+ string,
168
+ { description: string; parameters: unknown; execute: (args: unknown) => Promise<unknown> }
169
+ >
170
+ }
171
+
172
+ /**
173
+ * Options for running the loop
174
+ */
175
+ export interface RunOptions {
176
+ /** Model to use for generation */
177
+ model: {
178
+ generate: (options: ModelGenerationOptions) => Promise<ModelResponse>
179
+ }
180
+ /** Initial prompt */
181
+ prompt: string
182
+ /** System message */
183
+ system?: string
184
+ /** Abort signal */
185
+ abortSignal?: AbortSignal
186
+ }
187
+
188
+ /**
189
+ * Extended tool call result with metadata
190
+ */
191
+ export interface ToolCallResult {
192
+ /** Tool name */
193
+ name: string
194
+ /** Arguments passed */
195
+ arguments: Record<string, unknown>
196
+ /** Result if successful */
197
+ result?: unknown
198
+ /** Error if failed */
199
+ error?: string
200
+ /** Number of retries */
201
+ retryCount?: number
202
+ }
203
+
204
+ /**
205
+ * Tool result for SDK compatibility
206
+ */
207
+ export interface SDKToolResult {
208
+ /** Tool name */
209
+ toolName: string
210
+ /** Tool call ID */
211
+ toolCallId?: string
212
+ /** Result value */
213
+ result: unknown
214
+ }
215
+
216
+ /**
217
+ * Result of running the agentic loop
218
+ */
219
+ export interface LoopResult {
220
+ /** Final text output */
221
+ text: string
222
+ /** Number of steps executed */
223
+ steps: number
224
+ /** All tool calls made */
225
+ toolCalls: ToolCallResult[]
226
+ /** Tool results in SDK format */
227
+ toolResults: SDKToolResult[]
228
+ /** Why the loop stopped */
229
+ stopReason: 'stop' | 'max_steps' | 'error' | 'aborted'
230
+ /** Token usage if tracked */
231
+ usage?: {
232
+ promptTokens: number
233
+ completionTokens: number
234
+ totalTokens: number
235
+ }
236
+ /** Conversation messages */
237
+ messages: Message[]
238
+ }
239
+
240
+ // ============================================================================
241
+ // ToolValidator
242
+ // ============================================================================
243
+
244
+ /**
245
+ * Validates tool arguments before execution
246
+ */
247
+ export class ToolValidator {
248
+ private tools = new Map<string, Tool>()
249
+
250
+ /**
251
+ * Register a tool for validation
252
+ */
253
+ register(tool: Tool): void {
254
+ this.tools.set(tool.name, tool)
255
+ }
256
+
257
+ /**
258
+ * Validate arguments for a tool
259
+ */
260
+ validate(toolName: string, args: unknown): ValidationResult {
261
+ const tool = this.tools.get(toolName)
262
+ if (!tool) {
263
+ return {
264
+ valid: false,
265
+ errors: [`Tool '${toolName}' not registered`],
266
+ }
267
+ }
268
+
269
+ try {
270
+ const parsed = tool.parameters.parse(args)
271
+ return {
272
+ valid: true,
273
+ parsedArgs: parsed,
274
+ }
275
+ } catch (error) {
276
+ if (error instanceof z.ZodError) {
277
+ return {
278
+ valid: false,
279
+ errors: error.errors.map((e) => `${e.path.join('.')}: ${e.message}`),
280
+ }
281
+ }
282
+ return {
283
+ valid: false,
284
+ errors: [(error as Error).message],
285
+ }
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Validate multiple tool calls at once
291
+ */
292
+ validateAll(calls: ToolCall[]): ValidationResult[] {
293
+ return calls.map((call) => this.validate(call.name, call.arguments))
294
+ }
295
+ }
296
+
297
+ // ============================================================================
298
+ // ToolRouter
299
+ // ============================================================================
300
+
301
+ /**
302
+ * Routes tool calls to registered handlers
303
+ *
304
+ * @deprecated Phase C Week 2 — `ToolRouter` has zero production callers in
305
+ * primitives.org.ai (audited 2026-05-06; see `bd show aip-ibid`). Only the
306
+ * `ai-primitives` umbrella re-export tests reference it. AI SDK 6's native
307
+ * tool-routing under `generateText({ tools })` and `Agent` / `ToolLoopAgent`
308
+ * cover the same surface. Will be removed in the Phase C semver bump
309
+ * alongside `AgenticLoop` and `createAgenticLoop`.
310
+ */
311
+ export class ToolRouter {
312
+ private tools = new Map<string, Tool>()
313
+ private validator = new ToolValidator()
314
+
315
+ /**
316
+ * Register a tool
317
+ */
318
+ register(tool: Tool): void {
319
+ this.tools.set(tool.name, tool)
320
+ this.validator.register(tool)
321
+ }
322
+
323
+ /**
324
+ * Route a single tool call
325
+ */
326
+ async route(call: ToolCall): Promise<ToolResult> {
327
+ const tool = this.tools.get(call.name)
328
+ if (!tool) {
329
+ return {
330
+ success: false,
331
+ error: `Tool '${call.name}' not found`,
332
+ toolCall: call,
333
+ }
334
+ }
335
+
336
+ const validation = this.validator.validate(call.name, call.arguments)
337
+ if (!validation.valid) {
338
+ return {
339
+ success: false,
340
+ error: `Validation failed: ${validation.errors?.join(', ')}`,
341
+ toolCall: call,
342
+ }
343
+ }
344
+
345
+ try {
346
+ const result = await tool.execute(validation.parsedArgs)
347
+ return {
348
+ success: true,
349
+ result,
350
+ toolCall: call,
351
+ }
352
+ } catch (error) {
353
+ return {
354
+ success: false,
355
+ error: (error as Error).message,
356
+ toolCall: call,
357
+ }
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Route multiple tool calls sequentially
363
+ */
364
+ async routeAll(calls: ToolCall[]): Promise<ToolResult[]> {
365
+ const results: ToolResult[] = []
366
+ for (const call of calls) {
367
+ results.push(await this.route(call))
368
+ }
369
+ return results
370
+ }
371
+
372
+ /**
373
+ * Route multiple tool calls in parallel
374
+ */
375
+ async routeAllParallel(calls: ToolCall[]): Promise<ToolResult[]> {
376
+ return Promise.all(calls.map((call) => this.route(call)))
377
+ }
378
+
379
+ /**
380
+ * Format a tool result for model consumption
381
+ */
382
+ formatResult(result: ToolResult): FormattedToolResult {
383
+ if (result.success) {
384
+ return {
385
+ role: 'tool',
386
+ content: JSON.stringify(result.result),
387
+ ...(result.toolCall?.id !== undefined && { tool_call_id: result.toolCall.id }),
388
+ }
389
+ }
390
+ return {
391
+ role: 'tool',
392
+ content: JSON.stringify({ error: result.error }),
393
+ ...(result.toolCall?.id !== undefined && { tool_call_id: result.toolCall.id }),
394
+ isError: true,
395
+ }
396
+ }
397
+ }
398
+
399
+ // ============================================================================
400
+ // AgenticLoop
401
+ // ============================================================================
402
+
403
+ /**
404
+ * Orchestrates multi-turn model→tools→model loops
405
+ *
406
+ * @deprecated Phase C Week 2 — `AgenticLoop` has zero production callers in
407
+ * primitives.org.ai (audited 2026-05-06; see `bd show aip-ibid`). Only the
408
+ * `ai-primitives` umbrella re-export tests reference it. The production
409
+ * cascade walker (`services-as-software/v3/invoke/cascade-walker.ts:178`)
410
+ * already uses AI SDK 6's `generateText({ tools, maxSteps: 10 })` directly
411
+ * for agentic steps — no consumer code paths through this class. AI SDK 6's
412
+ * `Agent` / `ToolLoopAgent` (`stopWhen: stepCountIs(N)`) are the going-
413
+ * forward primitives. Will be removed in the Phase C semver bump.
414
+ */
415
+ export class AgenticLoop {
416
+ private options: LoopOptions
417
+ private router: ToolRouter
418
+ private validator: ToolValidator
419
+
420
+ constructor(options: LoopOptions) {
421
+ this.options = {
422
+ strictMaxSteps: false,
423
+ parallelExecution: false,
424
+ maxParallelCalls: 10,
425
+ retryFailedTools: false,
426
+ maxToolRetries: 3,
427
+ continueOnError: false,
428
+ trackUsage: false,
429
+ ...options,
430
+ }
431
+ this.router = new ToolRouter()
432
+ this.validator = new ToolValidator()
433
+
434
+ // Register all tools
435
+ for (const tool of options.tools) {
436
+ this.router.register(tool)
437
+ this.validator.register(tool)
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Get tools in AI SDK format
443
+ */
444
+ getToolsForSDK(): Record<
445
+ string,
446
+ { description: string; parameters: unknown; execute: (args: unknown) => Promise<unknown> }
447
+ > {
448
+ const tools: Record<
449
+ string,
450
+ { description: string; parameters: unknown; execute: (args: unknown) => Promise<unknown> }
451
+ > = {}
452
+ for (const tool of this.options.tools) {
453
+ tools[tool.name] = {
454
+ description: tool.description,
455
+ parameters: tool.parameters,
456
+ execute: tool.execute,
457
+ }
458
+ }
459
+ return tools
460
+ }
461
+
462
+ /**
463
+ * Execute a tool call with timeout and retry support
464
+ */
465
+ private async executeToolCall(
466
+ call: ToolCall,
467
+ abortSignal?: AbortSignal
468
+ ): Promise<ToolCallResult> {
469
+ const { toolTimeout, retryFailedTools, maxToolRetries = 3 } = this.options
470
+ let lastError: string | undefined
471
+ let retryCount = 0
472
+
473
+ const maxAttempts = retryFailedTools ? maxToolRetries : 1
474
+
475
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
476
+ // Check abort signal
477
+ if (abortSignal?.aborted) {
478
+ throw new Error('Aborted')
479
+ }
480
+
481
+ try {
482
+ // Create a promise for the tool execution
483
+ const executePromise = this.router.route(call)
484
+
485
+ // Apply timeout if configured
486
+ let result: ToolResult
487
+ if (toolTimeout) {
488
+ let timeoutId: NodeJS.Timeout
489
+ const timeoutPromise = new Promise<never>((_, reject) => {
490
+ timeoutId = setTimeout(() => reject(new Error('Tool execution timeout')), toolTimeout)
491
+ })
492
+ try {
493
+ result = await Promise.race([executePromise, timeoutPromise])
494
+ } finally {
495
+ clearTimeout(timeoutId!)
496
+ }
497
+ } else {
498
+ result = await executePromise
499
+ }
500
+
501
+ if (result.success) {
502
+ return {
503
+ name: call.name,
504
+ arguments: call.arguments,
505
+ result: result.result,
506
+ retryCount,
507
+ }
508
+ }
509
+
510
+ lastError = result.error
511
+ retryCount = attempt + 1
512
+ } catch (error) {
513
+ lastError = (error as Error).message
514
+ if (lastError === 'Aborted') throw error
515
+ retryCount = attempt + 1
516
+ }
517
+ }
518
+
519
+ return {
520
+ name: call.name,
521
+ arguments: call.arguments,
522
+ ...(lastError !== undefined && { error: lastError }),
523
+ retryCount: retryCount > 0 ? retryCount - 1 : 0,
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Execute multiple tool calls
529
+ */
530
+ private async executeToolCalls(
531
+ calls: ToolCall[],
532
+ abortSignal?: AbortSignal
533
+ ): Promise<ToolCallResult[]> {
534
+ const { parallelExecution, maxParallelCalls = 10 } = this.options
535
+
536
+ if (!parallelExecution) {
537
+ // Sequential execution
538
+ const results: ToolCallResult[] = []
539
+ for (const call of calls) {
540
+ results.push(await this.executeToolCall(call, abortSignal))
541
+ }
542
+ return results
543
+ }
544
+
545
+ // Parallel execution with concurrency limit
546
+ const results: ToolCallResult[] = []
547
+ const chunks: ToolCall[][] = []
548
+
549
+ for (let i = 0; i < calls.length; i += maxParallelCalls) {
550
+ chunks.push(calls.slice(i, i + maxParallelCalls))
551
+ }
552
+
553
+ for (const chunk of chunks) {
554
+ const chunkResults = await Promise.all(
555
+ chunk.map((call) => this.executeToolCall(call, abortSignal))
556
+ )
557
+ results.push(...chunkResults)
558
+ }
559
+
560
+ return results
561
+ }
562
+
563
+ /**
564
+ * Build messages for the next model call
565
+ */
566
+ private buildMessages(
567
+ prompt: string,
568
+ system: string | undefined,
569
+ conversationMessages: Message[],
570
+ toolResults: ToolCallResult[]
571
+ ): Message[] {
572
+ const messages: Message[] = []
573
+
574
+ // Add system message if provided
575
+ if (system) {
576
+ messages.push({ role: 'system', content: system })
577
+ }
578
+
579
+ // Add conversation history
580
+ messages.push(...conversationMessages)
581
+
582
+ // Add tool results as tool messages
583
+ for (const result of toolResults) {
584
+ if (result.error) {
585
+ messages.push({
586
+ role: 'tool',
587
+ content: JSON.stringify({ error: result.error }),
588
+ isError: true,
589
+ })
590
+ } else {
591
+ messages.push({
592
+ role: 'tool',
593
+ content: JSON.stringify(result.result),
594
+ })
595
+ }
596
+ }
597
+
598
+ return messages
599
+ }
600
+
601
+ /**
602
+ * Run the agentic loop
603
+ */
604
+ async run(runOptions: RunOptions): Promise<LoopResult> {
605
+ const { model, prompt, system, abortSignal } = runOptions
606
+ const { maxSteps, strictMaxSteps, continueOnError, trackUsage, onStep } = this.options
607
+
608
+ const allToolCalls: ToolCallResult[] = []
609
+ const allToolResults: SDKToolResult[] = []
610
+ const messages: Message[] = [{ role: 'user', content: prompt }]
611
+ let steps = 0
612
+ let stopReason: LoopResult['stopReason'] = 'stop'
613
+ let finalText = ''
614
+ let totalUsage = trackUsage
615
+ ? { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
616
+ : undefined
617
+
618
+ try {
619
+ while (steps < maxSteps) {
620
+ // Check abort signal
621
+ if (abortSignal?.aborted) {
622
+ stopReason = 'aborted'
623
+ throw new Error('Aborted')
624
+ }
625
+
626
+ steps++
627
+
628
+ // Call the model
629
+ const response = await model.generate({
630
+ messages: this.buildMessages(prompt, system, messages.slice(1), []),
631
+ tools: this.getToolsForSDK(),
632
+ })
633
+
634
+ // Track usage
635
+ if (trackUsage && response.usage) {
636
+ totalUsage!.promptTokens += response.usage.promptTokens
637
+ totalUsage!.completionTokens += response.usage.completionTokens
638
+ totalUsage!.totalTokens += response.usage.totalTokens
639
+ }
640
+
641
+ // If no tool calls, we're done
642
+ if (!response.toolCalls || response.toolCalls.length === 0) {
643
+ finalText = response.text || ''
644
+ messages.push({ role: 'assistant', content: finalText })
645
+ stopReason = 'stop'
646
+
647
+ if (onStep) {
648
+ onStep({
649
+ stepNumber: steps,
650
+ toolCalls: [],
651
+ response,
652
+ messages: [...messages],
653
+ })
654
+ }
655
+ break
656
+ }
657
+
658
+ // Execute tool calls
659
+ const toolResults = await this.executeToolCalls(response.toolCalls, abortSignal)
660
+
661
+ // Check for errors
662
+ const hasErrors = toolResults.some((r) => r.error)
663
+ if (hasErrors && !continueOnError) {
664
+ // Still record the results but note the errors
665
+ }
666
+
667
+ // Record tool calls and results
668
+ for (const result of toolResults) {
669
+ allToolCalls.push(result)
670
+ allToolResults.push({
671
+ toolName: result.name,
672
+ result: result.result,
673
+ })
674
+
675
+ // Add tool result to messages
676
+ if (result.error) {
677
+ messages.push({
678
+ role: 'tool',
679
+ content: JSON.stringify({ error: result.error }),
680
+ isError: true,
681
+ })
682
+ } else {
683
+ messages.push({
684
+ role: 'tool',
685
+ content: JSON.stringify(result.result),
686
+ })
687
+ }
688
+ }
689
+
690
+ // Call onStep callback
691
+ if (onStep) {
692
+ onStep({
693
+ stepNumber: steps,
694
+ toolCalls: response.toolCalls.map((tc, i) => ({
695
+ ...tc,
696
+ ...(toolResults[i]?.result !== undefined && { result: toolResults[i]?.result }),
697
+ ...(toolResults[i]?.error !== undefined && { error: toolResults[i]?.error }),
698
+ })),
699
+ response,
700
+ messages: [...messages],
701
+ })
702
+ }
703
+
704
+ // Add assistant message with tool calls
705
+ messages.push({
706
+ role: 'assistant',
707
+ content: '',
708
+ tool_calls: response.toolCalls,
709
+ })
710
+ }
711
+
712
+ // Check if we hit max steps
713
+ if (steps >= maxSteps && stopReason === 'stop') {
714
+ stopReason = 'max_steps'
715
+ if (strictMaxSteps) {
716
+ throw new Error('Max steps exceeded')
717
+ }
718
+ }
719
+ } catch (error) {
720
+ if ((error as Error).message === 'Aborted') {
721
+ stopReason = 'aborted'
722
+ throw error
723
+ }
724
+ if ((error as Error).message === 'Max steps exceeded') {
725
+ throw error
726
+ }
727
+ stopReason = 'error'
728
+ throw error
729
+ }
730
+
731
+ return {
732
+ text: finalText,
733
+ steps,
734
+ toolCalls: allToolCalls,
735
+ toolResults: allToolResults,
736
+ stopReason,
737
+ ...(totalUsage !== undefined && { usage: totalUsage }),
738
+ messages,
739
+ }
740
+ }
741
+
742
+ /**
743
+ * Run the agentic loop with streaming support
744
+ *
745
+ * Returns an async generator that yields step events as they occur.
746
+ */
747
+ async *stream(runOptions: RunOptions): AsyncGenerator<LoopStreamEvent, LoopResult> {
748
+ const { model, prompt, system, abortSignal } = runOptions
749
+ const { maxSteps, strictMaxSteps, continueOnError, trackUsage } = this.options
750
+
751
+ const allToolCalls: ToolCallResult[] = []
752
+ const allToolResults: SDKToolResult[] = []
753
+ const messages: Message[] = [{ role: 'user', content: prompt }]
754
+ let steps = 0
755
+ let stopReason: LoopResult['stopReason'] = 'stop'
756
+ let finalText = ''
757
+ let totalUsage = trackUsage
758
+ ? { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
759
+ : undefined
760
+
761
+ yield { type: 'start', prompt, timestamp: Date.now() }
762
+
763
+ try {
764
+ while (steps < maxSteps) {
765
+ if (abortSignal?.aborted) {
766
+ yield { type: 'aborted', steps, timestamp: Date.now() }
767
+ throw new Error('Aborted')
768
+ }
769
+
770
+ steps++
771
+ yield { type: 'step_start', stepNumber: steps, timestamp: Date.now() }
772
+
773
+ const response = await model.generate({
774
+ messages: this.buildMessages(prompt, system, messages.slice(1), []),
775
+ tools: this.getToolsForSDK(),
776
+ })
777
+
778
+ if (trackUsage && response.usage) {
779
+ totalUsage!.promptTokens += response.usage.promptTokens
780
+ totalUsage!.completionTokens += response.usage.completionTokens
781
+ totalUsage!.totalTokens += response.usage.totalTokens
782
+ }
783
+
784
+ if (!response.toolCalls || response.toolCalls.length === 0) {
785
+ finalText = response.text || ''
786
+ messages.push({ role: 'assistant', content: finalText })
787
+ yield { type: 'text', text: finalText, stepNumber: steps, timestamp: Date.now() }
788
+ yield { type: 'step_end', stepNumber: steps, hasToolCalls: false, timestamp: Date.now() }
789
+ break
790
+ }
791
+
792
+ yield {
793
+ type: 'tool_calls',
794
+ toolCalls: response.toolCalls,
795
+ stepNumber: steps,
796
+ timestamp: Date.now(),
797
+ }
798
+
799
+ const toolResults = await this.executeToolCalls(response.toolCalls, abortSignal)
800
+
801
+ for (const result of toolResults) {
802
+ allToolCalls.push(result)
803
+ allToolResults.push({ toolName: result.name, result: result.result })
804
+
805
+ yield {
806
+ type: 'tool_result',
807
+ toolName: result.name,
808
+ ...(result.result !== undefined && { result: result.result }),
809
+ ...(result.error !== undefined && { error: result.error }),
810
+ stepNumber: steps,
811
+ timestamp: Date.now(),
812
+ }
813
+
814
+ if (result.error) {
815
+ messages.push({
816
+ role: 'tool',
817
+ content: JSON.stringify({ error: result.error }),
818
+ isError: true,
819
+ })
820
+ } else {
821
+ messages.push({
822
+ role: 'tool',
823
+ content: JSON.stringify(result.result),
824
+ })
825
+ }
826
+ }
827
+
828
+ yield { type: 'step_end', stepNumber: steps, hasToolCalls: true, timestamp: Date.now() }
829
+
830
+ messages.push({
831
+ role: 'assistant',
832
+ content: '',
833
+ tool_calls: response.toolCalls,
834
+ })
835
+ }
836
+
837
+ if (steps >= maxSteps && stopReason === 'stop') {
838
+ stopReason = 'max_steps'
839
+ yield { type: 'max_steps', steps, timestamp: Date.now() }
840
+ if (strictMaxSteps) throw new Error('Max steps exceeded')
841
+ }
842
+ } catch (error) {
843
+ if ((error as Error).message === 'Aborted') {
844
+ stopReason = 'aborted'
845
+ throw error
846
+ }
847
+ if ((error as Error).message === 'Max steps exceeded') {
848
+ throw error
849
+ }
850
+ yield { type: 'error', error: (error as Error).message, timestamp: Date.now() }
851
+ stopReason = 'error'
852
+ throw error
853
+ }
854
+
855
+ yield { type: 'end', steps, stopReason, timestamp: Date.now() }
856
+
857
+ return {
858
+ text: finalText,
859
+ steps,
860
+ toolCalls: allToolCalls,
861
+ toolResults: allToolResults,
862
+ stopReason,
863
+ ...(totalUsage !== undefined && { usage: totalUsage }),
864
+ messages,
865
+ }
866
+ }
867
+ }
868
+
869
+ // ============================================================================
870
+ // Streaming Types
871
+ // ============================================================================
872
+
873
+ /**
874
+ * Events emitted during streaming loop execution
875
+ */
876
+ export type LoopStreamEvent =
877
+ | { type: 'start'; prompt: string; timestamp: number }
878
+ | { type: 'step_start'; stepNumber: number; timestamp: number }
879
+ | { type: 'step_end'; stepNumber: number; hasToolCalls: boolean; timestamp: number }
880
+ | { type: 'text'; text: string; stepNumber: number; timestamp: number }
881
+ | { type: 'tool_calls'; toolCalls: ToolCall[]; stepNumber: number; timestamp: number }
882
+ | {
883
+ type: 'tool_result'
884
+ toolName: string
885
+ result?: unknown
886
+ error?: string
887
+ stepNumber: number
888
+ timestamp: number
889
+ }
890
+ | { type: 'max_steps'; steps: number; timestamp: number }
891
+ | { type: 'aborted'; steps: number; timestamp: number }
892
+ | { type: 'error'; error: string; timestamp: number }
893
+ | { type: 'end'; steps: number; stopReason: LoopResult['stopReason']; timestamp: number }
894
+
895
+ // ============================================================================
896
+ // Tool Composition Patterns
897
+ // ============================================================================
898
+
899
+ /**
900
+ * Create a tool from a simple function
901
+ */
902
+ export function createTool<TParams extends z.ZodRawShape, TResult>(config: {
903
+ name: string
904
+ description: string
905
+ parameters: TParams
906
+ execute: (params: z.infer<z.ZodObject<TParams>>) => Promise<TResult>
907
+ }): Tool<z.ZodObject<TParams>, TResult> {
908
+ return {
909
+ name: config.name,
910
+ description: config.description,
911
+ parameters: z.object(config.parameters),
912
+ execute: config.execute,
913
+ }
914
+ }
915
+
916
+ /**
917
+ * Compose multiple tools into a single toolset
918
+ */
919
+ export function createToolset(...tools: Tool[]): Tool[] {
920
+ return tools
921
+ }
922
+
923
+ /**
924
+ * Create a tool that wraps another tool with middleware
925
+ */
926
+ export function wrapTool<T extends Tool>(
927
+ tool: T,
928
+ middleware: {
929
+ before?: (params: unknown) => Promise<unknown> | unknown
930
+ after?: (result: unknown) => Promise<unknown> | unknown
931
+ onError?: (error: Error) => Promise<unknown> | unknown
932
+ }
933
+ ): Tool {
934
+ return {
935
+ ...tool,
936
+ execute: async (params: unknown) => {
937
+ try {
938
+ const modifiedParams = middleware.before ? await middleware.before(params) : params
939
+ const result = await tool.execute(modifiedParams)
940
+ return middleware.after ? await middleware.after(result) : result
941
+ } catch (error) {
942
+ if (middleware.onError) {
943
+ return middleware.onError(error as Error)
944
+ }
945
+ throw error
946
+ }
947
+ },
948
+ }
949
+ }
950
+
951
+ /**
952
+ * Options for cachedTool
953
+ */
954
+ export interface CachedToolOptions {
955
+ /** Time-to-live in milliseconds (default: 60000) */
956
+ ttl?: number
957
+ /** Function to generate cache key from params (default: JSON.stringify) */
958
+ keyFn?: (params: unknown) => string
959
+ /** Interval in ms for automatic cleanup of expired entries (default: 0 = disabled) */
960
+ cleanupIntervalMs?: number
961
+ /** Maximum cache size before LRU eviction kicks in (default: 0 = unlimited) */
962
+ maxSize?: number
963
+ }
964
+
965
+ /**
966
+ * Extended tool interface with cache management methods
967
+ */
968
+ export interface CachedTool extends Tool {
969
+ /** Get the current number of entries in the cache */
970
+ cacheSize(): number
971
+ /** Clear all cache entries */
972
+ clearCache(): void
973
+ /** Stop cleanup timer and clear cache */
974
+ destroy(): void
975
+ }
976
+
977
+ /**
978
+ * Create a tool with caching support
979
+ *
980
+ * Features:
981
+ * - TTL-based expiration
982
+ * - Optional periodic cleanup of expired entries (prevents memory leaks)
983
+ * - Optional max size with LRU eviction
984
+ * - Manual cache control (clear, destroy)
985
+ */
986
+ export function cachedTool<T extends Tool>(tool: T, options: CachedToolOptions = {}): CachedTool {
987
+ const { ttl = 60000, keyFn = JSON.stringify, cleanupIntervalMs = 0, maxSize = 0 } = options
988
+
989
+ interface CacheEntry {
990
+ value: unknown
991
+ expires: number
992
+ lastAccessed: number
993
+ }
994
+
995
+ const cache = new Map<string, CacheEntry>()
996
+ let cleanupTimer: ReturnType<typeof setInterval> | null = null
997
+ let destroyed = false
998
+
999
+ // Cleanup function to remove expired entries
1000
+ const cleanupExpired = () => {
1001
+ const now = Date.now()
1002
+ for (const [key, entry] of cache) {
1003
+ if (entry.expires <= now) {
1004
+ cache.delete(key)
1005
+ }
1006
+ }
1007
+ }
1008
+
1009
+ // Start periodic cleanup if configured
1010
+ if (cleanupIntervalMs > 0) {
1011
+ cleanupTimer = setInterval(cleanupExpired, cleanupIntervalMs)
1012
+ // Unref the timer so it doesn't keep the process alive (Node.js)
1013
+ if (typeof cleanupTimer === 'object' && 'unref' in cleanupTimer) {
1014
+ cleanupTimer.unref()
1015
+ }
1016
+ }
1017
+
1018
+ // Evict oldest entries based on lastAccessed (LRU)
1019
+ const evictOldest = () => {
1020
+ if (maxSize <= 0 || cache.size < maxSize) return
1021
+
1022
+ // Find the entry with oldest lastAccessed
1023
+ let oldestKey: string | null = null
1024
+ let oldestTime = Infinity
1025
+
1026
+ for (const [key, entry] of cache) {
1027
+ if (entry.lastAccessed < oldestTime) {
1028
+ oldestTime = entry.lastAccessed
1029
+ oldestKey = key
1030
+ }
1031
+ }
1032
+
1033
+ if (oldestKey) {
1034
+ cache.delete(oldestKey)
1035
+ }
1036
+ }
1037
+
1038
+ const cachedToolInstance: CachedTool = {
1039
+ ...tool,
1040
+ execute: async (params: unknown) => {
1041
+ if (destroyed) {
1042
+ // If destroyed, just execute without caching
1043
+ return tool.execute(params)
1044
+ }
1045
+
1046
+ const key = keyFn(params)
1047
+ const cached = cache.get(key)
1048
+ const now = Date.now()
1049
+
1050
+ if (cached && cached.expires > now) {
1051
+ // Cache hit - update last accessed time for LRU
1052
+ cached.lastAccessed = now
1053
+ return cached.value
1054
+ }
1055
+
1056
+ // Cache miss or expired - remove expired entry if present
1057
+ if (cached) {
1058
+ cache.delete(key)
1059
+ }
1060
+
1061
+ const result = await tool.execute(params)
1062
+
1063
+ // Evict oldest if we're at max size
1064
+ if (maxSize > 0 && cache.size >= maxSize) {
1065
+ evictOldest()
1066
+ }
1067
+
1068
+ cache.set(key, {
1069
+ value: result,
1070
+ expires: now + ttl,
1071
+ lastAccessed: now,
1072
+ })
1073
+
1074
+ return result
1075
+ },
1076
+
1077
+ cacheSize(): number {
1078
+ return cache.size
1079
+ },
1080
+
1081
+ clearCache(): void {
1082
+ cache.clear()
1083
+ },
1084
+
1085
+ destroy(): void {
1086
+ destroyed = true
1087
+ if (cleanupTimer !== null) {
1088
+ clearInterval(cleanupTimer)
1089
+ cleanupTimer = null
1090
+ }
1091
+ cache.clear()
1092
+ },
1093
+ }
1094
+
1095
+ return cachedToolInstance
1096
+ }
1097
+
1098
+ /**
1099
+ * Create a tool with rate limiting
1100
+ */
1101
+ export function rateLimitedTool<T extends Tool>(
1102
+ tool: T,
1103
+ options: {
1104
+ maxCalls: number
1105
+ windowMs: number
1106
+ }
1107
+ ): Tool {
1108
+ const calls: number[] = []
1109
+ const { maxCalls, windowMs } = options
1110
+
1111
+ return {
1112
+ ...tool,
1113
+ execute: async (params: unknown) => {
1114
+ const now = Date.now()
1115
+ // Remove expired calls
1116
+ while (calls.length > 0 && calls[0]! < now - windowMs) {
1117
+ calls.shift()
1118
+ }
1119
+
1120
+ if (calls.length >= maxCalls) {
1121
+ throw new Error(`Rate limit exceeded: max ${maxCalls} calls per ${windowMs}ms`)
1122
+ }
1123
+
1124
+ calls.push(now)
1125
+ return tool.execute(params)
1126
+ },
1127
+ }
1128
+ }
1129
+
1130
+ /**
1131
+ * Create a tool that times out after a specified duration
1132
+ */
1133
+ export function timeoutTool<T extends Tool>(tool: T, timeoutMs: number): Tool {
1134
+ return {
1135
+ ...tool,
1136
+ execute: async (params: unknown) => {
1137
+ let timeoutId: ReturnType<typeof setTimeout> | undefined
1138
+ const timeoutPromise = new Promise<never>((_, reject) => {
1139
+ timeoutId = setTimeout(
1140
+ () => reject(new Error(`Tool '${tool.name}' timed out after ${timeoutMs}ms`)),
1141
+ timeoutMs
1142
+ )
1143
+ })
1144
+ try {
1145
+ return await Promise.race([tool.execute(params), timeoutPromise])
1146
+ } finally {
1147
+ if (timeoutId !== undefined) {
1148
+ clearTimeout(timeoutId)
1149
+ }
1150
+ }
1151
+ },
1152
+ }
1153
+ }
1154
+
1155
+ /**
1156
+ * Create an agentic loop with sensible defaults
1157
+ *
1158
+ * @deprecated Phase C Week 2 — `createAgenticLoop` has zero production
1159
+ * callers in primitives.org.ai (only `ai-primitives` umbrella re-export
1160
+ * tests). Use AI SDK 6's `Agent` / `ToolLoopAgent` with
1161
+ * `stopWhen: stepCountIs(N)` instead. Will be removed alongside
1162
+ * `AgenticLoop` in the Phase C semver bump. See `bd show aip-ibid`.
1163
+ */
1164
+ export function createAgenticLoop(options: Partial<LoopOptions> & { tools: Tool[] }): AgenticLoop {
1165
+ return new AgenticLoop({
1166
+ maxSteps: 10,
1167
+ parallelExecution: true,
1168
+ maxParallelCalls: 5,
1169
+ continueOnError: true,
1170
+ trackUsage: true,
1171
+ ...options,
1172
+ })
1173
+ }