@mastra/memory 0.0.0-a2a-20250421213654

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.
@@ -0,0 +1,238 @@
1
+ import { openai } from '@ai-sdk/openai';
2
+ import { createTool } from '@mastra/core';
3
+ import type { CoreMessage, MessageType } from '@mastra/core';
4
+ import { Agent } from '@mastra/core/agent';
5
+ import cl100k_base from 'js-tiktoken/ranks/cl100k_base';
6
+ import { describe, it, expect, vi } from 'vitest';
7
+ import { z } from 'zod';
8
+ import { generateConversationHistory } from '../../integration-tests/src/test-utils';
9
+ import { TokenLimiter, ToolCallFilter } from './index';
10
+
11
+ vi.setConfig({ testTimeout: 20_000, hookTimeout: 20_000 });
12
+
13
+ describe('TokenLimiter', () => {
14
+ it('should limit messages to the specified token count', () => {
15
+ // Create messages with predictable token counts (approximately 25 tokens each)
16
+ const { messages } = generateConversationHistory({
17
+ threadId: '1',
18
+ messageCount: 5,
19
+ toolNames: [],
20
+ toolFrequency: 0,
21
+ });
22
+
23
+ const limiter = new TokenLimiter(200);
24
+ // @ts-ignore
25
+ const result = limiter.process(messages);
26
+
27
+ // Should prioritize newest messages (higher ids)
28
+ expect(result.length).toBe(2);
29
+ expect((result[0] as MessageType).id).toBe('message-8');
30
+ expect((result[1] as MessageType).id).toBe('message-9');
31
+ });
32
+
33
+ it('should handle empty messages array', () => {
34
+ const limiter = new TokenLimiter(1000);
35
+ const result = limiter.process([]);
36
+ expect(result).toEqual([]);
37
+ });
38
+
39
+ it('should use different encodings based on configuration', () => {
40
+ const { messages } = generateConversationHistory({
41
+ threadId: '6',
42
+ messageCount: 1,
43
+ toolNames: [],
44
+ toolFrequency: 0,
45
+ });
46
+
47
+ // Create limiters with different encoding settings
48
+ const defaultLimiter = new TokenLimiter(1000);
49
+ const customLimiter = new TokenLimiter({
50
+ limit: 1000,
51
+ encoding: cl100k_base,
52
+ });
53
+
54
+ // All should process messages successfully but potentially with different token counts
55
+ const defaultResult = defaultLimiter.process(messages as CoreMessage[]);
56
+ const customResult = customLimiter.process(messages as CoreMessage[]);
57
+
58
+ // Each should return the same messages but with potentially different token counts
59
+ expect(defaultResult.length).toBe(messages.length);
60
+ expect(customResult.length).toBe(messages.length);
61
+ });
62
+
63
+ function estimateTokens(messages: MessageType[]) {
64
+ // Create a TokenLimiter just for counting tokens
65
+ const testLimiter = new TokenLimiter(Infinity);
66
+
67
+ let estimatedTokens = testLimiter.TOKENS_PER_CONVERSATION;
68
+
69
+ // Count tokens for each message including all overheads
70
+ for (const message of messages) {
71
+ // Base token count from the countTokens method
72
+ estimatedTokens += testLimiter.countTokens(message as CoreMessage);
73
+ }
74
+
75
+ return estimatedTokens;
76
+ }
77
+
78
+ function percentDifference(a: number, b: number) {
79
+ const difference = Number(((Math.abs(a - b) / b) * 100).toFixed(2));
80
+ console.log(`${a} and ${b} are ${difference}% different`);
81
+ return difference;
82
+ }
83
+
84
+ async function expectTokenEstimate(config: Parameters<typeof generateConversationHistory>[0], agent: Agent) {
85
+ const { messages, counts } = generateConversationHistory(config);
86
+
87
+ const estimate = estimateTokens(messages);
88
+ const used = (await agent.generate(messages.slice(0, -1) as CoreMessage[])).usage.totalTokens;
89
+
90
+ console.log(`Estimated ${estimate} tokens, used ${used} tokens.\n`, counts);
91
+
92
+ // Check if within 2% margin
93
+ expect(percentDifference(estimate, used)).toBeLessThanOrEqual(2);
94
+ }
95
+
96
+ const calculatorTool = createTool({
97
+ id: 'calculator',
98
+ description: 'Perform a simple calculation',
99
+ inputSchema: z.object({
100
+ expression: z.string().describe('The mathematical expression to calculate'),
101
+ }),
102
+ execute: async ({ context: { expression } }) => {
103
+ return `The result of ${expression} is ${eval(expression)}`;
104
+ },
105
+ });
106
+
107
+ const agent = new Agent({
108
+ name: 'token estimate agent',
109
+ model: openai('gpt-4o-mini'),
110
+ instructions: ``,
111
+ tools: { calculatorTool },
112
+ });
113
+
114
+ describe.concurrent(`98% accuracy`, () => {
115
+ it(`20 messages, no tools`, async () => {
116
+ await expectTokenEstimate(
117
+ {
118
+ messageCount: 10,
119
+ toolFrequency: 0,
120
+ threadId: '2',
121
+ },
122
+ agent,
123
+ );
124
+ });
125
+
126
+ it(`60 messages, no tools`, async () => {
127
+ await expectTokenEstimate(
128
+ {
129
+ messageCount: 30,
130
+ toolFrequency: 0,
131
+ threadId: '3',
132
+ },
133
+ agent,
134
+ );
135
+ });
136
+
137
+ it(`4 messages, 0 tools`, async () => {
138
+ await expectTokenEstimate(
139
+ {
140
+ messageCount: 2,
141
+ toolFrequency: 0,
142
+ threadId: '3',
143
+ },
144
+ agent,
145
+ );
146
+ });
147
+
148
+ it(`20 messages, 2 tool messages`, async () => {
149
+ await expectTokenEstimate(
150
+ {
151
+ messageCount: 10,
152
+ toolFrequency: 5,
153
+ threadId: '3',
154
+ },
155
+ agent,
156
+ );
157
+ });
158
+
159
+ it(`40 messages, 6 tool messages`, async () => {
160
+ await expectTokenEstimate(
161
+ {
162
+ messageCount: 20,
163
+ toolFrequency: 5,
164
+ threadId: '4',
165
+ },
166
+ agent,
167
+ );
168
+ });
169
+
170
+ it(`100 messages, 24 tool messages`, async () => {
171
+ await expectTokenEstimate(
172
+ {
173
+ messageCount: 50,
174
+ toolFrequency: 4,
175
+ threadId: '5',
176
+ },
177
+ agent,
178
+ );
179
+ });
180
+
181
+ it(`101 messages, 49 tool calls`, async () => {
182
+ await expectTokenEstimate(
183
+ {
184
+ messageCount: 50,
185
+ toolFrequency: 1,
186
+ threadId: '5',
187
+ },
188
+ agent,
189
+ );
190
+ });
191
+ });
192
+ });
193
+
194
+ describe.concurrent('ToolCallFilter', () => {
195
+ it('should exclude all tool calls when created with no arguments', () => {
196
+ const { messages } = generateConversationHistory({
197
+ threadId: '3',
198
+ toolNames: ['weather', 'calculator', 'search'],
199
+ messageCount: 1,
200
+ });
201
+ const filter = new ToolCallFilter();
202
+ const result = filter.process(messages as CoreMessage[]) as MessageType[];
203
+
204
+ // Should only keep the text message and assistant res
205
+ expect(result.length).toBe(2);
206
+ expect(result[0].id).toBe('message-0');
207
+ });
208
+
209
+ it('should exclude specific tool calls by name', () => {
210
+ const { messages } = generateConversationHistory({
211
+ threadId: '4',
212
+ toolNames: ['weather', 'calculator'],
213
+ messageCount: 2,
214
+ });
215
+ const filter = new ToolCallFilter({ exclude: ['weather'] });
216
+ const result = filter.process(messages as CoreMessage[]) as MessageType[];
217
+
218
+ // Should keep text message, assistant reply, calculator tool call, and calculator result
219
+ expect(result.length).toBe(4);
220
+ expect(result[0].id).toBe('message-0');
221
+ expect(result[1].id).toBe('message-1');
222
+ expect(result[2].id).toBe('message-2');
223
+ expect(result[3].id).toBe('message-3');
224
+ });
225
+
226
+ it('should keep all messages when exclude list is empty', () => {
227
+ const { messages } = generateConversationHistory({
228
+ threadId: '5',
229
+ toolNames: ['weather', 'calculator'],
230
+ });
231
+
232
+ const filter = new ToolCallFilter({ exclude: [] });
233
+ const result = filter.process(messages as CoreMessage[]);
234
+
235
+ // Should keep all messages
236
+ expect(result.length).toBe(messages.length);
237
+ });
238
+ });
@@ -0,0 +1,2 @@
1
+ export * from './token-limiter';
2
+ export * from './tool-call-filter';
@@ -0,0 +1,150 @@
1
+ import type { CoreMessage, MemoryProcessorOpts } from '@mastra/core';
2
+ import { MemoryProcessor } from '@mastra/core/memory';
3
+ import { Tiktoken } from 'js-tiktoken/lite';
4
+ import type { TiktokenBPE } from 'js-tiktoken/lite';
5
+ import o200k_base from 'js-tiktoken/ranks/o200k_base';
6
+
7
+ /**
8
+ * Configuration options for TokenLimiter
9
+ */
10
+ interface TokenLimiterOptions {
11
+ /** Maximum number of tokens to allow */
12
+ limit: number;
13
+ /** Optional encoding to use (defaults to o200k_base which is used by gpt-4o) */
14
+ encoding?: TiktokenBPE;
15
+ }
16
+
17
+ /**
18
+ * Limits the total number of tokens in the messages.
19
+ * Uses js-tiktoken with o200k_base encoding by default for accurate token counting with modern models.
20
+ */
21
+ export class TokenLimiter extends MemoryProcessor {
22
+ private encoder: Tiktoken;
23
+ private maxTokens: number;
24
+
25
+ // Token overheads per OpenAI's documentation
26
+ // See: https://cookbook.openai.com/examples/how_to_count_tokens_with_tiktoken#6-counting-tokens-for-chat-completions-api-calls
27
+ // Every message follows <|start|>{role/name}\n{content}<|end|>
28
+ public TOKENS_PER_MESSAGE = 3; // tokens added for each message (start & end tokens)
29
+ public TOKENS_PER_TOOL = 2; // empirical adjustment for tool calls
30
+ public TOKENS_PER_CONVERSATION = 25; // fixed overhead for the conversation
31
+
32
+ /**
33
+ * Create a token limiter for messages.
34
+ * @param options Either a number (token limit) or a configuration object
35
+ */
36
+ constructor(options: number | TokenLimiterOptions) {
37
+ super({
38
+ name: 'TokenLimiter',
39
+ });
40
+
41
+ if (typeof options === 'number') {
42
+ // Simple number format - just the token limit with default encoding
43
+ this.maxTokens = options;
44
+ this.encoder = new Tiktoken(o200k_base);
45
+ } else {
46
+ // Object format with limit and optional encoding
47
+ this.maxTokens = options.limit;
48
+ this.encoder = new Tiktoken(options.encoding || o200k_base);
49
+ }
50
+ }
51
+
52
+ process(
53
+ messages: CoreMessage[],
54
+ { systemMessage, memorySystemMessage, newMessages }: MemoryProcessorOpts = {},
55
+ ): CoreMessage[] {
56
+ // Messages are already chronologically ordered - take most recent ones up to the token limit
57
+ let totalTokens = 0;
58
+
59
+ // Start with the conversation overhead
60
+ totalTokens += this.TOKENS_PER_CONVERSATION;
61
+
62
+ if (systemMessage) {
63
+ totalTokens += this.countTokens(systemMessage);
64
+ totalTokens += this.TOKENS_PER_MESSAGE; // Add message overhead for system message
65
+ }
66
+
67
+ if (memorySystemMessage) {
68
+ totalTokens += this.countTokens(memorySystemMessage);
69
+ totalTokens += this.TOKENS_PER_MESSAGE; // Add message overhead for memory system message
70
+ }
71
+
72
+ const allMessages = [...messages, ...(newMessages || [])];
73
+
74
+ const result: CoreMessage[] = [];
75
+
76
+ // Process messages in reverse (newest first)
77
+ for (let i = allMessages.length - 1; i >= 0; i--) {
78
+ const message = allMessages[i];
79
+
80
+ // Skip undefined messages (shouldn't happen, but TypeScript is concerned)
81
+ if (!message) continue;
82
+
83
+ const messageTokens = this.countTokens(message);
84
+
85
+ if (totalTokens + messageTokens <= this.maxTokens) {
86
+ // Insert at the beginning to maintain chronological order
87
+ result.unshift(message);
88
+ totalTokens += messageTokens;
89
+ } else {
90
+ this.logger.info(
91
+ `filtering ${allMessages.length - result.length}/${allMessages.length} messages, token limit of ${this.maxTokens} exceeded`,
92
+ );
93
+ // If we can't fit the message, we stop
94
+ break;
95
+ }
96
+ }
97
+
98
+ return result;
99
+ }
100
+
101
+ public countTokens(message: string | CoreMessage): number {
102
+ if (typeof message === `string`) {
103
+ return this.encoder.encode(message).length;
104
+ }
105
+
106
+ let tokenString = message.role;
107
+
108
+ if (typeof message.content === 'string') {
109
+ tokenString += message.content;
110
+ } else if (Array.isArray(message.content)) {
111
+ // Calculate tokens for each content part
112
+ for (const part of message.content) {
113
+ tokenString += part.type;
114
+ if (part.type === 'text') {
115
+ tokenString += part.text;
116
+ } else if (part.type === 'tool-call') {
117
+ tokenString += part.toolName as any;
118
+ if (part.args) {
119
+ tokenString += typeof part.args === 'string' ? part.args : JSON.stringify(part.args);
120
+ }
121
+ } else if (part.type === 'tool-result') {
122
+ // Token cost for result if present
123
+ if (part.result !== undefined) {
124
+ tokenString += typeof part.result === 'string' ? part.result : JSON.stringify(part.result);
125
+ }
126
+ } else {
127
+ tokenString += JSON.stringify(part);
128
+ }
129
+ }
130
+ }
131
+
132
+ // Ensure we account for message formatting tokens
133
+ // See: https://cookbook.openai.com/examples/how_to_count_tokens_with_tiktoken#6-counting-tokens-for-chat-completions-api-calls
134
+ const messageOverhead = this.TOKENS_PER_MESSAGE;
135
+
136
+ // Count tool calls for additional overhead
137
+ let toolOverhead = 0;
138
+ if (Array.isArray(message.content)) {
139
+ for (const part of message.content) {
140
+ if (part.type === 'tool-call' || part.type === 'tool-result') {
141
+ toolOverhead += this.TOKENS_PER_TOOL;
142
+ }
143
+ }
144
+ }
145
+
146
+ const totalMessageOverhead = messageOverhead + toolOverhead;
147
+
148
+ return this.encoder.encode(tokenString).length + totalMessageOverhead;
149
+ }
150
+ }
@@ -0,0 +1,77 @@
1
+ import type { CoreMessage } from '@mastra/core';
2
+ import { MemoryProcessor } from '@mastra/core';
3
+
4
+ /**
5
+ * Filters out tool calls and results from messages.
6
+ * By default (with no arguments), excludes all tool calls and their results.
7
+ * Can be configured to exclude only specific tools by name.
8
+ */
9
+ export class ToolCallFilter extends MemoryProcessor {
10
+ private exclude: string[] | 'all';
11
+
12
+ /**
13
+ * Create a filter for tool calls and results.
14
+ * @param options Configuration options
15
+ * @param options.exclude List of specific tool names to exclude. If not provided, all tool calls are excluded.
16
+ */
17
+ constructor(options: { exclude?: string[] } = {}) {
18
+ super({ name: 'ToolCallFilter' });
19
+ // If no options or exclude is provided, exclude all tools
20
+ if (!options || !options.exclude) {
21
+ this.exclude = 'all'; // Exclude all tools
22
+ } else {
23
+ // Exclude specific tools
24
+ this.exclude = Array.isArray(options.exclude) ? options.exclude : [];
25
+ }
26
+ }
27
+
28
+ process(messages: CoreMessage[]): CoreMessage[] {
29
+ // Case 1: Exclude all tool calls and tool results
30
+ if (this.exclude === 'all') {
31
+ return messages.filter(message => {
32
+ if (Array.isArray(message.content)) {
33
+ return !message.content.some(part => part.type === 'tool-call' || part.type === 'tool-result');
34
+ }
35
+ return true;
36
+ });
37
+ }
38
+
39
+ // Case 2: Exclude specific tools by name
40
+ if (this.exclude.length > 0) {
41
+ // Single pass approach - track excluded tool call IDs while filtering
42
+ const excludedToolCallIds = new Set<string>();
43
+
44
+ return messages.filter(message => {
45
+ if (!Array.isArray(message.content)) return true;
46
+
47
+ // For assistant messages, check for excluded tool calls and track their IDs
48
+ if (message.role === 'assistant') {
49
+ let shouldExclude = false;
50
+
51
+ for (const part of message.content) {
52
+ if (part.type === 'tool-call' && this.exclude.includes(part.toolName)) {
53
+ excludedToolCallIds.add(part.toolCallId);
54
+ shouldExclude = true;
55
+ }
56
+ }
57
+
58
+ return !shouldExclude;
59
+ }
60
+
61
+ // For tool messages, filter out results for excluded tool calls
62
+ if (message.role === 'tool') {
63
+ const shouldExclude = message.content.some(
64
+ part => part.type === 'tool-result' && excludedToolCallIds.has(part.toolCallId),
65
+ );
66
+
67
+ return !shouldExclude;
68
+ }
69
+
70
+ return true;
71
+ });
72
+ }
73
+
74
+ // Case 3: Empty exclude array, return original messages
75
+ return messages;
76
+ }
77
+ }
@@ -0,0 +1,33 @@
1
+ import type { CoreTool } from '@mastra/core';
2
+ import { z } from 'zod';
3
+
4
+ export const updateWorkingMemoryTool: CoreTool = {
5
+ description: 'Update the working memory with new information',
6
+ parameters: z.object({
7
+ memory: z.string().describe('The Markdown-formatted working memory content to store'),
8
+ }),
9
+ execute: async (params: any) => {
10
+ const { context, threadId, memory } = params;
11
+ if (!threadId || !memory) {
12
+ throw new Error('Thread ID and Memory instance are required for working memory updates');
13
+ }
14
+
15
+ const thread = await memory.getThreadById({ threadId });
16
+ if (!thread) {
17
+ throw new Error(`Thread ${threadId} not found`);
18
+ }
19
+
20
+ // Update thread metadata with new working memory
21
+ await memory.saveThread({
22
+ thread: {
23
+ ...thread,
24
+ metadata: {
25
+ ...thread.metadata,
26
+ workingMemory: context.memory,
27
+ },
28
+ },
29
+ });
30
+
31
+ return { success: true };
32
+ },
33
+ };
@@ -0,0 +1,88 @@
1
+ import type { MessageType } from '@mastra/core/memory';
2
+
3
+ const isToolCallWithId = (message: MessageType | undefined, targetToolCallId: string): boolean => {
4
+ if (!message || !Array.isArray(message.content)) return false;
5
+ return message.content.some(
6
+ part =>
7
+ part &&
8
+ typeof part === 'object' &&
9
+ 'type' in part &&
10
+ part.type === 'tool-call' &&
11
+ 'toolCallId' in part &&
12
+ part.toolCallId === targetToolCallId,
13
+ );
14
+ };
15
+
16
+ const getToolResultIndexById = (id: string, results: MessageType[]) =>
17
+ results.findIndex(message => {
18
+ if (!Array.isArray(message?.content)) return false;
19
+ return message.content.some(
20
+ part =>
21
+ part &&
22
+ typeof part === 'object' &&
23
+ 'type' in part &&
24
+ part.type === 'tool-result' &&
25
+ 'toolCallId' in part &&
26
+ part.toolCallId === id,
27
+ );
28
+ });
29
+
30
+ /**
31
+ * Self-heals message ordering to ensure tool calls are directly before their matching tool results.
32
+ * This is needed due to a bug where messages were saved in the wrong order. That bug is fixed, but this code ensures any tool calls saved in the wrong order in the past will still be usable now.
33
+ */
34
+ export function reorderToolCallsAndResults(messages: MessageType[]): MessageType[] {
35
+ if (!messages.length) return messages;
36
+
37
+ // Create a copy of messages to avoid modifying the original
38
+ const results = [...messages];
39
+
40
+ const toolCallIds = new Set<string>();
41
+
42
+ // First loop: collect all tool result IDs in a set
43
+ for (const message of results) {
44
+ if (!Array.isArray(message.content)) continue;
45
+
46
+ for (const part of message.content) {
47
+ if (
48
+ part &&
49
+ typeof part === 'object' &&
50
+ 'type' in part &&
51
+ part.type === 'tool-result' &&
52
+ 'toolCallId' in part &&
53
+ part.toolCallId
54
+ ) {
55
+ toolCallIds.add(part.toolCallId);
56
+ }
57
+ }
58
+ }
59
+
60
+ // Second loop: for each tool ID, ensure tool calls come before tool results
61
+ for (const toolCallId of toolCallIds) {
62
+ // Find tool result index
63
+ const resultIndex = getToolResultIndexById(toolCallId, results);
64
+
65
+ // Check if tool call is at resultIndex - 1
66
+ const oneMessagePrev = results[resultIndex - 1];
67
+ if (isToolCallWithId(oneMessagePrev, toolCallId)) {
68
+ continue; // Tool call is already in the correct position
69
+ }
70
+
71
+ // Find the tool call anywhere in the array
72
+ const toolCallIndex = results.findIndex(message => isToolCallWithId(message, toolCallId));
73
+
74
+ if (toolCallIndex !== -1 && toolCallIndex !== resultIndex - 1) {
75
+ // Store the tool call message
76
+ const toolCall = results[toolCallIndex];
77
+ if (!toolCall) continue;
78
+
79
+ // Remove the tool call from its current position
80
+ results.splice(toolCallIndex, 1);
81
+
82
+ // Insert right before the tool result
83
+ results.splice(getToolResultIndexById(toolCallId, results), 0, toolCall);
84
+ }
85
+ }
86
+
87
+ return results;
88
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "../../tsconfig.node.json",
3
+ "include": ["src/**/*"],
4
+ "exclude": ["node_modules", "**/*.test.ts"]
5
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ include: ['src/**/*.test.ts'],
7
+ },
8
+ });