@mastra/memory 0.12.2 → 0.12.3-alpha.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 +2 -2
- package/CHANGELOG.md +26 -0
- package/dist/index.cjs +5 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/integration-tests-v5/.env.test +5 -0
- package/integration-tests-v5/CHANGELOG.md +25 -0
- package/integration-tests-v5/docker-compose.yml +39 -0
- package/integration-tests-v5/node_modules/.bin/next +21 -0
- package/integration-tests-v5/node_modules/.bin/tsc +21 -0
- package/integration-tests-v5/node_modules/.bin/tsserver +21 -0
- package/integration-tests-v5/node_modules/.bin/vitest +21 -0
- package/integration-tests-v5/package.json +43 -0
- package/integration-tests-v5/src/agent-memory.test.ts +621 -0
- package/integration-tests-v5/src/mastra/agents/weather.ts +75 -0
- package/integration-tests-v5/src/mastra/index.ts +13 -0
- package/integration-tests-v5/src/mastra/tools/weather.ts +24 -0
- package/integration-tests-v5/src/processors.test.ts +600 -0
- package/integration-tests-v5/src/streaming-memory.test.ts +367 -0
- package/integration-tests-v5/src/test-utils.ts +146 -0
- package/integration-tests-v5/src/working-memory.test.ts +1064 -0
- package/integration-tests-v5/tsconfig.json +13 -0
- package/integration-tests-v5/vitest.config.ts +18 -0
- package/package.json +4 -3
- package/src/index.ts +4 -2
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Mastra } from '@mastra/core';
|
|
2
|
+
import { LibSQLStore } from '@mastra/libsql';
|
|
3
|
+
import { memoryProcessorAgent, weatherAgent } from './agents/weather';
|
|
4
|
+
|
|
5
|
+
export const mastra = new Mastra({
|
|
6
|
+
agents: {
|
|
7
|
+
test: weatherAgent,
|
|
8
|
+
testProcessor: memoryProcessorAgent,
|
|
9
|
+
},
|
|
10
|
+
storage: new LibSQLStore({
|
|
11
|
+
url: 'file:mastra.db',
|
|
12
|
+
}),
|
|
13
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createTool } from '@mastra/core/tools';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
export const weatherTool = createTool({
|
|
5
|
+
id: 'get_weather',
|
|
6
|
+
description: 'Get the weather for a given location',
|
|
7
|
+
inputSchema: z.object({
|
|
8
|
+
postalCode: z.string().describe('The location to get the weather for'),
|
|
9
|
+
}),
|
|
10
|
+
execute: async ({ context: { postalCode } }) => {
|
|
11
|
+
return `The weather in ${postalCode} is sunny. It is currently 70 degrees and feels like 65 degrees.`;
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const weatherToolCity = createTool({
|
|
16
|
+
id: 'get_weather_city',
|
|
17
|
+
description: 'Get the weather for a given location',
|
|
18
|
+
inputSchema: z.object({
|
|
19
|
+
city: z.string().describe('The location to get the weather for'),
|
|
20
|
+
}),
|
|
21
|
+
execute: async ({ context: { city } }) => {
|
|
22
|
+
return `The weather in ${city} is sunny. It is currently 70 degrees and feels like 65 degrees.`;
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
import { mkdtemp } from 'fs/promises';
|
|
2
|
+
import { afterEach } from 'node:test';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { openai } from '@ai-sdk/openai';
|
|
6
|
+
import type { CoreMessage, MemoryProcessorOpts } from '@mastra/core';
|
|
7
|
+
import { MemoryProcessor } from '@mastra/core';
|
|
8
|
+
import type { MastraMessageV2 } from '@mastra/core/agent';
|
|
9
|
+
import { Agent, MessageList } from '@mastra/core/agent';
|
|
10
|
+
import { createTool } from '@mastra/core/tools';
|
|
11
|
+
import { fastembed } from '@mastra/fastembed';
|
|
12
|
+
import { LibSQLVector, LibSQLStore } from '@mastra/libsql';
|
|
13
|
+
import { Memory } from '@mastra/memory';
|
|
14
|
+
import { TokenLimiter, ToolCallFilter } from '@mastra/memory/processors';
|
|
15
|
+
import type { UIMessage } from 'ai';
|
|
16
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
import { filterToolCallsByName, filterToolResultsByName, generateConversationHistory } from './test-utils';
|
|
19
|
+
|
|
20
|
+
function v2ToCoreMessages(messages: MastraMessageV2[] | UIMessage[]): CoreMessage[] {
|
|
21
|
+
return new MessageList().add(messages, 'memory').get.all.core();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let memory: Memory;
|
|
25
|
+
let storage: LibSQLStore;
|
|
26
|
+
let vector: LibSQLVector;
|
|
27
|
+
const resourceId = 'processor-test';
|
|
28
|
+
|
|
29
|
+
beforeEach(async () => {
|
|
30
|
+
// Create a new unique database file in the temp directory for each test
|
|
31
|
+
const dbPath = join(await mkdtemp(join(tmpdir(), `memory-processor-test-`)), 'test.db');
|
|
32
|
+
|
|
33
|
+
storage = new LibSQLStore({
|
|
34
|
+
url: `file:${dbPath}`,
|
|
35
|
+
});
|
|
36
|
+
vector = new LibSQLVector({
|
|
37
|
+
connectionUrl: `file:${dbPath}`,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Initialize memory with the in-memory database
|
|
41
|
+
memory = new Memory({
|
|
42
|
+
storage,
|
|
43
|
+
options: {
|
|
44
|
+
lastMessages: 10,
|
|
45
|
+
semanticRecall: false,
|
|
46
|
+
threads: {
|
|
47
|
+
generateTitle: false,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(async () => {
|
|
54
|
+
//@ts-ignore
|
|
55
|
+
await storage.client.close();
|
|
56
|
+
//@ts-ignore
|
|
57
|
+
await vector.turso.close();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('Memory with Processors', () => {
|
|
61
|
+
it('should apply TokenLimiter when retrieving messages', async () => {
|
|
62
|
+
// Create a thread
|
|
63
|
+
const thread = await memory.createThread({
|
|
64
|
+
title: 'TokenLimiter Test Thread',
|
|
65
|
+
resourceId,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Generate conversation with 10 turn pairs (20 messages total)
|
|
69
|
+
const { messagesV2 } = generateConversationHistory({
|
|
70
|
+
threadId: thread.id,
|
|
71
|
+
resourceId,
|
|
72
|
+
messageCount: 10,
|
|
73
|
+
toolFrequency: 3,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Save messages
|
|
77
|
+
await memory.saveMessages({ messages: messagesV2, format: 'v2' });
|
|
78
|
+
|
|
79
|
+
// Get messages with a token limit of 250 (should get ~2.5 messages)
|
|
80
|
+
const queryResult = await memory.query({
|
|
81
|
+
threadId: thread.id,
|
|
82
|
+
selectBy: { last: 20 },
|
|
83
|
+
});
|
|
84
|
+
const result = memory.processMessages({
|
|
85
|
+
messages: new MessageList({ threadId: thread.id, resourceId })
|
|
86
|
+
.add(queryResult.uiMessages, 'memory')
|
|
87
|
+
.get.all.core(),
|
|
88
|
+
processors: [new TokenLimiter(250)], // Limit to 250 tokens
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// We should have messages limited by token count
|
|
92
|
+
expect(result.length).toBeGreaterThan(0);
|
|
93
|
+
expect(result.length).toBeLessThanOrEqual(4); // Should get a small subset of messages
|
|
94
|
+
|
|
95
|
+
expect(result.at(-1)).toEqual({
|
|
96
|
+
role: 'tool',
|
|
97
|
+
content: [
|
|
98
|
+
{
|
|
99
|
+
type: 'tool-result',
|
|
100
|
+
toolCallId: 'tool-9',
|
|
101
|
+
toolName: 'weather',
|
|
102
|
+
result: 'Pretty hot',
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Now query with a very high token limit that should return all messages
|
|
108
|
+
const allMessagesQuery = await memory.query({
|
|
109
|
+
threadId: thread.id,
|
|
110
|
+
selectBy: { last: 20 },
|
|
111
|
+
});
|
|
112
|
+
expect(allMessagesQuery.messages.length).toBe(20);
|
|
113
|
+
|
|
114
|
+
const allMessagesResult = memory.processMessages({
|
|
115
|
+
messages: new MessageList({ threadId: thread.id, resourceId })
|
|
116
|
+
.add(allMessagesQuery.uiMessages, 'memory')
|
|
117
|
+
.get.all.core(),
|
|
118
|
+
processors: [new TokenLimiter(3000)], // High limit that should exceed total tokens
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// create response message list to add to memory
|
|
122
|
+
const messages = new MessageList({ threadId: thread.id, resourceId })
|
|
123
|
+
.add(allMessagesResult, 'response')
|
|
124
|
+
.get.all.v2();
|
|
125
|
+
|
|
126
|
+
const listed = new MessageList({ threadId: thread.id, resourceId }).add(messages, 'memory').get.all.v2();
|
|
127
|
+
|
|
128
|
+
// We should get all 20 messages
|
|
129
|
+
expect(listed.length).toBe(20);
|
|
130
|
+
// core messages store tool call/result as separate messages, so +3
|
|
131
|
+
expect(allMessagesResult.length).toBe(23);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should apply ToolCallFilter when retrieving messages', async () => {
|
|
135
|
+
// Create a thread
|
|
136
|
+
const thread = await memory.createThread({
|
|
137
|
+
title: 'ToolFilter Test Thread',
|
|
138
|
+
resourceId,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Generate conversation with tool calls
|
|
142
|
+
const { messagesV2 } = generateConversationHistory({
|
|
143
|
+
threadId: thread.id,
|
|
144
|
+
resourceId,
|
|
145
|
+
messageCount: 5,
|
|
146
|
+
toolFrequency: 2, // Every other assistant response is a tool call
|
|
147
|
+
toolNames: ['weather', 'calculator'],
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Save messages
|
|
151
|
+
await memory.saveMessages({ messages: messagesV2, format: 'v2' });
|
|
152
|
+
|
|
153
|
+
// filter weather tool calls
|
|
154
|
+
const queryResult = await memory.query({
|
|
155
|
+
threadId: thread.id,
|
|
156
|
+
selectBy: { last: 20 },
|
|
157
|
+
});
|
|
158
|
+
const result = memory.processMessages({
|
|
159
|
+
messages: v2ToCoreMessages(queryResult.uiMessages),
|
|
160
|
+
processors: [new ToolCallFilter({ exclude: ['weather'] })],
|
|
161
|
+
});
|
|
162
|
+
const messages = new MessageList({ threadId: thread.id, resourceId }).add(result, 'response').get.all.v2();
|
|
163
|
+
expect(new MessageList().add(messages, 'memory').get.all.v2().length).toBeLessThan(messagesV2.length);
|
|
164
|
+
expect(filterToolCallsByName(result, 'weather')).toHaveLength(0);
|
|
165
|
+
expect(filterToolResultsByName(result, 'weather')).toHaveLength(0);
|
|
166
|
+
expect(filterToolCallsByName(result, 'calculator')).toHaveLength(1);
|
|
167
|
+
expect(filterToolResultsByName(result, 'calculator')).toHaveLength(1);
|
|
168
|
+
|
|
169
|
+
// make another query with no processors to make sure memory messages in DB were not altered and were only filtered from results
|
|
170
|
+
const queryResult2 = await memory.query({
|
|
171
|
+
threadId: thread.id,
|
|
172
|
+
selectBy: { last: 20 },
|
|
173
|
+
});
|
|
174
|
+
const result2 = memory.processMessages({ messages: v2ToCoreMessages(queryResult2.uiMessages), processors: [] });
|
|
175
|
+
const messages2 = new MessageList({ threadId: thread.id, resourceId }).add(result2, 'response').get.all.v2();
|
|
176
|
+
expect(new MessageList().add(messages2, 'memory').get.all.v2()).toHaveLength(messagesV2.length);
|
|
177
|
+
expect(filterToolCallsByName(result2, 'weather')).toHaveLength(1);
|
|
178
|
+
expect(filterToolResultsByName(result2, 'weather')).toHaveLength(1);
|
|
179
|
+
expect(filterToolCallsByName(result2, 'calculator')).toHaveLength(1);
|
|
180
|
+
expect(filterToolResultsByName(result2, 'calculator')).toHaveLength(1);
|
|
181
|
+
|
|
182
|
+
// filter all by name
|
|
183
|
+
const queryResult3 = await memory.query({
|
|
184
|
+
threadId: thread.id,
|
|
185
|
+
selectBy: { last: 20 },
|
|
186
|
+
});
|
|
187
|
+
const result3 = memory.processMessages({
|
|
188
|
+
messages: v2ToCoreMessages(queryResult3.uiMessages),
|
|
189
|
+
processors: [new ToolCallFilter({ exclude: ['weather', 'calculator'] })],
|
|
190
|
+
});
|
|
191
|
+
expect(result3.length).toBeLessThan(messagesV2.length);
|
|
192
|
+
expect(filterToolCallsByName(result3, 'weather')).toHaveLength(0);
|
|
193
|
+
expect(filterToolResultsByName(result3, 'weather')).toHaveLength(0);
|
|
194
|
+
expect(filterToolCallsByName(result3, 'calculator')).toHaveLength(0);
|
|
195
|
+
expect(filterToolResultsByName(result3, 'calculator')).toHaveLength(0);
|
|
196
|
+
|
|
197
|
+
// filter all by default
|
|
198
|
+
const queryResult4 = await memory.query({
|
|
199
|
+
threadId: thread.id,
|
|
200
|
+
selectBy: { last: 20 },
|
|
201
|
+
});
|
|
202
|
+
const result4 = memory.processMessages({
|
|
203
|
+
messages: v2ToCoreMessages(queryResult4.uiMessages),
|
|
204
|
+
processors: [new ToolCallFilter()],
|
|
205
|
+
});
|
|
206
|
+
expect(result4.length).toBeLessThan(messagesV2.length);
|
|
207
|
+
expect(filterToolCallsByName(result4, 'weather')).toHaveLength(0);
|
|
208
|
+
expect(filterToolResultsByName(result4, 'weather')).toHaveLength(0);
|
|
209
|
+
expect(filterToolCallsByName(result4, 'calculator')).toHaveLength(0);
|
|
210
|
+
expect(filterToolResultsByName(result4, 'calculator')).toHaveLength(0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should apply multiple processors in order', async () => {
|
|
214
|
+
// Create a thread
|
|
215
|
+
const thread = await memory.createThread({
|
|
216
|
+
title: 'Multiple Processors Test Thread',
|
|
217
|
+
resourceId,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// Generate conversation with tool calls
|
|
221
|
+
const { messages } = generateConversationHistory({
|
|
222
|
+
threadId: thread.id,
|
|
223
|
+
resourceId,
|
|
224
|
+
messageCount: 8,
|
|
225
|
+
toolFrequency: 2, // Every other assistant response is a tool call
|
|
226
|
+
toolNames: ['weather', 'calculator', 'search'],
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Save messages
|
|
230
|
+
await memory.saveMessages({ messages });
|
|
231
|
+
|
|
232
|
+
// Apply multiple processors: first remove weather tool calls, then limit to 250 tokens
|
|
233
|
+
const queryResult = await memory.query({
|
|
234
|
+
threadId: thread.id,
|
|
235
|
+
selectBy: { last: 20 },
|
|
236
|
+
});
|
|
237
|
+
const result = memory.processMessages({
|
|
238
|
+
messages: v2ToCoreMessages(queryResult.uiMessages),
|
|
239
|
+
processors: [new ToolCallFilter({ exclude: ['weather'] }), new TokenLimiter(250)],
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// We should have fewer messages after filtering and token limiting
|
|
243
|
+
expect(result.length).toBeGreaterThan(0);
|
|
244
|
+
expect(result.length).toBeLessThan(messages.length);
|
|
245
|
+
// And they should exclude weather tool messages
|
|
246
|
+
expect(filterToolResultsByName(result, `weather`)).toHaveLength(0);
|
|
247
|
+
expect(filterToolCallsByName(result, `weather`)).toHaveLength(0);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should apply multiple processors without duplicating messages', async () => {
|
|
251
|
+
class ConversationOnlyFilter extends MemoryProcessor {
|
|
252
|
+
constructor() {
|
|
253
|
+
super({ name: 'ConversationOnlyFilter' });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
process(messages: CoreMessage[], _opts: MemoryProcessorOpts = {}): CoreMessage[] {
|
|
257
|
+
return messages.filter(msg => msg.role === 'user' || msg.role === 'assistant');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const memory = new Memory({
|
|
261
|
+
storage,
|
|
262
|
+
vector,
|
|
263
|
+
embedder: fastembed,
|
|
264
|
+
processors: [new ToolCallFilter(), new ConversationOnlyFilter(), new TokenLimiter(127000)],
|
|
265
|
+
options: {
|
|
266
|
+
lastMessages: 10,
|
|
267
|
+
semanticRecall: true,
|
|
268
|
+
workingMemory: {
|
|
269
|
+
enabled: true,
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
const thread = await memory.createThread({
|
|
274
|
+
title: 'Multiple Processors Test Thread 2',
|
|
275
|
+
resourceId,
|
|
276
|
+
});
|
|
277
|
+
const instructions = 'You are a helpful assistant';
|
|
278
|
+
const agent = new Agent({
|
|
279
|
+
name: 'processor-test-agent',
|
|
280
|
+
instructions,
|
|
281
|
+
model: openai('gpt-4o'),
|
|
282
|
+
memory,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const userMessage = 'Tell me something interesting about space';
|
|
286
|
+
|
|
287
|
+
const res = await agent.generateVNext(
|
|
288
|
+
[
|
|
289
|
+
{
|
|
290
|
+
role: 'user',
|
|
291
|
+
content: userMessage,
|
|
292
|
+
},
|
|
293
|
+
],
|
|
294
|
+
{
|
|
295
|
+
threadId: thread.id,
|
|
296
|
+
resourceId,
|
|
297
|
+
},
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const requestInputMessages = res.request.body?.input;
|
|
301
|
+
if (!Array.isArray(requestInputMessages)) {
|
|
302
|
+
throw new Error(`responseMessages should be an array`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const userMessagesByContent = requestInputMessages.filter(m => m.content?.[0]?.text === userMessage);
|
|
306
|
+
expect(userMessagesByContent).toEqual([
|
|
307
|
+
expect.objectContaining({ role: 'user', content: [expect.objectContaining({ text: userMessage })] }),
|
|
308
|
+
]); // should only be one
|
|
309
|
+
expect(userMessagesByContent.length).toBe(1); // if there's more than one we have duplicate messages
|
|
310
|
+
|
|
311
|
+
const userMessage2 = 'Tell me something else interesting about space';
|
|
312
|
+
|
|
313
|
+
const res2 = await agent.generateVNext(
|
|
314
|
+
[
|
|
315
|
+
{
|
|
316
|
+
role: 'user',
|
|
317
|
+
content: userMessage2,
|
|
318
|
+
},
|
|
319
|
+
],
|
|
320
|
+
{
|
|
321
|
+
threadId: thread.id,
|
|
322
|
+
resourceId,
|
|
323
|
+
},
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
const requestInputMessages2 = res2.request.body?.input;
|
|
327
|
+
|
|
328
|
+
if (!Array.isArray(requestInputMessages2)) {
|
|
329
|
+
throw new Error(`responseMessages should be an array`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const userMessagesByContent2 = requestInputMessages2.filter((m: any) => m.content?.[0]?.text === userMessage2);
|
|
333
|
+
expect(userMessagesByContent2).toEqual([
|
|
334
|
+
expect.objectContaining({ role: 'user', content: [expect.objectContaining({ text: userMessage2 })] }),
|
|
335
|
+
]); // should only be one
|
|
336
|
+
expect(userMessagesByContent2.length).toBe(1); // if there's more than one we have duplicate messages
|
|
337
|
+
|
|
338
|
+
// make sure all user messages are there
|
|
339
|
+
const allUserMessages = requestInputMessages2.filter((m: CoreMessage) => m.role === 'user');
|
|
340
|
+
expect(allUserMessages.length).toBe(2);
|
|
341
|
+
|
|
342
|
+
const remembered = await memory.query({
|
|
343
|
+
threadId: thread.id,
|
|
344
|
+
resourceId,
|
|
345
|
+
selectBy: {
|
|
346
|
+
last: 20,
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
expect(remembered.messages.filter(m => m.role === 'user').length).toBe(2);
|
|
350
|
+
expect(remembered.messages.length).toBe(4); // 2 user, 2 assistant. These wont be filtered because they come from memory.query() directly
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('should apply processors with a real Mastra agent', async () => {
|
|
354
|
+
// Create a thread
|
|
355
|
+
const thread = await memory.createThread({
|
|
356
|
+
title: 'Real Agent Processor Test Thread',
|
|
357
|
+
resourceId,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const threadId = thread.id;
|
|
361
|
+
|
|
362
|
+
// Create test tools
|
|
363
|
+
const weatherTool = createTool({
|
|
364
|
+
id: 'get_weather',
|
|
365
|
+
description: 'Get the weather for a given location',
|
|
366
|
+
inputSchema: z.object({
|
|
367
|
+
location: z.string().describe('The location to get the weather for'),
|
|
368
|
+
}),
|
|
369
|
+
execute: async ({ context: { location } }) => {
|
|
370
|
+
return `The weather in ${location} is sunny. It is currently 70 degrees and feels like 65 degrees.`;
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const calculatorTool = createTool({
|
|
375
|
+
id: 'calculator',
|
|
376
|
+
description: 'Perform a simple calculation',
|
|
377
|
+
inputSchema: z.object({
|
|
378
|
+
expression: z.string().describe('The mathematical expression to calculate'),
|
|
379
|
+
}),
|
|
380
|
+
execute: async ({ context: { expression } }) => {
|
|
381
|
+
return `The result of ${expression} is ${eval(expression)}`;
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
const instructions =
|
|
386
|
+
'You are a helpful assistant with access to weather and calculator tools. Use them when appropriate.';
|
|
387
|
+
// Create agent with memory and tools
|
|
388
|
+
const agent = new Agent({
|
|
389
|
+
name: 'processor-test-agent',
|
|
390
|
+
instructions,
|
|
391
|
+
model: openai('gpt-4o'),
|
|
392
|
+
memory,
|
|
393
|
+
tools: {
|
|
394
|
+
get_weather: weatherTool,
|
|
395
|
+
calculator: calculatorTool,
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// First message - use weather tool
|
|
400
|
+
await agent.generateVNext('What is the weather in Seattle?', {
|
|
401
|
+
threadId,
|
|
402
|
+
resourceId,
|
|
403
|
+
});
|
|
404
|
+
// Second message - use calculator tool
|
|
405
|
+
await agent.generateVNext('Calculate 123 * 456', {
|
|
406
|
+
threadId,
|
|
407
|
+
resourceId,
|
|
408
|
+
});
|
|
409
|
+
// Third message - simple text response
|
|
410
|
+
await agent.generateVNext('Tell me something interesting about space', {
|
|
411
|
+
threadId,
|
|
412
|
+
resourceId,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// Query with no processors to verify baseline message count
|
|
416
|
+
const queryResult = await memory.query({
|
|
417
|
+
threadId,
|
|
418
|
+
selectBy: { last: 20 },
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
const list = new MessageList({ threadId }).add(queryResult.messagesV2, 'memory');
|
|
422
|
+
|
|
423
|
+
const baselineResult = memory.processMessages({
|
|
424
|
+
messages: list.get.remembered.core(),
|
|
425
|
+
newMessages: list.get.input.core(),
|
|
426
|
+
processors: [],
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
// There should be at least 6 messages (3 user + 3 assistant responses)
|
|
430
|
+
expect(baselineResult.length).toBeGreaterThanOrEqual(6);
|
|
431
|
+
|
|
432
|
+
// Verify we have tool calls in the baseline
|
|
433
|
+
const weatherToolCalls = filterToolCallsByName(baselineResult, 'get_weather');
|
|
434
|
+
const calculatorToolCalls = filterToolCallsByName(baselineResult, 'calculator');
|
|
435
|
+
expect(weatherToolCalls.length).toBeGreaterThan(0);
|
|
436
|
+
expect(calculatorToolCalls.length).toBeGreaterThan(0);
|
|
437
|
+
|
|
438
|
+
// Test filtering weather tool calls
|
|
439
|
+
const weatherQueryResult = await memory.query({
|
|
440
|
+
threadId,
|
|
441
|
+
selectBy: { last: 20 },
|
|
442
|
+
});
|
|
443
|
+
const list2 = new MessageList({ threadId }).add(weatherQueryResult.messagesV2, 'memory');
|
|
444
|
+
const weatherFilteredResult = memory.processMessages({
|
|
445
|
+
messages: list2.get.all.core(),
|
|
446
|
+
processors: [new ToolCallFilter({ exclude: ['get_weather'] })],
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// Should have fewer messages after filtering
|
|
450
|
+
expect(weatherFilteredResult.length).toBeLessThan(baselineResult.length);
|
|
451
|
+
|
|
452
|
+
// No weather tool calls should remain
|
|
453
|
+
expect(filterToolCallsByName(weatherFilteredResult, 'get_weather').length).toBe(0);
|
|
454
|
+
expect(filterToolResultsByName(weatherFilteredResult, 'get_weather').length).toBe(0);
|
|
455
|
+
|
|
456
|
+
// Calculator tool calls should still be present
|
|
457
|
+
expect(filterToolCallsByName(weatherFilteredResult, 'calculator').length).toBeGreaterThan(0);
|
|
458
|
+
|
|
459
|
+
// Test token limiting
|
|
460
|
+
const tokenLimitQuery = await memory.query({
|
|
461
|
+
threadId,
|
|
462
|
+
selectBy: { last: 20 },
|
|
463
|
+
});
|
|
464
|
+
const list3 = new MessageList({ threadId }).add(tokenLimitQuery.messages, 'memory');
|
|
465
|
+
const tokenLimitedResult = memory.processMessages({
|
|
466
|
+
messages: list3.get.all.core(),
|
|
467
|
+
processors: [new TokenLimiter(100)], // Small limit to only get a subset
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
// Should have fewer messages after token limiting
|
|
471
|
+
expect(tokenLimitedResult.length).toBeLessThan(baselineResult.length);
|
|
472
|
+
|
|
473
|
+
// Test combining processors
|
|
474
|
+
const combinedQuery = await memory.query({
|
|
475
|
+
threadId,
|
|
476
|
+
selectBy: { last: 20 },
|
|
477
|
+
});
|
|
478
|
+
const list4 = new MessageList({ threadId }).add(combinedQuery.messages, 'memory');
|
|
479
|
+
const combinedResult = memory.processMessages({
|
|
480
|
+
messages: list4.get.all.core(),
|
|
481
|
+
processors: [new ToolCallFilter({ exclude: ['get_weather', 'calculator'] }), new TokenLimiter(500)],
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// No tool calls should remain
|
|
485
|
+
expect(filterToolCallsByName(combinedResult, 'get_weather').length).toBe(0);
|
|
486
|
+
expect(filterToolCallsByName(combinedResult, 'calculator').length).toBe(0);
|
|
487
|
+
expect(filterToolResultsByName(combinedResult, 'get_weather').length).toBe(0);
|
|
488
|
+
expect(filterToolResultsByName(combinedResult, 'calculator').length).toBe(0);
|
|
489
|
+
|
|
490
|
+
// The result should still contain some messages
|
|
491
|
+
expect(combinedResult.length).toBeGreaterThan(0);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('should chunk long text by character count', async () => {
|
|
495
|
+
// Create a thread
|
|
496
|
+
const thread = await memory.createThread({
|
|
497
|
+
title: 'Text Chunking Test Thread',
|
|
498
|
+
resourceId,
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// Create a long text with known word boundaries
|
|
502
|
+
const words = [];
|
|
503
|
+
for (let i = 0; i < 1000; i++) {
|
|
504
|
+
words.push(`word${i}`);
|
|
505
|
+
}
|
|
506
|
+
const longText = words.join(' ');
|
|
507
|
+
|
|
508
|
+
// Save a message with the long text
|
|
509
|
+
await memory.saveMessages({
|
|
510
|
+
messages: [
|
|
511
|
+
{
|
|
512
|
+
id: 'chunking-test',
|
|
513
|
+
threadId: thread.id,
|
|
514
|
+
role: 'user',
|
|
515
|
+
content: {
|
|
516
|
+
format: 2,
|
|
517
|
+
parts: [{ type: 'text', text: longText }],
|
|
518
|
+
},
|
|
519
|
+
createdAt: new Date(),
|
|
520
|
+
resourceId,
|
|
521
|
+
},
|
|
522
|
+
],
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// Query the message back
|
|
526
|
+
const queryResult = await memory.query({
|
|
527
|
+
threadId: thread.id,
|
|
528
|
+
selectBy: { last: 1 },
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// Retrieve the message (no TokenLimiter, just get the message back)
|
|
532
|
+
const result = memory.processMessages({
|
|
533
|
+
messages: v2ToCoreMessages(queryResult.uiMessages),
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
// Should have retrieved the message
|
|
537
|
+
expect(result.length).toBe(1);
|
|
538
|
+
|
|
539
|
+
// Each chunk should respect word boundaries
|
|
540
|
+
for (const msg of result) {
|
|
541
|
+
// No words should be cut off
|
|
542
|
+
const content = typeof msg.content === 'string' ? msg.content : (msg.content[0] as { text: string }).text;
|
|
543
|
+
const words = content.split(/\s+/);
|
|
544
|
+
for (const word of words) {
|
|
545
|
+
expect(word).toMatch(/^word\d+$/); // Each word should be complete
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Chunks should maintain original order
|
|
550
|
+
let prevNum = -1;
|
|
551
|
+
for (const msg of result) {
|
|
552
|
+
const content = typeof msg.content === 'string' ? msg.content : (msg.content[0] as { text: string }).text;
|
|
553
|
+
const firstWord = content.split(/\s+/)[0];
|
|
554
|
+
const num = parseInt(firstWord.replace('word', ''));
|
|
555
|
+
expect(num).toBeGreaterThan(prevNum);
|
|
556
|
+
prevNum = num;
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
// Direct unit test for chunkText
|
|
562
|
+
|
|
563
|
+
describe('Memory.chunkText', () => {
|
|
564
|
+
it('should split long text into chunks at word boundaries', () => {
|
|
565
|
+
const memory = new Memory({
|
|
566
|
+
storage,
|
|
567
|
+
vector,
|
|
568
|
+
embedder: fastembed,
|
|
569
|
+
options: {
|
|
570
|
+
semanticRecall: true,
|
|
571
|
+
lastMessages: 10,
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
const words = [];
|
|
575
|
+
for (let i = 0; i < 1000; i++) {
|
|
576
|
+
words.push(`word${i}`);
|
|
577
|
+
}
|
|
578
|
+
const longText = words.join(' ');
|
|
579
|
+
// Use a small token size to force chunking
|
|
580
|
+
const chunks = (memory as any).chunkText(longText, 50);
|
|
581
|
+
expect(chunks.length).toBeGreaterThan(1);
|
|
582
|
+
// Each chunk should respect word boundaries
|
|
583
|
+
for (const chunk of chunks) {
|
|
584
|
+
const chunkWords = chunk.split(/\s+/);
|
|
585
|
+
for (const word of chunkWords) {
|
|
586
|
+
if (word.length === 0) continue;
|
|
587
|
+
expect(word).toMatch(/^word\d+$/);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
// Chunks should maintain original order
|
|
591
|
+
let prevNum = -1;
|
|
592
|
+
for (const chunk of chunks) {
|
|
593
|
+
const firstWord = chunk.split(/\s+/)[0];
|
|
594
|
+
if (!firstWord) continue; // skip empty
|
|
595
|
+
const num = parseInt(firstWord.replace('word', ''));
|
|
596
|
+
expect(num).toBeGreaterThan(prevNum);
|
|
597
|
+
prevNum = num;
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
});
|