Skip to content

Commit 106c960

Browse files
authored
feat(memory): make agentId optional for read operations (#11540)
## Description feat(memory): make agentId optional for read operations ## Related Issue(s) fixes #10867 ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Code refactoring - [ ] Performance improvement - [ ] Test update ## Checklist - [x] I have made corresponding changes to the documentation (if applicable) - [x] I have added tests that prove my fix is effective or that my feature works <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a method to list messages within a thread with selectable memory source (agent, network, or storage). * **Improvements** * agentId is now optional for memory read/delete operations; omitting it falls back to server storage so threads/messages can be accessed without a specific agent. * Server now supports storage-backed retrieval when no agent is provided. * **Documentation** * Updated memory API docs to describe optional agentId and storage fallback behavior. * **Tests** * Expanded coverage for thread/message ops and storage-fallback scenarios. * **Chore** * Added changelog entry updating client/server versions. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 4a443a7 commit 106c960

File tree

11 files changed

+804
-93
lines changed

11 files changed

+804
-93
lines changed

.changeset/witty-plums-pull.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@mastra/client-js': patch
3+
'@mastra/server': patch
4+
---
5+
6+
Make agentId optional for memory read operations (getThread, listThreads, listMessages)
7+
8+
When workflows use multiple agents sharing the same threadId/resourceId, users can now retrieve threads and messages without specifying an agentId. The server falls back to using storage directly when agentId is not provided.

client-sdks/client-js/src/client.test.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,4 +321,140 @@ describe('MastraClient', () => {
321321
});
322322
});
323323
});
324+
325+
describe('Memory Thread Operations without agentId', () => {
326+
describe('listMemoryThreads', () => {
327+
it('should list threads with agentId', async () => {
328+
const mockThreads = {
329+
threads: [{ id: 'thread-1', title: 'Test' }],
330+
};
331+
(global.fetch as any).mockResolvedValueOnce({
332+
ok: true,
333+
status: 200,
334+
headers: { get: () => 'application/json' },
335+
json: async () => mockThreads,
336+
});
337+
338+
const result = await client.listMemoryThreads({
339+
agentId: 'agent-1',
340+
resourceId: 'resource-1',
341+
});
342+
343+
// Note: URL includes both resourceId and resourceid (lowercase) for backwards compatibility
344+
expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('/api/memory/threads?'), expect.any(Object));
345+
expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('agentId=agent-1'), expect.any(Object));
346+
expect(result).toEqual(mockThreads);
347+
});
348+
349+
it('should list threads without agentId (storage fallback)', async () => {
350+
const mockThreads = {
351+
threads: [{ id: 'thread-1', title: 'Test' }],
352+
};
353+
(global.fetch as any).mockResolvedValueOnce({
354+
ok: true,
355+
status: 200,
356+
headers: { get: () => 'application/json' },
357+
json: async () => mockThreads,
358+
});
359+
360+
const result = await client.listMemoryThreads({
361+
resourceId: 'resource-1',
362+
});
363+
364+
// URL should NOT include agentId when not provided
365+
const fetchCall = (global.fetch as any).mock.calls[0][0];
366+
expect(fetchCall).toContain('/api/memory/threads?');
367+
expect(fetchCall).toContain('resourceId=resource-1');
368+
expect(fetchCall).not.toContain('agentId=');
369+
expect(result).toEqual(mockThreads);
370+
});
371+
});
372+
373+
describe('getMemoryThread', () => {
374+
it('should get thread with agentId', async () => {
375+
const mockThread = { id: 'thread-1', title: 'Test' };
376+
(global.fetch as any).mockResolvedValueOnce({
377+
ok: true,
378+
status: 200,
379+
headers: { get: () => 'application/json' },
380+
json: async () => mockThread,
381+
});
382+
383+
const thread = client.getMemoryThread({
384+
agentId: 'agent-1',
385+
threadId: 'thread-1',
386+
});
387+
await thread.get();
388+
389+
expect(global.fetch).toHaveBeenCalledWith(
390+
'http://localhost:4111/api/memory/threads/thread-1?agentId=agent-1',
391+
expect.any(Object),
392+
);
393+
});
394+
395+
it('should get thread without agentId (storage fallback)', async () => {
396+
const mockThread = { id: 'thread-1', title: 'Test' };
397+
(global.fetch as any).mockResolvedValueOnce({
398+
ok: true,
399+
status: 200,
400+
headers: { get: () => 'application/json' },
401+
json: async () => mockThread,
402+
});
403+
404+
const thread = client.getMemoryThread({
405+
threadId: 'thread-1',
406+
});
407+
await thread.get();
408+
409+
expect(global.fetch).toHaveBeenCalledWith(
410+
'http://localhost:4111/api/memory/threads/thread-1',
411+
expect.any(Object),
412+
);
413+
});
414+
});
415+
416+
describe('listThreadMessages', () => {
417+
it('should list messages with agentId', async () => {
418+
const mockMessages = {
419+
messages: [{ id: 'msg-1', content: 'Hello' }],
420+
};
421+
(global.fetch as any).mockResolvedValueOnce({
422+
ok: true,
423+
status: 200,
424+
headers: { get: () => 'application/json' },
425+
json: async () => mockMessages,
426+
});
427+
428+
const result = await client.listThreadMessages('thread-1', {
429+
agentId: 'agent-1',
430+
});
431+
432+
expect(global.fetch).toHaveBeenCalledWith(
433+
'http://localhost:4111/api/memory/threads/thread-1/messages?agentId=agent-1',
434+
expect.any(Object),
435+
);
436+
expect(result).toEqual(mockMessages);
437+
});
438+
439+
it('should list messages without agentId (storage fallback)', async () => {
440+
const mockMessages = {
441+
messages: [{ id: 'msg-1', content: 'Hello' }],
442+
};
443+
(global.fetch as any).mockResolvedValueOnce({
444+
ok: true,
445+
status: 200,
446+
headers: { get: () => 'application/json' },
447+
json: async () => mockMessages,
448+
});
449+
450+
const result = await client.listThreadMessages('thread-1');
451+
452+
expect(global.fetch).toHaveBeenCalledWith(
453+
'http://localhost:4111/api/memory/threads/thread-1/messages',
454+
expect.any(Object),
455+
);
456+
expect(result).toEqual(mockMessages);
457+
});
458+
});
459+
});
324460
});

