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,1040 @@
1
+ /**
2
+ * Tests for agentic tool orchestration
3
+ *
4
+ * These tests cover multi-turn model→tools→model loops for complex AI workflows.
5
+ * Tests are written first (TDD RED phase) - implementation follows.
6
+ */
7
+
8
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
9
+ import { z } from 'zod'
10
+
11
+ // Import types and classes we'll implement
12
+ import {
13
+ AgenticLoop,
14
+ ToolRouter,
15
+ ToolValidator,
16
+ type Tool,
17
+ type ToolResult,
18
+ type LoopOptions,
19
+ type LoopResult,
20
+ type ValidationResult,
21
+ } from '../src/tool-orchestration.js'
22
+
23
+ // Mock model for testing
24
+ const createMockModel = () => ({
25
+ generate: vi.fn(),
26
+ })
27
+
28
+ // Sample tools for testing
29
+ const calculatorTool: Tool = {
30
+ name: 'calculator',
31
+ description: 'Performs basic math operations',
32
+ parameters: z.object({
33
+ operation: z.enum(['add', 'subtract', 'multiply', 'divide']),
34
+ a: z.number(),
35
+ b: z.number(),
36
+ }),
37
+ execute: async ({ operation, a, b }) => {
38
+ switch (operation) {
39
+ case 'add': return a + b
40
+ case 'subtract': return a - b
41
+ case 'multiply': return a * b
42
+ case 'divide': return b !== 0 ? a / b : 'Division by zero'
43
+ }
44
+ },
45
+ }
46
+
47
+ const fetchTool: Tool = {
48
+ name: 'fetch',
49
+ description: 'Fetches data from a URL',
50
+ parameters: z.object({
51
+ url: z.string().url(),
52
+ }),
53
+ execute: async ({ url }) => {
54
+ return { data: `Content from ${url}`, status: 200 }
55
+ },
56
+ }
57
+
58
+ const slowTool: Tool = {
59
+ name: 'slow',
60
+ description: 'A tool that takes time to execute',
61
+ parameters: z.object({
62
+ delay: z.number(),
63
+ }),
64
+ execute: async ({ delay }) => {
65
+ await new Promise(resolve => setTimeout(resolve, delay))
66
+ return 'completed'
67
+ },
68
+ }
69
+
70
+ const failingTool: Tool = {
71
+ name: 'failing',
72
+ description: 'A tool that always fails',
73
+ parameters: z.object({
74
+ message: z.string(),
75
+ }),
76
+ execute: async ({ message }) => {
77
+ throw new Error(`Tool failed: ${message}`)
78
+ },
79
+ }
80
+
81
+ // ============================================================================
82
+ // AgenticLoop Tests - Multi-turn model→tools→model loops
83
+ // ============================================================================
84
+
85
+ describe('AgenticLoop', () => {
86
+ describe('basic loop execution', () => {
87
+ it('should execute a single tool call and return result', async () => {
88
+ const loop = new AgenticLoop({
89
+ tools: [calculatorTool],
90
+ maxSteps: 5,
91
+ })
92
+
93
+ // Mock model response that calls calculator then finishes
94
+ const mockGenerate = vi.fn()
95
+ .mockResolvedValueOnce({
96
+ toolCalls: [{ name: 'calculator', arguments: { operation: 'add', a: 5, b: 3 } }],
97
+ finishReason: 'tool_call',
98
+ })
99
+ .mockResolvedValueOnce({
100
+ text: 'The result of 5 + 3 is 8',
101
+ finishReason: 'stop',
102
+ })
103
+
104
+ const result = await loop.run({
105
+ model: { generate: mockGenerate } as any,
106
+ prompt: 'What is 5 + 3?',
107
+ })
108
+
109
+ expect(result.text).toContain('8')
110
+ expect(result.steps).toBe(2)
111
+ expect(result.toolCalls).toHaveLength(1)
112
+ expect(result.toolCalls[0].name).toBe('calculator')
113
+ expect(result.toolCalls[0].result).toBe(8)
114
+ })
115
+
116
+ it('should handle multiple sequential tool calls', async () => {
117
+ const loop = new AgenticLoop({
118
+ tools: [calculatorTool],
119
+ maxSteps: 10,
120
+ })
121
+
122
+ const mockGenerate = vi.fn()
123
+ .mockResolvedValueOnce({
124
+ toolCalls: [{ name: 'calculator', arguments: { operation: 'add', a: 5, b: 3 } }],
125
+ finishReason: 'tool_call',
126
+ })
127
+ .mockResolvedValueOnce({
128
+ toolCalls: [{ name: 'calculator', arguments: { operation: 'multiply', a: 8, b: 2 } }],
129
+ finishReason: 'tool_call',
130
+ })
131
+ .mockResolvedValueOnce({
132
+ text: '5 + 3 = 8, then 8 * 2 = 16',
133
+ finishReason: 'stop',
134
+ })
135
+
136
+ const result = await loop.run({
137
+ model: { generate: mockGenerate } as any,
138
+ prompt: 'Add 5 and 3, then multiply by 2',
139
+ })
140
+
141
+ expect(result.steps).toBe(3)
142
+ expect(result.toolCalls).toHaveLength(2)
143
+ })
144
+
145
+ it('should preserve conversation state across turns', async () => {
146
+ const loop = new AgenticLoop({
147
+ tools: [calculatorTool],
148
+ maxSteps: 5,
149
+ })
150
+
151
+ const mockGenerate = vi.fn()
152
+ .mockResolvedValueOnce({
153
+ toolCalls: [{ name: 'calculator', arguments: { operation: 'add', a: 10, b: 5 } }],
154
+ finishReason: 'tool_call',
155
+ })
156
+ .mockResolvedValueOnce({
157
+ text: 'Done',
158
+ finishReason: 'stop',
159
+ })
160
+
161
+ const result = await loop.run({
162
+ model: { generate: mockGenerate } as any,
163
+ prompt: 'Calculate 10 + 5',
164
+ })
165
+
166
+ // Verify the second call received the tool result in messages
167
+ const secondCall = mockGenerate.mock.calls[1][0]
168
+ expect(secondCall.messages).toBeDefined()
169
+ expect(secondCall.messages.some((m: any) =>
170
+ m.role === 'tool' && m.content.includes('15')
171
+ )).toBe(true)
172
+ })
173
+ })
174
+
175
+ describe('maxSteps limit enforcement', () => {
176
+ it('should stop at maxSteps limit', async () => {
177
+ const loop = new AgenticLoop({
178
+ tools: [calculatorTool],
179
+ maxSteps: 3,
180
+ })
181
+
182
+ // Model keeps calling tools indefinitely
183
+ const mockGenerate = vi.fn().mockResolvedValue({
184
+ toolCalls: [{ name: 'calculator', arguments: { operation: 'add', a: 1, b: 1 } }],
185
+ finishReason: 'tool_call',
186
+ })
187
+
188
+ const result = await loop.run({
189
+ model: { generate: mockGenerate } as any,
190
+ prompt: 'Keep adding',
191
+ })
192
+
193
+ expect(result.steps).toBe(3)
194
+ expect(result.stopReason).toBe('max_steps')
195
+ })
196
+
197
+ it('should throw error when maxSteps is exceeded in strict mode', async () => {
198
+ const loop = new AgenticLoop({
199
+ tools: [calculatorTool],
200
+ maxSteps: 2,
201
+ strictMaxSteps: true,
202
+ })
203
+
204
+ const mockGenerate = vi.fn().mockResolvedValue({
205
+ toolCalls: [{ name: 'calculator', arguments: { operation: 'add', a: 1, b: 1 } }],
206
+ finishReason: 'tool_call',
207
+ })
208
+
209
+ await expect(loop.run({
210
+ model: { generate: mockGenerate } as any,
211
+ prompt: 'Keep adding',
212
+ })).rejects.toThrow('Max steps exceeded')
213
+ })
214
+ })
215
+
216
+ describe('parallel tool execution', () => {
217
+ it('should execute multiple tool calls in parallel', async () => {
218
+ const timingTool: Tool = {
219
+ name: 'timing',
220
+ description: 'Returns execution order',
221
+ parameters: z.object({ id: z.string() }),
222
+ execute: async ({ id }) => {
223
+ await new Promise(r => setTimeout(r, 10))
224
+ return { id, time: Date.now() }
225
+ },
226
+ }
227
+
228
+ const loop = new AgenticLoop({
229
+ tools: [timingTool],
230
+ maxSteps: 5,
231
+ parallelExecution: true,
232
+ })
233
+
234
+ const mockGenerate = vi.fn()
235
+ .mockResolvedValueOnce({
236
+ toolCalls: [
237
+ { name: 'timing', arguments: { id: 'a' } },
238
+ { name: 'timing', arguments: { id: 'b' } },
239
+ { name: 'timing', arguments: { id: 'c' } },
240
+ ],
241
+ finishReason: 'tool_call',
242
+ })
243
+ .mockResolvedValueOnce({
244
+ text: 'All done',
245
+ finishReason: 'stop',
246
+ })
247
+
248
+ const startTime = Date.now()
249
+ const result = await loop.run({
250
+ model: { generate: mockGenerate } as any,
251
+ prompt: 'Run all',
252
+ })
253
+ const elapsed = Date.now() - startTime
254
+
255
+ expect(result.toolCalls).toHaveLength(3)
256
+ // Parallel execution should be faster than sequential (3 * 10ms)
257
+ expect(elapsed).toBeLessThan(40)
258
+ })
259
+
260
+ it('should respect parallel execution limit', async () => {
261
+ const executionOrder: string[] = []
262
+ const trackingTool: Tool = {
263
+ name: 'track',
264
+ description: 'Tracks execution',
265
+ parameters: z.object({ id: z.string() }),
266
+ execute: async ({ id }) => {
267
+ executionOrder.push(`start:${id}`)
268
+ await new Promise(r => setTimeout(r, 20))
269
+ executionOrder.push(`end:${id}`)
270
+ return id
271
+ },
272
+ }
273
+
274
+ const loop = new AgenticLoop({
275
+ tools: [trackingTool],
276
+ maxSteps: 5,
277
+ parallelExecution: true,
278
+ maxParallelCalls: 2,
279
+ })
280
+
281
+ const mockGenerate = vi.fn()
282
+ .mockResolvedValueOnce({
283
+ toolCalls: [
284
+ { name: 'track', arguments: { id: '1' } },
285
+ { name: 'track', arguments: { id: '2' } },
286
+ { name: 'track', arguments: { id: '3' } },
287
+ { name: 'track', arguments: { id: '4' } },
288
+ ],
289
+ finishReason: 'tool_call',
290
+ })
291
+ .mockResolvedValueOnce({
292
+ text: 'Done',
293
+ finishReason: 'stop',
294
+ })
295
+
296
+ await loop.run({
297
+ model: { generate: mockGenerate } as any,
298
+ prompt: 'Track all',
299
+ })
300
+
301
+ // With maxParallelCalls: 2, at most 2 should start before any ends
302
+ let concurrentStarts = 0
303
+ let maxConcurrent = 0
304
+ for (const event of executionOrder) {
305
+ if (event.startsWith('start:')) concurrentStarts++
306
+ else concurrentStarts--
307
+ maxConcurrent = Math.max(maxConcurrent, concurrentStarts)
308
+ }
309
+ expect(maxConcurrent).toBeLessThanOrEqual(2)
310
+ })
311
+ })
312
+
313
+ describe('abort signal support', () => {
314
+ it('should abort execution when signal is triggered', async () => {
315
+ const loop = new AgenticLoop({
316
+ tools: [slowTool],
317
+ maxSteps: 10,
318
+ })
319
+
320
+ const controller = new AbortController()
321
+ const mockGenerate = vi.fn().mockResolvedValue({
322
+ toolCalls: [{ name: 'slow', arguments: { delay: 1000 } }],
323
+ finishReason: 'tool_call',
324
+ })
325
+
326
+ // Abort after 50ms
327
+ setTimeout(() => controller.abort(), 50)
328
+
329
+ await expect(loop.run({
330
+ model: { generate: mockGenerate } as any,
331
+ prompt: 'Run slow tool',
332
+ abortSignal: controller.signal,
333
+ })).rejects.toThrow('Aborted')
334
+ })
335
+ })
336
+
337
+ describe('onStep callback', () => {
338
+ it('should call onStep for each loop iteration', async () => {
339
+ const steps: any[] = []
340
+ const loop = new AgenticLoop({
341
+ tools: [calculatorTool],
342
+ maxSteps: 5,
343
+ onStep: (step) => { steps.push(step) },
344
+ })
345
+
346
+ const mockGenerate = vi.fn()
347
+ .mockResolvedValueOnce({
348
+ toolCalls: [{ name: 'calculator', arguments: { operation: 'add', a: 1, b: 2 } }],
349
+ finishReason: 'tool_call',
350
+ })
351
+ .mockResolvedValueOnce({
352
+ text: 'Result is 3',
353
+ finishReason: 'stop',
354
+ })
355
+
356
+ await loop.run({
357
+ model: { generate: mockGenerate } as any,
358
+ prompt: 'Add',
359
+ })
360
+
361
+ expect(steps).toHaveLength(2)
362
+ expect(steps[0].stepNumber).toBe(1)
363
+ expect(steps[0].toolCalls).toHaveLength(1)
364
+ expect(steps[1].stepNumber).toBe(2)
365
+ })
366
+ })
367
+ })
368
+
369
+ // ============================================================================
370
+ // ToolRouter Tests - Routing tool calls to handlers
371
+ // ============================================================================
372
+
373
+ describe('ToolRouter', () => {
374
+ describe('tool registration and routing', () => {
375
+ it('should register and route to correct tool', async () => {
376
+ const router = new ToolRouter()
377
+ router.register(calculatorTool)
378
+ router.register(fetchTool)
379
+
380
+ const result = await router.route({
381
+ name: 'calculator',
382
+ arguments: { operation: 'multiply', a: 6, b: 7 },
383
+ })
384
+
385
+ expect(result.success).toBe(true)
386
+ expect(result.result).toBe(42)
387
+ })
388
+
389
+ it('should return error for unknown tool', async () => {
390
+ const router = new ToolRouter()
391
+ router.register(calculatorTool)
392
+
393
+ const result = await router.route({
394
+ name: 'unknown_tool',
395
+ arguments: {},
396
+ })
397
+
398
+ expect(result.success).toBe(false)
399
+ expect(result.error).toContain('not found')
400
+ })
401
+
402
+ it('should route multiple calls in order', async () => {
403
+ const router = new ToolRouter()
404
+ router.register(calculatorTool)
405
+
406
+ const calls = [
407
+ { name: 'calculator', arguments: { operation: 'add', a: 1, b: 2 } },
408
+ { name: 'calculator', arguments: { operation: 'multiply', a: 3, b: 4 } },
409
+ ]
410
+
411
+ const results = await router.routeAll(calls)
412
+
413
+ expect(results).toHaveLength(2)
414
+ expect(results[0].result).toBe(3)
415
+ expect(results[1].result).toBe(12)
416
+ })
417
+ })
418
+
419
+ describe('tool result formatting', () => {
420
+ it('should format tool results for model consumption', async () => {
421
+ const router = new ToolRouter()
422
+ router.register(calculatorTool)
423
+
424
+ const result = await router.route({
425
+ name: 'calculator',
426
+ arguments: { operation: 'add', a: 10, b: 20 },
427
+ })
428
+
429
+ const formatted = router.formatResult(result)
430
+ expect(formatted.role).toBe('tool')
431
+ expect(formatted.content).toContain('30')
432
+ })
433
+
434
+ it('should format error results appropriately', async () => {
435
+ const router = new ToolRouter()
436
+ router.register(failingTool)
437
+
438
+ const result = await router.route({
439
+ name: 'failing',
440
+ arguments: { message: 'test error' },
441
+ })
442
+
443
+ const formatted = router.formatResult(result)
444
+ expect(formatted.role).toBe('tool')
445
+ expect(formatted.content).toContain('error')
446
+ expect(formatted.isError).toBe(true)
447
+ })
448
+ })
449
+
450
+ describe('parallel routing', () => {
451
+ it('should route multiple calls in parallel', async () => {
452
+ const router = new ToolRouter()
453
+ const executionTimes: number[] = []
454
+
455
+ const timingTool: Tool = {
456
+ name: 'time',
457
+ description: 'Records time',
458
+ parameters: z.object({ id: z.number() }),
459
+ execute: async ({ id }) => {
460
+ await new Promise(r => setTimeout(r, 20))
461
+ executionTimes.push(Date.now())
462
+ return id
463
+ },
464
+ }
465
+
466
+ router.register(timingTool)
467
+
468
+ const startTime = Date.now()
469
+ await router.routeAllParallel([
470
+ { name: 'time', arguments: { id: 1 } },
471
+ { name: 'time', arguments: { id: 2 } },
472
+ { name: 'time', arguments: { id: 3 } },
473
+ ])
474
+ const elapsed = Date.now() - startTime
475
+
476
+ // Should complete in ~20ms, not 60ms
477
+ expect(elapsed).toBeLessThan(50)
478
+ })
479
+ })
480
+ })
481
+
482
+ // ============================================================================
483
+ // ToolValidator Tests - Pre-execution validation
484
+ // ============================================================================
485
+
486
+ describe('ToolValidator', () => {
487
+ describe('argument validation', () => {
488
+ it('should validate arguments against tool schema', () => {
489
+ const validator = new ToolValidator()
490
+ validator.register(calculatorTool)
491
+
492
+ const result = validator.validate('calculator', {
493
+ operation: 'add',
494
+ a: 5,
495
+ b: 10,
496
+ })
497
+
498
+ expect(result.valid).toBe(true)
499
+ })
500
+
501
+ it('should reject invalid arguments', () => {
502
+ const validator = new ToolValidator()
503
+ validator.register(calculatorTool)
504
+
505
+ const result = validator.validate('calculator', {
506
+ operation: 'invalid_op',
507
+ a: 'not a number',
508
+ b: 10,
509
+ })
510
+
511
+ expect(result.valid).toBe(false)
512
+ expect(result.errors).toBeDefined()
513
+ expect(result.errors!.length).toBeGreaterThan(0)
514
+ })
515
+
516
+ it('should reject missing required arguments', () => {
517
+ const validator = new ToolValidator()
518
+ validator.register(calculatorTool)
519
+
520
+ const result = validator.validate('calculator', {
521
+ operation: 'add',
522
+ a: 5,
523
+ // missing 'b'
524
+ })
525
+
526
+ expect(result.valid).toBe(false)
527
+ })
528
+
529
+ it('should return error for unknown tool', () => {
530
+ const validator = new ToolValidator()
531
+
532
+ const result = validator.validate('unknown', { foo: 'bar' })
533
+
534
+ expect(result.valid).toBe(false)
535
+ expect(result.errors![0]).toContain('not registered')
536
+ })
537
+ })
538
+
539
+ describe('batch validation', () => {
540
+ it('should validate multiple tool calls at once', () => {
541
+ const validator = new ToolValidator()
542
+ validator.register(calculatorTool)
543
+ validator.register(fetchTool)
544
+
545
+ const results = validator.validateAll([
546
+ { name: 'calculator', arguments: { operation: 'add', a: 1, b: 2 } },
547
+ { name: 'fetch', arguments: { url: 'https://example.com' } },
548
+ { name: 'calculator', arguments: { operation: 'bad', a: 1, b: 2 } },
549
+ ])
550
+
551
+ expect(results).toHaveLength(3)
552
+ expect(results[0].valid).toBe(true)
553
+ expect(results[1].valid).toBe(true)
554
+ expect(results[2].valid).toBe(false)
555
+ })
556
+ })
557
+ })
558
+
559
+ // ============================================================================
560
+ // Tool Error Recovery Tests
561
+ // ============================================================================
562
+
563
+ describe('Tool Error Recovery', () => {
564
+ describe('error handling', () => {
565
+ it('should catch and report tool execution errors', async () => {
566
+ const loop = new AgenticLoop({
567
+ tools: [failingTool, calculatorTool],
568
+ maxSteps: 5,
569
+ })
570
+
571
+ const mockGenerate = vi.fn()
572
+ .mockResolvedValueOnce({
573
+ toolCalls: [{ name: 'failing', arguments: { message: 'test' } }],
574
+ finishReason: 'tool_call',
575
+ })
576
+ .mockResolvedValueOnce({
577
+ text: 'Tool failed, moving on',
578
+ finishReason: 'stop',
579
+ })
580
+
581
+ const result = await loop.run({
582
+ model: { generate: mockGenerate } as any,
583
+ prompt: 'Try the failing tool',
584
+ })
585
+
586
+ expect(result.toolCalls[0].error).toBeDefined()
587
+ expect(result.toolCalls[0].error).toContain('Tool failed')
588
+ })
589
+
590
+ it('should retry failed tool calls when retry is enabled', async () => {
591
+ let attempts = 0
592
+ const flakeyTool: Tool = {
593
+ name: 'flakey',
594
+ description: 'Fails first attempt',
595
+ parameters: z.object({}),
596
+ execute: async () => {
597
+ attempts++
598
+ if (attempts < 2) throw new Error('First attempt fails')
599
+ return 'success'
600
+ },
601
+ }
602
+
603
+ const loop = new AgenticLoop({
604
+ tools: [flakeyTool],
605
+ maxSteps: 5,
606
+ retryFailedTools: true,
607
+ maxToolRetries: 3,
608
+ })
609
+
610
+ const mockGenerate = vi.fn()
611
+ .mockResolvedValueOnce({
612
+ toolCalls: [{ name: 'flakey', arguments: {} }],
613
+ finishReason: 'tool_call',
614
+ })
615
+ .mockResolvedValueOnce({
616
+ text: 'Got success',
617
+ finishReason: 'stop',
618
+ })
619
+
620
+ const result = await loop.run({
621
+ model: { generate: mockGenerate } as any,
622
+ prompt: 'Use flakey tool',
623
+ })
624
+
625
+ expect(result.toolCalls[0].result).toBe('success')
626
+ expect(result.toolCalls[0].retryCount).toBe(1)
627
+ })
628
+ })
629
+
630
+ describe('graceful degradation', () => {
631
+ it('should continue with partial results when tools fail', async () => {
632
+ const loop = new AgenticLoop({
633
+ tools: [calculatorTool, failingTool],
634
+ maxSteps: 5,
635
+ continueOnError: true,
636
+ })
637
+
638
+ const mockGenerate = vi.fn()
639
+ .mockResolvedValueOnce({
640
+ toolCalls: [
641
+ { name: 'calculator', arguments: { operation: 'add', a: 1, b: 2 } },
642
+ { name: 'failing', arguments: { message: 'error' } },
643
+ ],
644
+ finishReason: 'tool_call',
645
+ })
646
+ .mockResolvedValueOnce({
647
+ text: 'Calculator worked, other failed',
648
+ finishReason: 'stop',
649
+ })
650
+
651
+ const result = await loop.run({
652
+ model: { generate: mockGenerate } as any,
653
+ prompt: 'Use both tools',
654
+ })
655
+
656
+ expect(result.toolCalls).toHaveLength(2)
657
+ expect(result.toolCalls[0].result).toBe(3)
658
+ expect(result.toolCalls[1].error).toBeDefined()
659
+ })
660
+ })
661
+
662
+ describe('timeout handling', () => {
663
+ it('should timeout long-running tools', async () => {
664
+ const loop = new AgenticLoop({
665
+ tools: [slowTool],
666
+ maxSteps: 5,
667
+ toolTimeout: 50, // 50ms timeout
668
+ })
669
+
670
+ const mockGenerate = vi.fn()
671
+ .mockResolvedValueOnce({
672
+ toolCalls: [{ name: 'slow', arguments: { delay: 1000 } }],
673
+ finishReason: 'tool_call',
674
+ })
675
+ .mockResolvedValueOnce({
676
+ text: 'Tool timed out',
677
+ finishReason: 'stop',
678
+ })
679
+
680
+ const result = await loop.run({
681
+ model: { generate: mockGenerate } as any,
682
+ prompt: 'Run slow tool',
683
+ })
684
+
685
+ expect(result.toolCalls[0].error).toContain('timeout')
686
+ })
687
+ })
688
+ })
689
+
690
+ // ============================================================================
691
+ // Integration with generateText Tests
692
+ // ============================================================================
693
+
694
+ describe('Integration with generateText', () => {
695
+ it('should work with AI SDK tool format', async () => {
696
+ const loop = new AgenticLoop({
697
+ tools: [calculatorTool],
698
+ maxSteps: 5,
699
+ })
700
+
701
+ // Verify tool conversion to AI SDK format
702
+ const sdkTools = loop.getToolsForSDK()
703
+ expect(sdkTools.calculator).toBeDefined()
704
+ expect(sdkTools.calculator.description).toBe('Performs basic math operations')
705
+ expect(sdkTools.calculator.parameters).toBeDefined()
706
+ })
707
+
708
+ it('should expose tool results through experimental_toolResultContent', async () => {
709
+ const loop = new AgenticLoop({
710
+ tools: [calculatorTool],
711
+ maxSteps: 5,
712
+ })
713
+
714
+ const mockGenerate = vi.fn()
715
+ .mockResolvedValueOnce({
716
+ toolCalls: [{ name: 'calculator', arguments: { operation: 'add', a: 2, b: 3 } }],
717
+ finishReason: 'tool_call',
718
+ })
719
+ .mockResolvedValueOnce({
720
+ text: '5',
721
+ finishReason: 'stop',
722
+ })
723
+
724
+ const result = await loop.run({
725
+ model: { generate: mockGenerate } as any,
726
+ prompt: 'Add 2 + 3',
727
+ })
728
+
729
+ // Verify tool results are exposed in a format compatible with AI SDK
730
+ expect(result.toolResults).toBeDefined()
731
+ expect(result.toolResults[0].toolName).toBe('calculator')
732
+ expect(result.toolResults[0].result).toBe(5)
733
+ })
734
+ })
735
+
736
+ // ============================================================================
737
+ // Token Usage Tracking Tests
738
+ // ============================================================================
739
+
740
+ describe('Token Usage Tracking', () => {
741
+ it('should track token usage across multi-turn conversations', async () => {
742
+ const loop = new AgenticLoop({
743
+ tools: [calculatorTool],
744
+ maxSteps: 5,
745
+ trackUsage: true,
746
+ })
747
+
748
+ const mockGenerate = vi.fn()
749
+ .mockResolvedValueOnce({
750
+ toolCalls: [{ name: 'calculator', arguments: { operation: 'add', a: 1, b: 2 } }],
751
+ finishReason: 'tool_call',
752
+ usage: { promptTokens: 50, completionTokens: 20, totalTokens: 70 },
753
+ })
754
+ .mockResolvedValueOnce({
755
+ text: 'Result is 3',
756
+ finishReason: 'stop',
757
+ usage: { promptTokens: 80, completionTokens: 10, totalTokens: 90 },
758
+ })
759
+
760
+ const result = await loop.run({
761
+ model: { generate: mockGenerate } as any,
762
+ prompt: 'Add 1 + 2',
763
+ })
764
+
765
+ expect(result.usage).toBeDefined()
766
+ expect(result.usage!.promptTokens).toBe(130)
767
+ expect(result.usage!.completionTokens).toBe(30)
768
+ expect(result.usage!.totalTokens).toBe(160)
769
+ })
770
+ })
771
+
772
+ // ============================================================================
773
+ // cachedTool Tests - Tool result caching with cleanup
774
+ // ============================================================================
775
+
776
+ import { cachedTool, type CachedTool } from '../src/tool-orchestration.js'
777
+
778
+ describe('cachedTool', () => {
779
+ describe('basic caching behavior', () => {
780
+ it('should cache tool results', async () => {
781
+ let executionCount = 0
782
+ const countingTool: Tool = {
783
+ name: 'counting',
784
+ description: 'Counts executions',
785
+ parameters: z.object({ key: z.string() }),
786
+ execute: async ({ key }) => {
787
+ executionCount++
788
+ return { key, count: executionCount }
789
+ },
790
+ }
791
+
792
+ const cached = cachedTool(countingTool, { ttl: 1000 })
793
+
794
+ // First call executes the tool
795
+ const result1 = await cached.execute({ key: 'test' })
796
+ expect(result1).toEqual({ key: 'test', count: 1 })
797
+
798
+ // Second call with same key returns cached result
799
+ const result2 = await cached.execute({ key: 'test' })
800
+ expect(result2).toEqual({ key: 'test', count: 1 })
801
+ expect(executionCount).toBe(1) // Only executed once
802
+
803
+ // Different key executes again
804
+ const result3 = await cached.execute({ key: 'other' })
805
+ expect(result3).toEqual({ key: 'other', count: 2 })
806
+ })
807
+
808
+ it('should expire cached entries after TTL', async () => {
809
+ vi.useFakeTimers()
810
+ let executionCount = 0
811
+ const countingTool: Tool = {
812
+ name: 'counting',
813
+ description: 'Counts executions',
814
+ parameters: z.object({ key: z.string() }),
815
+ execute: async ({ key }) => {
816
+ executionCount++
817
+ return { key, count: executionCount }
818
+ },
819
+ }
820
+
821
+ const cached = cachedTool(countingTool, { ttl: 100 })
822
+
823
+ // First call
824
+ await cached.execute({ key: 'test' })
825
+ expect(executionCount).toBe(1)
826
+
827
+ // After TTL expires
828
+ vi.advanceTimersByTime(150)
829
+
830
+ // Should execute again since cache expired
831
+ await cached.execute({ key: 'test' })
832
+ expect(executionCount).toBe(2)
833
+
834
+ vi.useRealTimers()
835
+ })
836
+ })
837
+
838
+ describe('cache cleanup', () => {
839
+ it('should periodically clean up expired entries', async () => {
840
+ vi.useFakeTimers()
841
+ let executionCount = 0
842
+ const countingTool: Tool = {
843
+ name: 'counting',
844
+ description: 'Counts executions',
845
+ parameters: z.object({ key: z.string() }),
846
+ execute: async ({ key }) => {
847
+ executionCount++
848
+ return { key, count: executionCount }
849
+ },
850
+ }
851
+
852
+ const cached = cachedTool(countingTool, {
853
+ ttl: 100,
854
+ cleanupIntervalMs: 50,
855
+ }) as CachedTool
856
+
857
+ // Execute to populate cache
858
+ await cached.execute({ key: 'entry1' })
859
+ await cached.execute({ key: 'entry2' })
860
+
861
+ expect(cached.cacheSize()).toBe(2)
862
+
863
+ // Wait for TTL to expire and cleanup to run
864
+ vi.advanceTimersByTime(150)
865
+
866
+ // Entries should be cleaned up automatically
867
+ expect(cached.cacheSize()).toBe(0)
868
+
869
+ // Cleanup timer
870
+ cached.destroy()
871
+ vi.useRealTimers()
872
+ })
873
+
874
+ it('should stop cleanup timer and clear cache when destroyed', async () => {
875
+ vi.useFakeTimers()
876
+ const countingTool: Tool = {
877
+ name: 'counting',
878
+ description: 'Counts executions',
879
+ parameters: z.object({ key: z.string() }),
880
+ execute: async ({ key }) => ({ key }),
881
+ }
882
+
883
+ const cached = cachedTool(countingTool, {
884
+ ttl: 100,
885
+ cleanupIntervalMs: 50,
886
+ }) as CachedTool
887
+
888
+ await cached.execute({ key: 'entry1' })
889
+ expect(cached.cacheSize()).toBe(1)
890
+
891
+ // Destroy stops cleanup timer and clears cache to prevent memory leaks
892
+ cached.destroy()
893
+
894
+ // Cache should be cleared immediately on destroy
895
+ expect(cached.cacheSize()).toBe(0)
896
+
897
+ // Advancing time should have no effect (timer is stopped)
898
+ vi.advanceTimersByTime(150)
899
+ expect(cached.cacheSize()).toBe(0)
900
+
901
+ vi.useRealTimers()
902
+ })
903
+
904
+ it('should clear all cache entries on clearCache()', async () => {
905
+ const countingTool: Tool = {
906
+ name: 'counting',
907
+ description: 'Counts executions',
908
+ parameters: z.object({ key: z.string() }),
909
+ execute: async ({ key }) => ({ key }),
910
+ }
911
+
912
+ const cached = cachedTool(countingTool, { ttl: 60000 }) as CachedTool
913
+
914
+ await cached.execute({ key: 'entry1' })
915
+ await cached.execute({ key: 'entry2' })
916
+ await cached.execute({ key: 'entry3' })
917
+
918
+ expect(cached.cacheSize()).toBe(3)
919
+
920
+ cached.clearCache()
921
+ expect(cached.cacheSize()).toBe(0)
922
+
923
+ cached.destroy()
924
+ })
925
+ })
926
+
927
+ describe('max cache size (LRU eviction)', () => {
928
+ it('should evict oldest entries when maxSize is reached', async () => {
929
+ vi.useFakeTimers()
930
+ let executionCount = 0
931
+ const countingTool: Tool = {
932
+ name: 'counting',
933
+ description: 'Counts executions',
934
+ parameters: z.object({ key: z.string() }),
935
+ execute: async ({ key }) => {
936
+ executionCount++
937
+ return { key, count: executionCount }
938
+ },
939
+ }
940
+
941
+ const cached = cachedTool(countingTool, {
942
+ ttl: 60000,
943
+ maxSize: 3,
944
+ }) as CachedTool
945
+
946
+ // Fill cache to max
947
+ await cached.execute({ key: 'a' })
948
+ vi.advanceTimersByTime(10)
949
+ await cached.execute({ key: 'b' })
950
+ vi.advanceTimersByTime(10)
951
+ await cached.execute({ key: 'c' })
952
+ vi.advanceTimersByTime(10)
953
+
954
+ expect(cached.cacheSize()).toBe(3)
955
+ expect(executionCount).toBe(3)
956
+
957
+ // Adding 4th entry should evict oldest ('a')
958
+ await cached.execute({ key: 'd' })
959
+ expect(cached.cacheSize()).toBe(3)
960
+
961
+ // Accessing 'a' should re-execute since it was evicted
962
+ await cached.execute({ key: 'a' })
963
+ expect(executionCount).toBe(5) // New execution
964
+
965
+ cached.destroy()
966
+ vi.useRealTimers()
967
+ })
968
+
969
+ it('should update LRU order on cache hit', async () => {
970
+ vi.useFakeTimers()
971
+ let executionCount = 0
972
+ const countingTool: Tool = {
973
+ name: 'counting',
974
+ description: 'Counts executions',
975
+ parameters: z.object({ key: z.string() }),
976
+ execute: async ({ key }) => {
977
+ executionCount++
978
+ return { key, count: executionCount }
979
+ },
980
+ }
981
+
982
+ const cached = cachedTool(countingTool, {
983
+ ttl: 60000,
984
+ maxSize: 3,
985
+ }) as CachedTool
986
+
987
+ // Fill cache: a, b, c (oldest to newest)
988
+ await cached.execute({ key: 'a' })
989
+ vi.advanceTimersByTime(10)
990
+ await cached.execute({ key: 'b' })
991
+ vi.advanceTimersByTime(10)
992
+ await cached.execute({ key: 'c' })
993
+ vi.advanceTimersByTime(10)
994
+
995
+ // Access 'a' to make it recently used
996
+ await cached.execute({ key: 'a' }) // Cache hit
997
+ vi.advanceTimersByTime(10)
998
+ expect(executionCount).toBe(3) // No new execution
999
+
1000
+ // Add 'd' - should evict 'b' (now oldest) not 'a'
1001
+ await cached.execute({ key: 'd' })
1002
+
1003
+ // 'b' was evicted, 'a' and 'c' remain
1004
+ await cached.execute({ key: 'b' }) // Should re-execute
1005
+ expect(executionCount).toBe(5)
1006
+
1007
+ await cached.execute({ key: 'a' }) // Still cached
1008
+ expect(executionCount).toBe(5)
1009
+
1010
+ cached.destroy()
1011
+ vi.useRealTimers()
1012
+ })
1013
+ })
1014
+
1015
+ describe('resource cleanup on tool destruction', () => {
1016
+ it('should clean up timers and memory when destroy is called', async () => {
1017
+ const tool: Tool = {
1018
+ name: 'test',
1019
+ description: 'Test tool',
1020
+ parameters: z.object({ key: z.string() }),
1021
+ execute: async ({ key }) => ({ key }),
1022
+ }
1023
+
1024
+ const cached = cachedTool(tool, {
1025
+ ttl: 1000,
1026
+ cleanupIntervalMs: 100,
1027
+ }) as CachedTool
1028
+
1029
+ await cached.execute({ key: 'test' })
1030
+ expect(cached.cacheSize()).toBe(1)
1031
+
1032
+ cached.destroy()
1033
+ expect(cached.cacheSize()).toBe(0)
1034
+
1035
+ // Should still work after destroy but without caching
1036
+ await cached.execute({ key: 'test2' })
1037
+ expect(cached.cacheSize()).toBe(0)
1038
+ })
1039
+ })
1040
+ })