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.
- package/.turbo/turbo-build.log +1 -4
- package/CHANGELOG.md +68 -1
- package/README.md +397 -157
- package/dist/ai-promise.d.ts +50 -3
- package/dist/ai-promise.d.ts.map +1 -1
- package/dist/ai-promise.js +410 -51
- package/dist/ai-promise.js.map +1 -1
- package/dist/ai-schemas.d.ts +56 -0
- package/dist/ai-schemas.d.ts.map +1 -0
- package/dist/ai-schemas.js +53 -0
- package/dist/ai-schemas.js.map +1 -0
- package/dist/ai.d.ts +16 -242
- package/dist/ai.d.ts.map +1 -1
- package/dist/ai.js +54 -837
- package/dist/ai.js.map +1 -1
- package/dist/batch/anthropic.d.ts +6 -4
- package/dist/batch/anthropic.d.ts.map +1 -1
- package/dist/batch/anthropic.js +83 -145
- package/dist/batch/anthropic.js.map +1 -1
- package/dist/batch/bedrock.d.ts +8 -30
- package/dist/batch/bedrock.d.ts.map +1 -1
- package/dist/batch/bedrock.js +155 -338
- package/dist/batch/bedrock.js.map +1 -1
- package/dist/batch/cloudflare.d.ts +8 -20
- package/dist/batch/cloudflare.d.ts.map +1 -1
- package/dist/batch/cloudflare.js +68 -189
- package/dist/batch/cloudflare.js.map +1 -1
- package/dist/batch/google.d.ts +6 -20
- package/dist/batch/google.d.ts.map +1 -1
- package/dist/batch/google.js +70 -238
- package/dist/batch/google.js.map +1 -1
- package/dist/batch/index.d.ts +4 -1
- package/dist/batch/index.d.ts.map +1 -1
- package/dist/batch/index.js +4 -1
- package/dist/batch/index.js.map +1 -1
- package/dist/batch/memory.d.ts +1 -1
- package/dist/batch/memory.d.ts.map +1 -1
- package/dist/batch/memory.js +14 -10
- package/dist/batch/memory.js.map +1 -1
- package/dist/batch/openai.d.ts +11 -14
- package/dist/batch/openai.d.ts.map +1 -1
- package/dist/batch/openai.js +52 -156
- package/dist/batch/openai.js.map +1 -1
- package/dist/batch/provider.d.ts +111 -0
- package/dist/batch/provider.d.ts.map +1 -0
- package/dist/batch/provider.js +233 -0
- package/dist/batch/provider.js.map +1 -0
- package/dist/batch-map.d.ts.map +1 -1
- package/dist/batch-map.js +23 -17
- package/dist/batch-map.js.map +1 -1
- package/dist/batch-queue.d.ts +65 -0
- package/dist/batch-queue.d.ts.map +1 -1
- package/dist/batch-queue.js +169 -14
- package/dist/batch-queue.js.map +1 -1
- package/dist/budget.d.ts +272 -0
- package/dist/budget.d.ts.map +1 -0
- package/dist/budget.js +513 -0
- package/dist/budget.js.map +1 -0
- package/dist/cache.d.ts +295 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +433 -0
- package/dist/cache.js.map +1 -0
- package/dist/context.d.ts +42 -8
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +64 -62
- package/dist/context.js.map +1 -1
- package/dist/digital-objects-registry.d.ts +229 -0
- package/dist/digital-objects-registry.d.ts.map +1 -0
- package/dist/digital-objects-registry.js +617 -0
- package/dist/digital-objects-registry.js.map +1 -0
- package/dist/embeddings.d.ts +2 -2
- package/dist/embeddings.d.ts.map +1 -1
- package/dist/errors.d.ts +22 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +35 -0
- package/dist/errors.js.map +1 -0
- package/dist/eval/runner.d.ts +10 -1
- package/dist/eval/runner.d.ts.map +1 -1
- package/dist/eval/runner.js +41 -35
- package/dist/eval/runner.js.map +1 -1
- package/dist/eval-log/in-memory.d.ts +34 -0
- package/dist/eval-log/in-memory.d.ts.map +1 -0
- package/dist/eval-log/in-memory.js +84 -0
- package/dist/eval-log/in-memory.js.map +1 -0
- package/dist/eval-log/index.d.ts +29 -0
- package/dist/eval-log/index.d.ts.map +1 -0
- package/dist/eval-log/index.js +39 -0
- package/dist/eval-log/index.js.map +1 -0
- package/dist/eval-log/types.d.ts +101 -0
- package/dist/eval-log/types.d.ts.map +1 -0
- package/dist/eval-log/types.js +16 -0
- package/dist/eval-log/types.js.map +1 -0
- package/dist/function-registry.d.ts +116 -0
- package/dist/function-registry.d.ts.map +1 -0
- package/dist/function-registry.js +546 -0
- package/dist/function-registry.js.map +1 -0
- package/dist/generate.d.ts +9 -3
- package/dist/generate.d.ts.map +1 -1
- package/dist/generate.js +18 -22
- package/dist/generate.js.map +1 -1
- package/dist/index.d.ts +35 -20
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +89 -42
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +118 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +187 -0
- package/dist/logger.js.map +1 -0
- package/dist/middleware/budget.d.ts +84 -0
- package/dist/middleware/budget.d.ts.map +1 -0
- package/dist/middleware/budget.js +110 -0
- package/dist/middleware/budget.js.map +1 -0
- package/dist/middleware/cache.d.ts +103 -0
- package/dist/middleware/cache.d.ts.map +1 -0
- package/dist/middleware/cache.js +228 -0
- package/dist/middleware/cache.js.map +1 -0
- package/dist/middleware/embed-cache.d.ts +99 -0
- package/dist/middleware/embed-cache.d.ts.map +1 -0
- package/dist/middleware/embed-cache.js +128 -0
- package/dist/middleware/embed-cache.js.map +1 -0
- package/dist/middleware/index.d.ts +11 -0
- package/dist/middleware/index.d.ts.map +1 -0
- package/dist/middleware/index.js +11 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/middleware/trace.d.ts +103 -0
- package/dist/middleware/trace.d.ts.map +1 -0
- package/dist/middleware/trace.js +176 -0
- package/dist/middleware/trace.js.map +1 -0
- package/dist/primitives.d.ts +120 -1
- package/dist/primitives.d.ts.map +1 -1
- package/dist/primitives.js +398 -26
- package/dist/primitives.js.map +1 -1
- package/dist/retry.d.ts +368 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +646 -0
- package/dist/retry.js.map +1 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +2 -10
- package/dist/schema.js.map +1 -1
- package/dist/telemetry.d.ts +128 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +285 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/template.d.ts.map +1 -1
- package/dist/template.js +6 -1
- package/dist/template.js.map +1 -1
- package/dist/tool-orchestration.d.ts +453 -0
- package/dist/tool-orchestration.d.ts.map +1 -0
- package/dist/tool-orchestration.js +763 -0
- package/dist/tool-orchestration.js.map +1 -0
- package/dist/type-guards.d.ts +28 -0
- package/dist/type-guards.d.ts.map +1 -0
- package/dist/type-guards.js +29 -0
- package/dist/type-guards.js.map +1 -0
- package/dist/types.d.ts +135 -17
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +36 -1
- package/dist/types.js.map +1 -1
- package/dist/wrap-for-v3.d.ts +80 -0
- package/dist/wrap-for-v3.d.ts.map +1 -0
- package/dist/wrap-for-v3.js +89 -0
- package/dist/wrap-for-v3.js.map +1 -0
- package/examples/00-quickstart.ts +232 -0
- package/examples/01-rag-chatbot.ts +212 -0
- package/examples/02-multi-agent-research.ts +290 -0
- package/examples/03-email-classification.ts +379 -0
- package/examples/04-content-moderation.ts +400 -0
- package/examples/05-document-extraction.ts +455 -0
- package/examples/06-streaming-chat-nextjs.ts +437 -0
- package/examples/07-cloudflare-worker.ts +483 -0
- package/examples/08-batch-processing.ts +491 -0
- package/examples/09-budget-constrained.ts +527 -0
- package/examples/10-tool-orchestration.ts +565 -0
- package/examples/11-retry-resilience.ts +403 -0
- package/examples/12-caching-strategies.ts +422 -0
- package/examples/README.md +145 -0
- package/package.json +10 -6
- package/src/ai-promise.ts +528 -99
- package/src/ai-schemas.ts +122 -0
- package/src/ai.ts +69 -1153
- package/src/batch/anthropic.ts +96 -161
- package/src/batch/bedrock.ts +203 -454
- package/src/batch/cloudflare.ts +99 -282
- package/src/batch/google.ts +91 -297
- package/src/batch/index.ts +4 -1
- package/src/batch/memory.ts +15 -10
- package/src/batch/openai.ts +65 -193
- package/src/batch/provider.ts +336 -0
- package/src/batch-map.ts +29 -24
- package/src/batch-queue.ts +200 -11
- package/src/budget.ts +740 -0
- package/src/cache.ts +681 -0
- package/src/context.ts +122 -76
- package/src/digital-objects-registry.ts +750 -0
- package/src/errors.ts +37 -0
- package/src/eval/runner.ts +63 -38
- package/src/eval-log/in-memory.ts +90 -0
- package/src/eval-log/index.ts +46 -0
- package/src/eval-log/types.ts +110 -0
- package/src/function-registry.ts +671 -0
- package/src/generate.ts +33 -33
- package/src/index.ts +325 -49
- package/src/logger.ts +232 -0
- package/src/middleware/budget.ts +171 -0
- package/src/middleware/cache.ts +299 -0
- package/src/middleware/embed-cache.ts +195 -0
- package/src/middleware/index.ts +23 -0
- package/src/middleware/trace.ts +248 -0
- package/src/primitives.ts +589 -62
- package/src/retry.ts +902 -0
- package/src/schema.ts +8 -17
- package/src/telemetry.ts +403 -0
- package/src/template.ts +8 -4
- package/src/tool-orchestration.ts +1173 -0
- package/src/type-guards.ts +31 -0
- package/src/types.ts +164 -25
- package/src/wrap-for-v3.ts +105 -0
- package/test/ai-promise.test.ts +1080 -0
- package/test/ai-proxy.test.ts +1 -1
- package/test/backward-compat.test.ts +147 -0
- package/test/batch-autosubmit-errors.test.ts +610 -0
- package/test/batch-blog-posts.test.ts +87 -129
- package/test/budget-tracking.test.ts +800 -0
- package/test/cache.test.ts +712 -0
- package/test/context-isolation.test.ts +687 -0
- package/test/core-functions.test.ts +183 -579
- package/test/decide.test.ts +154 -322
- package/test/define.test.ts +211 -8
- package/test/digital-objects-registry.test.ts +760 -0
- package/test/embedding-cache-middleware.test.ts +140 -0
- package/test/evals/deterministic.eval.test.ts +376 -0
- package/test/generate-core.test.ts +140 -229
- package/test/implicit-batch.test.ts +22 -65
- package/test/json-parse-error-handling.test.ts +463 -0
- package/test/retry-policy-integration.test.ts +117 -0
- package/test/retry.test.ts +1016 -0
- package/test/schema.test.ts +55 -19
- package/test/streaming.test.ts +316 -0
- package/test/template.test.ts +1164 -0
- package/test/tool-orchestration.test.ts +1040 -0
- package/test/wrap-for-v3.test.ts +612 -0
- package/vitest.config.js +6 -0
- package/vitest.config.ts +20 -0
- package/dist/rpc/auth.d.ts +0 -69
- package/dist/rpc/auth.d.ts.map +0 -1
- package/dist/rpc/auth.js +0 -136
- package/dist/rpc/auth.js.map +0 -1
- package/dist/rpc/client.d.ts +0 -62
- package/dist/rpc/client.d.ts.map +0 -1
- package/dist/rpc/client.js +0 -103
- package/dist/rpc/client.js.map +0 -1
- package/dist/rpc/deferred.d.ts +0 -60
- package/dist/rpc/deferred.d.ts.map +0 -1
- package/dist/rpc/deferred.js +0 -96
- package/dist/rpc/deferred.js.map +0 -1
- package/dist/rpc/index.d.ts +0 -22
- package/dist/rpc/index.d.ts.map +0 -1
- package/dist/rpc/index.js +0 -38
- package/dist/rpc/index.js.map +0 -1
- package/dist/rpc/local.d.ts +0 -42
- package/dist/rpc/local.d.ts.map +0 -1
- package/dist/rpc/local.js +0 -50
- package/dist/rpc/local.js.map +0 -1
- package/dist/rpc/server.d.ts +0 -165
- package/dist/rpc/server.d.ts.map +0 -1
- package/dist/rpc/server.js +0 -405
- package/dist/rpc/server.js.map +0 -1
- package/dist/rpc/session.d.ts +0 -32
- package/dist/rpc/session.d.ts.map +0 -1
- package/dist/rpc/session.js +0 -43
- package/dist/rpc/session.js.map +0 -1
- package/dist/rpc/transport.d.ts +0 -306
- package/dist/rpc/transport.d.ts.map +0 -1
- package/dist/rpc/transport.js +0 -731
- package/dist/rpc/transport.js.map +0 -1
- package/src/batch/anthropic.js +0 -256
- package/src/batch/bedrock.js +0 -584
- package/src/batch/cloudflare.js +0 -287
- package/src/batch/google.js +0 -359
- package/src/batch/index.js +0 -30
- package/src/batch/memory.js +0 -187
- package/src/batch/openai.js +0 -402
- package/src/eval/index.js +0 -7
- package/src/eval/models.js +0 -119
- package/src/eval/runner.js +0 -147
- 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
|
+
})
|