@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.
- package/CHANGELOG.md +1842 -0
- package/LICENSE.md +46 -0
- package/README.md +7 -0
- package/dist/_tsup-dts-rollup.d.cts +138 -0
- package/dist/_tsup-dts-rollup.d.ts +138 -0
- package/dist/index.cjs +482 -0
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +476 -0
- package/dist/processors/index.cjs +161 -0
- package/dist/processors/index.d.cts +2 -0
- package/dist/processors/index.d.ts +2 -0
- package/dist/processors/index.js +154 -0
- package/eslint.config.js +12 -0
- package/package.json +67 -0
- package/src/index.ts +565 -0
- package/src/processors/index.test.ts +238 -0
- package/src/processors/index.ts +2 -0
- package/src/processors/token-limiter.ts +150 -0
- package/src/processors/tool-call-filter.ts +77 -0
- package/src/tools/working-memory.ts +33 -0
- package/src/utils/index.ts +88 -0
- package/tsconfig.json +5 -0
- package/vitest.config.ts +8 -0
|
@@ -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,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