client-sdks/client-js/src/client.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export class MastraClient extends BaseResource {
111111
const queryParams = new URLSearchParams({
112112
resourceId: params.resourceId,
113113
resourceid: params.resourceId,
114-
agentId: params.agentId,
114+
...(params.agentId && { agentId: params.agentId }),
115115
...(params.page !== undefined && { page: params.page.toString() }),
116116
...(params.perPage !== undefined && { perPage: params.perPage.toString() }),
117117
...(params.orderBy && { orderBy: params.orderBy }),
@@ -162,24 +162,33 @@ export class MastraClient extends BaseResource {
162162
/**
163163
* Gets a memory thread instance by ID
164164
* @param threadId - ID of the memory thread to retrieve
165+
* @param agentId - Optional agent ID. When not provided, uses storage directly
165166
* @returns MemoryThread instance
166167
*/
167-
public getMemoryThread({ threadId, agentId }: { threadId: string; agentId: string }) {
168+
public getMemoryThread({ threadId, agentId }: { threadId: string; agentId?: string }) {
168169
return new MemoryThread(this.options, threadId, agentId);
169170
}
170171

172+
/**
173+
* Lists messages for a thread.
174+
* @param threadId - ID of the thread
175+
* @param opts - Optional parameters including agentId, networkId, and requestContext
176+
* - When agentId is provided, uses the agent's memory
177+
* - When networkId is provided, uses the network endpoint
178+
* - When neither is provided, uses storage directly
179+
* @returns Promise containing the thread messages
180+
*/
171181
public listThreadMessages(
172182
threadId: string,
173183
opts: { agentId?: string; networkId?: string; requestContext?: RequestContext | Record<string, any> } = {},
174184
): Promise<ListMemoryThreadMessagesResponse> {
175-
if (!opts.agentId && !opts.networkId) {
176-
throw new Error('Either agentId or networkId must be provided');
177-
}
178185
let url = '';
179-
if (opts.agentId) {
180-
url = `/api/memory/threads/${threadId}/messages?agentId=${opts.agentId}${requestContextQueryString(opts.requestContext, '&')}`;
181-
} else if (opts.networkId) {
186+
if (opts.networkId) {
182187
url = `/api/memory/network/threads/${threadId}/messages?networkId=${opts.networkId}${requestContextQueryString(opts.requestContext, '&')}`;
188+
} else if (opts.agentId) {
189+
url = `/api/memory/threads/${threadId}/messages?agentId=${opts.agentId}${requestContextQueryString(opts.requestContext, '&')}`;
190+
} else {
191+
url = `/api/memory/threads/${threadId}/messages${requestContextQueryString(opts.requestContext, '?')}`;
183192
}
184193
return this.request(url);
185194
}

client-sdks/client-js/src/resources/memory-thread.test.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,4 +282,173 @@ describe('MemoryThread', () => {
282282
await expect(thread.deleteMessages(messageIds)).rejects.toThrow();
283283
});
284284
});
285+
286+
describe('without agentId (storage fallback)', () => {
287+
let threadWithoutAgent: MemoryThread;
288+
289+
beforeEach(() => {
290+
vi.clearAllMocks();
291+
// Create MemoryThread without agentId - uses storage fallback on server
292+
threadWithoutAgent = new MemoryThread(clientOptions, threadId);
293+
});
294+
295+
it('should retrieve thread details without agentId in URL', async () => {
296+
const mockThread = {
297+
id: threadId,
298+
title: 'Test Thread',
299+
metadata: { test: true },
300+
createdAt: new Date().toISOString(),
301+
updatedAt: new Date().toISOString(),
302+
};
303+
304+
mockFetchResponse(mockThread);
305+
306+
const result = await threadWithoutAgent.get();
307+
308+
expect(global.fetch).toHaveBeenCalledWith(
309+
`http://localhost:4111/api/memory/threads/${threadId}`,
310+
expect.objectContaining({
311+
headers: expect.objectContaining({
312+
Authorization: 'Bearer test-key',
313+
}),
314+
}),
315+
);
316+
expect(result).toEqual(mockThread);
317+
});
318+
319+
it('should retrieve thread messages without agentId in URL', async () => {
320+
const mockMessages = {
321+
messages: [
322+
{ id: 'msg-1', content: 'Hello', role: 'user' },
323+
{ id: 'msg-2', content: 'Hi there', role: 'assistant' },
324+
],
325+
uiMessages: [
326+
{ id: 'msg-1', content: 'Hello', role: 'user' },
327+
{ id: 'msg-2', content: 'Hi there', role: 'assistant' },
328+
],
329+
};
330+
331+
mockFetchResponse(mockMessages);
332+
333+
const result = await threadWithoutAgent.listMessages();
334+
335+
expect(global.fetch).toHaveBeenCalledWith(
336+
`http://localhost:4111/api/memory/threads/${threadId}/messages`,
337+
expect.objectContaining({
338+
headers: expect.objectContaining({
339+
Authorization: 'Bearer test-key',
340+
}),
341+
}),
342+
);
343+
expect(result).toEqual(mockMessages);
344+
});
345+
346+
it('should update thread without agentId in URL', async () => {
347+
const updateParams = {
348+
title: 'Updated Title',
349+
metadata: { updated: true },
350+
resourceId: 'resource-1',
351+
};
352+
353+
const mockUpdatedThread = {
354+
id: threadId,
355+
title: updateParams.title,
356+
metadata: updateParams.metadata,
357+
resourceId: updateParams.resourceId,
358+
createdAt: new Date().toISOString(),
359+
updatedAt: new Date().toISOString(),
360+
};
361+
362+
mockFetchResponse(mockUpdatedThread);
363+
364+
const result = await threadWithoutAgent.update(updateParams);
365+
366+
expect(global.fetch).toHaveBeenCalledWith(
367+
`http://localhost:4111/api/memory/threads/${threadId}`,
368+
expect.objectContaining({
369+
method: 'PATCH',
370+
headers: expect.objectContaining({
371+
Authorization: 'Bearer test-key',
372+
}),
373+
body: JSON.stringify(updateParams),
374+
}),
375+
);
376+
expect(result).toEqual(mockUpdatedThread);
377+
});
378+
379+
it('should delete thread without agentId in URL', async () => {
380+
const mockResponse = { result: 'Thread deleted' };
381+
382+
mockFetchResponse(mockResponse);
383+
384+
const result = await threadWithoutAgent.delete();
385+
386+
expect(global.fetch).toHaveBeenCalledWith(
387+
`http://localhost:4111/api/memory/threads/${threadId}`,
388+
expect.objectContaining({
389+
method: 'DELETE',
390+
headers: expect.objectContaining({
391+
Authorization: 'Bearer test-key',
392+
}),
393+
}),
394+
);
395+
expect(result).toEqual(mockResponse);
396+
});
397+
398+
it('should clone thread without agentId in URL', async () => {
399+
const cloneParams = {
400+
newThreadId: 'cloned-thread-id',
401+
newTitle: 'Cloned Thread',
402+
newMetadata: { cloned: true },
403+
};
404+
405+
const mockCloneResponse = {
406+
thread: {
407+
id: cloneParams.newThreadId,
408+
title: cloneParams.newTitle,
409+
metadata: cloneParams.newMetadata,
410+
createdAt: new Date().toISOString(),
411+
updatedAt: new Date().toISOString(),
412+
},
413+
messages: [{ id: 'cloned-msg-1', content: 'Hello', role: 'user' }],
414+
};
415+
416+
mockFetchResponse(mockCloneResponse);
417+
418+
const result = await threadWithoutAgent.clone(cloneParams);
419+
420+
expect(global.fetch).toHaveBeenCalledWith(
421+
`http://localhost:4111/api/memory/threads/${threadId}/clone`,
422+
expect.objectContaining({
423+
method: 'POST',
424+
headers: expect.objectContaining({
425+
Authorization: 'Bearer test-key',
426+
}),
427+
body: JSON.stringify(cloneParams),
428+
}),
429+
);
430+
expect(result).toEqual(mockCloneResponse);
431+
});
432+
433+
it('should delete messages without agentId in URL', async () => {
434+
const messageIds = ['msg-1', 'msg-2'];
435+
const mockResponse = { success: true, message: '2 messages deleted successfully' };
436+
437+
mockFetchResponse(mockResponse);
438+
439+
const result = await threadWithoutAgent.deleteMessages(messageIds);
440+
441+
expect(global.fetch).toHaveBeenCalledWith(
442+
`http://localhost:4111/api/memory/messages/delete`,
443+
expect.objectContaining({
444+
method: 'POST',
445+
headers: expect.objectContaining({
446+
Authorization: 'Bearer test-key',
447+
}),
448+
body: JSON.stringify({ messageIds }),
449+
}),
450+
);
451+
expect(result).toEqual(mockResponse);
452+
});
453+
});
285454
});

0 commit comments

Comments
 (0)