From 3628a992283bfc5b2acd3b3de930e47d16669336 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Mon, 4 May 2026 16:13:35 -0400 Subject: [PATCH 1/2] chore: update namespace design for data plane --- src/cli/commands/dev/browser-mode.ts | 30 +- .../agent/import/__tests__/translator.test.ts | 46 ++++ .../agent/import/langgraph-translator.ts | 6 +- .../agent/import/strands-translator.ts | 6 +- .../dev/web-ui/__tests__/memory.test.ts | 260 ++++++++++++++++++ .../operations/dev/web-ui/handlers/memory.ts | 58 +++- src/cli/operations/dev/web-ui/web-server.ts | 30 +- .../operations/memory/list-memory-records.ts | 32 ++- .../memory/retrieve-memory-records.ts | 32 ++- 9 files changed, 455 insertions(+), 45 deletions(-) create mode 100644 src/cli/operations/dev/web-ui/__tests__/memory.test.ts diff --git a/src/cli/commands/dev/browser-mode.ts b/src/cli/commands/dev/browser-mode.ts index c35113e6d..d92e38df5 100644 --- a/src/cli/commands/dev/browser-mode.ts +++ b/src/cli/commands/dev/browser-mode.ts @@ -60,26 +60,26 @@ async function resolveDeployedHandlers( `Memory browsing enabled for ${memories.length} deployed memory(ies): ${memories.map(m => m.name).join(', ')}` ); - result.onListMemoryRecords = async (memoryName, namespace, strategyId) => { - const memory = memories.find(m => m.name === memoryName); - if (!memory) return { success: false, error: `Memory "${memoryName}" not found in deployed state` }; + result.onListMemoryRecords = async args => { + const memory = memories.find(m => m.name === args.memoryName); + if (!memory) return { success: false, error: `Memory "${args.memoryName}" not found in deployed state` }; return listMemoryRecords({ region: memory.region, memoryId: memory.memoryId, - namespace, - memoryStrategyId: strategyId, + memoryStrategyId: args.strategyId, + ...(args.namespace !== undefined ? { namespace: args.namespace } : { namespacePath: args.namespacePath }), }); }; - result.onRetrieveMemoryRecords = async (memoryName, namespace, searchQuery, strategyId) => { - const memory = memories.find(m => m.name === memoryName); - if (!memory) return { success: false, error: `Memory "${memoryName}" not found in deployed state` }; + result.onRetrieveMemoryRecords = async args => { + const memory = memories.find(m => m.name === args.memoryName); + if (!memory) return { success: false, error: `Memory "${args.memoryName}" not found in deployed state` }; return retrieveMemoryRecords({ region: memory.region, memoryId: memory.memoryId, - namespace, - searchQuery, - memoryStrategyId: strategyId, + searchQuery: args.searchQuery, + memoryStrategyId: args.strategyId, + ...(args.namespace !== undefined ? { namespace: args.namespace } : { namespacePath: args.namespacePath }), }); }; } @@ -235,15 +235,15 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise { }; } }, - onListMemoryRecords: async (memoryName, namespace, strategyId) => { + onListMemoryRecords: async args => { const deployed = await resolveDeployedHandlers(baseDir, onLog); if (!deployed.onListMemoryRecords) return { success: false, error: 'No deployed AgentCore Memory found' }; - return deployed.onListMemoryRecords(memoryName, namespace, strategyId); + return deployed.onListMemoryRecords(args); }, - onRetrieveMemoryRecords: async (memoryName, namespace, searchQuery, strategyId) => { + onRetrieveMemoryRecords: async args => { const deployed = await resolveDeployedHandlers(baseDir, onLog); if (!deployed.onRetrieveMemoryRecords) return { success: false, error: 'No deployed AgentCore Memory found' }; - return deployed.onRetrieveMemoryRecords(memoryName, namespace, searchQuery, strategyId); + return deployed.onRetrieveMemoryRecords(args); }, }, }); diff --git a/src/cli/operations/agent/import/__tests__/translator.test.ts b/src/cli/operations/agent/import/__tests__/translator.test.ts index ea4f323b1..c3725ac6d 100644 --- a/src/cli/operations/agent/import/__tests__/translator.test.ts +++ b/src/cli/operations/agent/import/__tests__/translator.test.ts @@ -81,6 +81,29 @@ describe('StrandsTranslator', () => { expect(result.features.hasMemory).toBe(true); }); + it('emits namespace_path (not namespace) in retrieve_memories calls for longAndShortTerm memory', () => { + const config = makeSimpleAgentConfig({ + agent: { + ...makeSimpleAgentConfig().agent, + memoryConfiguration: { enabledMemoryTypes: ['SESSION_SUMMARY'] }, + }, + }); + const translator = new StrandsTranslator(config, { + agentConfig: config, + enableMemory: true, + memoryOption: 'longAndShortTerm', + enableObservability: false, + }); + const result = translator.translate(); + + // All three retrieval calls should use the new namespace_path kwarg + expect(result.mainPyContent).toContain("namespace_path=f'/users/{user_id}/facts'"); + expect(result.mainPyContent).toContain("namespace_path=f'/users/{user_id}/preferences'"); + expect(result.mainPyContent).toContain("namespace_path=f'/summaries/{user_id}/'"); + // The deprecated kwarg form must not appear for longAndShortTerm retrievals + expect(result.mainPyContent).not.toMatch(/retrieve_memories\([^)]*\bnamespace=/); + }); + it('generates action group tools for function-schema action groups', () => { const config = makeSimpleAgentConfig({ action_groups: [ @@ -221,6 +244,29 @@ describe('LangGraphTranslator', () => { expect(result.mainPyContent).toContain('gr-123'); expect(result.features.hasGuardrails).toBe(true); }); + + it('emits namespace_path (not namespace) in retrieve_memories calls for longAndShortTerm memory', () => { + const config = makeSimpleAgentConfig({ + agent: { + ...makeSimpleAgentConfig().agent, + memoryConfiguration: { enabledMemoryTypes: ['SESSION_SUMMARY'] }, + }, + }); + const translator = new LangGraphTranslator(config, { + agentConfig: config, + enableMemory: true, + memoryOption: 'longAndShortTerm', + enableObservability: false, + }); + const result = translator.translate(); + + // All three retrieval calls should use the new namespace_path kwarg + expect(result.mainPyContent).toContain("namespace_path=f'/users/{user_id}/facts'"); + expect(result.mainPyContent).toContain("namespace_path=f'/users/{user_id}/preferences'"); + expect(result.mainPyContent).toContain("namespace_path=f'/summaries/{user_id}/'"); + // The deprecated kwarg form must not appear for longAndShortTerm retrievals + expect(result.mainPyContent).not.toMatch(/retrieve_memories\([^)]*\bnamespace=/); + }); }); describe('generatePyprojectToml', () => { diff --git a/src/cli/operations/agent/import/langgraph-translator.ts b/src/cli/operations/agent/import/langgraph-translator.ts index 89436e92a..c6d9038ad 100644 --- a/src/cli/operations/agent/import/langgraph-translator.ts +++ b/src/cli/operations/agent/import/langgraph-translator.ts @@ -209,9 +209,9 @@ def invoke_${collabName}(query: str, state: Annotated[dict, InjectedState]) -> s const memoryRetrieveCode = this.agentcoreMemoryEnabled && this.hasLongTermStrategies ? ` - semantic_memories = memory_client.retrieve_memories(memory_id=memory_id, namespace=f'/users/{user_id}/facts', query="Retrieve relevant facts.", actor_id=user_id, top_k=3) - pref_memories = memory_client.retrieve_memories(memory_id=memory_id, namespace=f'/users/{user_id}/preferences', query="Retrieve user preferences.", actor_id=user_id, top_k=3) - summary_memories = memory_client.retrieve_memories(memory_id=memory_id, namespace=f'/summaries/{user_id}/', query="Retrieve the most recent session summaries.", actor_id=user_id, top_k=3) + semantic_memories = memory_client.retrieve_memories(memory_id=memory_id, namespace_path=f'/users/{user_id}/facts', query="Retrieve relevant facts.", actor_id=user_id, top_k=3) + pref_memories = memory_client.retrieve_memories(memory_id=memory_id, namespace_path=f'/users/{user_id}/preferences', query="Retrieve user preferences.", actor_id=user_id, top_k=3) + summary_memories = memory_client.retrieve_memories(memory_id=memory_id, namespace_path=f'/summaries/{user_id}/', query="Retrieve the most recent session summaries.", actor_id=user_id, top_k=3) all_memories = semantic_memories + pref_memories + summary_memories memory_synopsis = "\\n".join([m.get("content", {}).get("text", "") for m in all_memories])` : this.memoryEnabled diff --git a/src/cli/operations/agent/import/strands-translator.ts b/src/cli/operations/agent/import/strands-translator.ts index fff4a5274..cece610f8 100644 --- a/src/cli/operations/agent/import/strands-translator.ts +++ b/src/cli/operations/agent/import/strands-translator.ts @@ -185,9 +185,9 @@ def invoke_${collabName}(query: str) -> str: const memoryRetrieveLines = this.agentcoreMemoryEnabled && this.hasLongTermStrategies ? [ - ' semantic_memories = memory_client.retrieve_memories(memory_id=memory_id, namespace=f\'/users/{user_id}/facts\', query="Retrieve relevant facts.", actor_id=user_id, top_k=3)', - ' pref_memories = memory_client.retrieve_memories(memory_id=memory_id, namespace=f\'/users/{user_id}/preferences\', query="Retrieve user preferences.", actor_id=user_id, top_k=3)', - ' summary_memories = memory_client.retrieve_memories(memory_id=memory_id, namespace=f\'/summaries/{user_id}/\', query="Retrieve the most recent session summaries.", actor_id=user_id, top_k=3)', + ' semantic_memories = memory_client.retrieve_memories(memory_id=memory_id, namespace_path=f\'/users/{user_id}/facts\', query="Retrieve relevant facts.", actor_id=user_id, top_k=3)', + ' pref_memories = memory_client.retrieve_memories(memory_id=memory_id, namespace_path=f\'/users/{user_id}/preferences\', query="Retrieve user preferences.", actor_id=user_id, top_k=3)', + ' summary_memories = memory_client.retrieve_memories(memory_id=memory_id, namespace_path=f\'/summaries/{user_id}/\', query="Retrieve the most recent session summaries.", actor_id=user_id, top_k=3)', ' all_memories = semantic_memories + pref_memories + summary_memories', ' memory_synopsis = "\\n".join([m.get("content", {}).get("text", "") for m in all_memories])', ] diff --git a/src/cli/operations/dev/web-ui/__tests__/memory.test.ts b/src/cli/operations/dev/web-ui/__tests__/memory.test.ts new file mode 100644 index 000000000..f8646adee --- /dev/null +++ b/src/cli/operations/dev/web-ui/__tests__/memory.test.ts @@ -0,0 +1,260 @@ +import { handleListMemoryRecords, handleRetrieveMemoryRecords } from '../handlers/memory.js'; +import type { RouteContext } from '../handlers/route-context.js'; +import type { IncomingMessage, ServerResponse } from 'http'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +function mockRes(): ServerResponse & { _status: number; _headers: Record; _body: string } { + const res = { + _status: 0, + _headers: {} as Record, + _body: '', + writeHead(status: number, headers?: Record) { + res._status = status; + if (headers) Object.assign(res._headers, headers); + return res; + }, + setHeader(name: string, value: string) { + res._headers[name] = value; + }, + end(body?: string) { + if (body) res._body = body; + }, + }; + return res as unknown as ServerResponse & { _status: number; _headers: Record; _body: string }; +} + +function mockReq(url: string): IncomingMessage { + return { url, headers: { host: 'localhost:8081' } } as unknown as IncomingMessage; +} + +function mockCtx(overrides: Partial = {}, bodyValue?: string): RouteContext { + return { + options: { + mode: 'dev', + agents: [], + harnesses: [], + uiPort: 8081, + ...overrides, + }, + runningAgents: new Map(), + startingAgents: new Map(), + agentErrors: new Map(), + setCorsHeaders: vi.fn(), + readBody: bodyValue !== undefined ? vi.fn().mockResolvedValue(bodyValue) : vi.fn(), + } as RouteContext; +} + +describe('handleListMemoryRecords', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('returns 404 when no handler is wired', async () => { + const ctx = mockCtx(); + const req = mockReq('/api/memory?memoryName=m&namespace=/a/'); + const res = mockRes(); + + await handleListMemoryRecords(ctx, req, res); + + expect(res._status).toBe(404); + expect(JSON.parse(res._body).error).toContain('not available'); + }); + + it('returns 400 when memoryName is missing', async () => { + const onListMemoryRecords = vi.fn(); + const ctx = mockCtx({ onListMemoryRecords }); + const req = mockReq('/api/memory?namespace=/a/'); + const res = mockRes(); + + await handleListMemoryRecords(ctx, req, res); + + expect(res._status).toBe(400); + expect(JSON.parse(res._body).error).toContain('memoryName'); + expect(onListMemoryRecords).not.toHaveBeenCalled(); + }); + + it('returns 400 when both namespace and namespacePath are provided', async () => { + const onListMemoryRecords = vi.fn(); + const ctx = mockCtx({ onListMemoryRecords }); + const req = mockReq('/api/memory?memoryName=m&namespace=/a/&namespacePath=/b/'); + const res = mockRes(); + + await handleListMemoryRecords(ctx, req, res); + + expect(res._status).toBe(400); + expect(JSON.parse(res._body).error).toContain('mutually exclusive'); + expect(onListMemoryRecords).not.toHaveBeenCalled(); + }); + + it('returns 400 when neither namespace nor namespacePath is provided', async () => { + const onListMemoryRecords = vi.fn(); + const ctx = mockCtx({ onListMemoryRecords }); + const req = mockReq('/api/memory?memoryName=m'); + const res = mockRes(); + + await handleListMemoryRecords(ctx, req, res); + + expect(res._status).toBe(400); + expect(JSON.parse(res._body).error).toContain("either 'namespace' or 'namespacePath'"); + expect(onListMemoryRecords).not.toHaveBeenCalled(); + }); + + it('forwards namespace to handler when only namespace is provided', async () => { + const onListMemoryRecords = vi.fn().mockResolvedValue({ success: true, records: [] }); + const ctx = mockCtx({ onListMemoryRecords }); + const req = mockReq('/api/memory?memoryName=m&namespace=/exact/'); + const res = mockRes(); + + await handleListMemoryRecords(ctx, req, res); + + expect(res._status).toBe(200); + expect(onListMemoryRecords).toHaveBeenCalledWith({ + memoryName: 'm', + strategyId: undefined, + namespace: '/exact/', + }); + }); + + it('forwards namespacePath to handler when only namespacePath is provided', async () => { + const onListMemoryRecords = vi.fn().mockResolvedValue({ success: true, records: [] }); + const ctx = mockCtx({ onListMemoryRecords }); + const req = mockReq('/api/memory?memoryName=m&namespacePath=/prefix/'); + const res = mockRes(); + + await handleListMemoryRecords(ctx, req, res); + + expect(res._status).toBe(200); + expect(onListMemoryRecords).toHaveBeenCalledWith({ + memoryName: 'm', + strategyId: undefined, + namespacePath: '/prefix/', + }); + }); + + it('forwards strategyId when provided', async () => { + const onListMemoryRecords = vi.fn().mockResolvedValue({ success: true, records: [] }); + const ctx = mockCtx({ onListMemoryRecords }); + const req = mockReq('/api/memory?memoryName=m&namespace=/a/&strategyId=s-1'); + const res = mockRes(); + + await handleListMemoryRecords(ctx, req, res); + + expect(onListMemoryRecords).toHaveBeenCalledWith({ + memoryName: 'm', + strategyId: 's-1', + namespace: '/a/', + }); + }); +}); + +describe('handleRetrieveMemoryRecords', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('returns 404 when no handler is wired', async () => { + const ctx = mockCtx({}, JSON.stringify({ memoryName: 'm', namespace: '/a/', searchQuery: 'q' })); + const req = mockReq('/api/memory/search'); + const res = mockRes(); + + await handleRetrieveMemoryRecords(ctx, req, res); + + expect(res._status).toBe(404); + expect(JSON.parse(res._body).error).toContain('not available'); + }); + + it('returns 400 when memoryName is missing', async () => { + const onRetrieveMemoryRecords = vi.fn(); + const ctx = mockCtx({ onRetrieveMemoryRecords }, JSON.stringify({ namespace: '/a/', searchQuery: 'q' })); + const req = mockReq('/api/memory/search'); + const res = mockRes(); + + await handleRetrieveMemoryRecords(ctx, req, res); + + expect(res._status).toBe(400); + expect(JSON.parse(res._body).error).toContain('memoryName'); + expect(onRetrieveMemoryRecords).not.toHaveBeenCalled(); + }); + + it('returns 400 when both namespace and namespacePath are in the body', async () => { + const onRetrieveMemoryRecords = vi.fn(); + const ctx = mockCtx( + { onRetrieveMemoryRecords }, + JSON.stringify({ memoryName: 'm', namespace: '/a/', namespacePath: '/b/', searchQuery: 'q' }) + ); + const req = mockReq('/api/memory/search'); + const res = mockRes(); + + await handleRetrieveMemoryRecords(ctx, req, res); + + expect(res._status).toBe(400); + expect(JSON.parse(res._body).error).toContain('mutually exclusive'); + expect(onRetrieveMemoryRecords).not.toHaveBeenCalled(); + }); + + it('returns 400 when neither namespace nor namespacePath is in the body', async () => { + const onRetrieveMemoryRecords = vi.fn(); + const ctx = mockCtx({ onRetrieveMemoryRecords }, JSON.stringify({ memoryName: 'm', searchQuery: 'q' })); + const req = mockReq('/api/memory/search'); + const res = mockRes(); + + await handleRetrieveMemoryRecords(ctx, req, res); + + expect(res._status).toBe(400); + expect(JSON.parse(res._body).error).toContain("either 'namespace' or 'namespacePath'"); + expect(onRetrieveMemoryRecords).not.toHaveBeenCalled(); + }); + + it('returns 400 when searchQuery is missing', async () => { + const onRetrieveMemoryRecords = vi.fn(); + const ctx = mockCtx({ onRetrieveMemoryRecords }, JSON.stringify({ memoryName: 'm', namespace: '/a/' })); + const req = mockReq('/api/memory/search'); + const res = mockRes(); + + await handleRetrieveMemoryRecords(ctx, req, res); + + expect(res._status).toBe(400); + expect(JSON.parse(res._body).error).toContain('searchQuery'); + expect(onRetrieveMemoryRecords).not.toHaveBeenCalled(); + }); + + it('forwards namespace to handler when only namespace is provided', async () => { + const onRetrieveMemoryRecords = vi.fn().mockResolvedValue({ success: true, records: [] }); + const ctx = mockCtx( + { onRetrieveMemoryRecords }, + JSON.stringify({ memoryName: 'm', namespace: '/exact/', searchQuery: 'q' }) + ); + const req = mockReq('/api/memory/search'); + const res = mockRes(); + + await handleRetrieveMemoryRecords(ctx, req, res); + + expect(res._status).toBe(200); + expect(onRetrieveMemoryRecords).toHaveBeenCalledWith({ + memoryName: 'm', + searchQuery: 'q', + strategyId: undefined, + namespace: '/exact/', + }); + }); + + it('forwards namespacePath to handler when only namespacePath is provided', async () => { + const onRetrieveMemoryRecords = vi.fn().mockResolvedValue({ success: true, records: [] }); + const ctx = mockCtx( + { onRetrieveMemoryRecords }, + JSON.stringify({ memoryName: 'm', namespacePath: '/prefix/', searchQuery: 'q' }) + ); + const req = mockReq('/api/memory/search'); + const res = mockRes(); + + await handleRetrieveMemoryRecords(ctx, req, res); + + expect(res._status).toBe(200); + expect(onRetrieveMemoryRecords).toHaveBeenCalledWith({ + memoryName: 'm', + searchQuery: 'q', + strategyId: undefined, + namespacePath: '/prefix/', + }); + }); +}); diff --git a/src/cli/operations/dev/web-ui/handlers/memory.ts b/src/cli/operations/dev/web-ui/handlers/memory.ts index 5a01c9139..ee00744de 100644 --- a/src/cli/operations/dev/web-ui/handlers/memory.ts +++ b/src/cli/operations/dev/web-ui/handlers/memory.ts @@ -3,8 +3,11 @@ import { parseRequestUrl } from './route-context'; import type { IncomingMessage, ServerResponse } from 'node:http'; /** - * GET /api/memory?memoryName=xxx&namespace=yyy[&strategyId=zzz] + * GET /api/memory?memoryName=xxx&(namespace=yyy|namespacePath=yyy)[&strategyId=zzz] * Lists memory records. Requires onListMemoryRecords handler. + * + * Exactly one of `namespace` (exact match) or `namespacePath` (hierarchical path prefix) + * must be provided. */ export async function handleListMemoryRecords( ctx: RouteContext, @@ -22,6 +25,7 @@ export async function handleListMemoryRecords( const { param } = parseRequestUrl(req); const memoryName = param('memoryName'); const namespace = param('namespace'); + const namespacePath = param('namespacePath'); const strategyId = param('strategyId'); if (!memoryName) { @@ -31,15 +35,36 @@ export async function handleListMemoryRecords( return; } - if (!namespace) { + if (namespace && namespacePath) { ctx.setCorsHeaders(res, origin); res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ success: false, error: 'namespace query parameter is required' })); + res.end( + JSON.stringify({ + success: false, + error: "'namespace' and 'namespacePath' query parameters are mutually exclusive", + }) + ); + return; + } + + if (!namespace && !namespacePath) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + success: false, + error: "either 'namespace' or 'namespacePath' query parameter is required", + }) + ); return; } try { - const result = await ctx.options.onListMemoryRecords(memoryName, namespace, strategyId); + const result = await ctx.options.onListMemoryRecords({ + memoryName, + strategyId, + ...(namespace ? { namespace } : { namespacePath: namespacePath! }), + }); ctx.setCorsHeaders(res, origin); res.writeHead(result.success ? 200 : 500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); @@ -53,8 +78,10 @@ export async function handleListMemoryRecords( /** * POST /api/memory/search — semantic search across memory records. - * Body: { memoryName, namespace, searchQuery, strategyId? } + * Body: { memoryName, namespace | namespacePath, searchQuery, strategyId? } * Requires onRetrieveMemoryRecords handler. + * + * Exactly one of `namespace` or `namespacePath` must be provided. */ export async function handleRetrieveMemoryRecords( ctx: RouteContext, @@ -72,6 +99,7 @@ export async function handleRetrieveMemoryRecords( const body = await ctx.readBody(req); let memoryName: string | undefined; let namespace: string | undefined; + let namespacePath: string | undefined; let searchQuery: string | undefined; let strategyId: string | undefined; @@ -79,11 +107,13 @@ export async function handleRetrieveMemoryRecords( const parsed = JSON.parse(body) as { memoryName?: string; namespace?: string; + namespacePath?: string; searchQuery?: string; strategyId?: string; }; memoryName = parsed.memoryName; namespace = parsed.namespace; + namespacePath = parsed.namespacePath; searchQuery = parsed.searchQuery; strategyId = parsed.strategyId; } catch { @@ -97,10 +127,17 @@ export async function handleRetrieveMemoryRecords( return; } - if (!namespace) { + if (namespace && namespacePath) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: "'namespace' and 'namespacePath' are mutually exclusive" })); + return; + } + + if (!namespace && !namespacePath) { ctx.setCorsHeaders(res, origin); res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ success: false, error: 'namespace is required' })); + res.end(JSON.stringify({ success: false, error: "either 'namespace' or 'namespacePath' is required" })); return; } @@ -112,7 +149,12 @@ export async function handleRetrieveMemoryRecords( } try { - const result = await ctx.options.onRetrieveMemoryRecords(memoryName, namespace, searchQuery, strategyId); + const result = await ctx.options.onRetrieveMemoryRecords({ + memoryName, + searchQuery, + strategyId, + ...(namespace ? { namespace } : { namespacePath: namespacePath! }), + }); ctx.setCorsHeaders(res, origin); res.writeHead(result.success ? 200 : 500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); diff --git a/src/cli/operations/dev/web-ui/web-server.ts b/src/cli/operations/dev/web-ui/web-server.ts index 2b20b2d07..fe845194f 100644 --- a/src/cli/operations/dev/web-ui/web-server.ts +++ b/src/cli/operations/dev/web-ui/web-server.ts @@ -103,25 +103,39 @@ export type GetCloudWatchTraceHandler = ( endTime?: number ) => Promise<{ success: boolean; records?: unknown[]; spans?: unknown[]; error?: string }>; +/** + * Arguments for {@link ListMemoryRecordsHandler}. Exactly one of `namespace` (exact match) + * or `namespacePath` (hierarchical path prefix) must be provided. + */ +export type ListMemoryRecordsArgs = { + memoryName: string; + strategyId?: string; +} & ({ namespace: string; namespacePath?: never } | { namespace?: never; namespacePath: string }); + /** * Custom handler for GET /api/memory. - * Returns a list of memory records for a given memory + namespace. + * Returns a list of memory records for a given memory + namespace/namespacePath. */ export type ListMemoryRecordsHandler = ( - memoryName: string, - namespace: string, - strategyId?: string + args: ListMemoryRecordsArgs ) => Promise<{ success: boolean; records?: unknown[]; error?: string }>; +/** + * Arguments for {@link RetrieveMemoryRecordsHandler}. Exactly one of `namespace` (exact match) + * or `namespacePath` (hierarchical path prefix) must be provided. + */ +export type RetrieveMemoryRecordsArgs = { + memoryName: string; + searchQuery: string; + strategyId?: string; +} & ({ namespace: string; namespacePath?: never } | { namespace?: never; namespacePath: string }); + /** * Custom handler for POST /api/memory/search. * Performs semantic search across memory records. */ export type RetrieveMemoryRecordsHandler = ( - memoryName: string, - namespace: string, - searchQuery: string, - strategyId?: string + args: RetrieveMemoryRecordsArgs ) => Promise<{ success: boolean; records?: unknown[]; error?: string }>; export interface WebUIOptions { diff --git a/src/cli/operations/memory/list-memory-records.ts b/src/cli/operations/memory/list-memory-records.ts index 8bbf34628..a077b05b7 100644 --- a/src/cli/operations/memory/list-memory-records.ts +++ b/src/cli/operations/memory/list-memory-records.ts @@ -11,15 +11,26 @@ export interface MemoryRecordEntry { metadata: Record; } -export interface ListMemoryRecordsOptions { +/** + * Base options for listing memory records, excluding the namespace filter. + * @internal + */ +interface ListMemoryRecordsOptionsBase { region: string; memoryId: string; - namespace: string; memoryStrategyId?: string; maxResults?: number; nextToken?: string; } +/** + * Options for listing memory records. Exactly one of `namespace` (exact match) or + * `namespacePath` (hierarchical path prefix) must be provided. + */ +export type ListMemoryRecordsOptions = + | (ListMemoryRecordsOptionsBase & { namespace: string; namespacePath?: never }) + | (ListMemoryRecordsOptionsBase & { namespace?: never; namespacePath: string }); + export interface ListMemoryRecordsResult { success: boolean; records?: MemoryRecordEntry[]; @@ -29,9 +40,22 @@ export interface ListMemoryRecordsResult { /** * Lists memory records for a deployed memory resource via the AWS SDK. + * + * Exactly one of `namespace` (exact match) or `namespacePath` (hierarchical path prefix) + * must be provided. */ export async function listMemoryRecords(options: ListMemoryRecordsOptions): Promise { - const { region, memoryId, namespace, memoryStrategyId, maxResults = 50, nextToken } = options; + const { region, memoryId, namespace, namespacePath, memoryStrategyId, maxResults = 50, nextToken } = options; + + // Defensive runtime check — the discriminated union enforces this at compile time, but we + // also validate at runtime to protect against callers bypassing the type system (e.g. JSON + // input from web UI handlers). + if (namespace !== undefined && namespacePath !== undefined) { + return { success: false, error: "'namespace' and 'namespacePath' are mutually exclusive." }; + } + if (namespace === undefined && namespacePath === undefined) { + return { success: false, error: "Either 'namespace' or 'namespacePath' must be provided." }; + } const client = new BedrockAgentCoreClient({ region, @@ -42,7 +66,7 @@ export async function listMemoryRecords(options: ListMemoryRecordsOptions): Prom const response = await client.send( new ListMemoryRecordsCommand({ memoryId, - namespace, + ...(namespace !== undefined ? { namespace } : { namespacePath }), memoryStrategyId, maxResults, nextToken, diff --git a/src/cli/operations/memory/retrieve-memory-records.ts b/src/cli/operations/memory/retrieve-memory-records.ts index e8d2a65ed..11f347a75 100644 --- a/src/cli/operations/memory/retrieve-memory-records.ts +++ b/src/cli/operations/memory/retrieve-memory-records.ts @@ -2,10 +2,13 @@ import { getCredentialProvider } from '../../aws'; import type { MemoryRecordEntry } from './list-memory-records'; import { BedrockAgentCoreClient, RetrieveMemoryRecordsCommand } from '@aws-sdk/client-bedrock-agentcore'; -export interface RetrieveMemoryRecordsOptions { +/** + * Base options for retrieving memory records, excluding the namespace filter. + * @internal + */ +interface RetrieveMemoryRecordsOptionsBase { region: string; memoryId: string; - namespace: string; searchQuery: string; memoryStrategyId?: string; topK?: number; @@ -13,6 +16,14 @@ export interface RetrieveMemoryRecordsOptions { nextToken?: string; } +/** + * Options for retrieving memory records. Exactly one of `namespace` (exact match) or + * `namespacePath` (hierarchical path prefix) must be provided. + */ +export type RetrieveMemoryRecordsOptions = + | (RetrieveMemoryRecordsOptionsBase & { namespace: string; namespacePath?: never }) + | (RetrieveMemoryRecordsOptionsBase & { namespace?: never; namespacePath: string }); + export interface RetrieveMemoryRecordsResult { success: boolean; records?: MemoryRecordEntry[]; @@ -22,11 +33,24 @@ export interface RetrieveMemoryRecordsResult { /** * Searches memory records using semantic retrieval via the AWS SDK. + * + * Exactly one of `namespace` (exact match) or `namespacePath` (hierarchical path prefix) + * must be provided. */ export async function retrieveMemoryRecords( options: RetrieveMemoryRecordsOptions ): Promise { - const { region, memoryId, namespace, searchQuery, memoryStrategyId, topK, maxResults, nextToken } = options; + const { region, memoryId, namespace, namespacePath, searchQuery, memoryStrategyId, topK, maxResults, nextToken } = + options; + + // Defensive runtime check — the discriminated union enforces this at compile time, but we + // also validate at runtime to protect against callers bypassing the type system. + if (namespace !== undefined && namespacePath !== undefined) { + return { success: false, error: "'namespace' and 'namespacePath' are mutually exclusive." }; + } + if (namespace === undefined && namespacePath === undefined) { + return { success: false, error: "Either 'namespace' or 'namespacePath' must be provided." }; + } const client = new BedrockAgentCoreClient({ region, @@ -37,7 +61,7 @@ export async function retrieveMemoryRecords( const response = await client.send( new RetrieveMemoryRecordsCommand({ memoryId, - namespace, + ...(namespace !== undefined ? { namespace } : { namespacePath }), searchCriteria: { searchQuery, memoryStrategyId, From 3f47058f0f28f31c3b6d8e12fecbfed6f41549a5 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Tue, 5 May 2026 11:23:53 -0400 Subject: [PATCH 2/2] fix: add missing api changes --- src/cli/operations/dev/web-ui/README.md | 15 +++++++++++++-- src/cli/operations/dev/web-ui/api-types.ts | 21 ++++++++++++++++----- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/cli/operations/dev/web-ui/README.md b/src/cli/operations/dev/web-ui/README.md index c33f0ea0c..57d474b80 100644 --- a/src/cli/operations/dev/web-ui/README.md +++ b/src/cli/operations/dev/web-ui/README.md @@ -209,10 +209,13 @@ Response: { "success": true, "spans": [...] } ``` -### `GET /api/memory?memoryName=xxx&namespace=yyy[&strategyId=zzz]` +### `GET /api/memory?memoryName=xxx&(namespace=yyy|namespacePath=yyy)[&strategyId=zzz]` Lists memory records for a given memory and namespace. Requires a deployed memory with `onListMemoryRecords` handler. +Exactly one of `namespace` (exact match) or `namespacePath` (hierarchical path prefix) must be provided. Supplying both +returns HTTP 400. + Response: ```json @@ -223,10 +226,18 @@ Response: Performs semantic search across memory records. Requires a deployed memory with `onRetrieveMemoryRecords` handler. +Exactly one of `namespace` or `namespacePath` must be provided in the request body. + Request: ```json -{ "memoryName": "MyMemory", "namespace": "/users/123/facts", "searchQuery": "preferences", "strategyId": "optional" } +{ "memoryName": "MyMemory", "namespacePath": "/users/123/", "searchQuery": "preferences", "strategyId": "optional" } +``` + +Or with exact-match semantics: + +```json +{ "memoryName": "MyMemory", "namespace": "/users/123/facts", "searchQuery": "preferences" } ``` Response: diff --git a/src/cli/operations/dev/web-ui/api-types.ts b/src/cli/operations/dev/web-ui/api-types.ts index 8ba57937e..b1a32eafc 100644 --- a/src/cli/operations/dev/web-ui/api-types.ts +++ b/src/cli/operations/dev/web-ui/api-types.ts @@ -314,9 +314,18 @@ export interface GetCloudWatchTraceResponse { export type { CloudWatchTraceRecord, CloudWatchSpanRecord } from '../../traces/types'; // --------------------------------------------------------------------------- -// GET /api/memory?memoryName=xxx&namespace=yyy[&strategyId=zzz] +// GET /api/memory?memoryName=xxx&(namespace=yyy|namespacePath=yyy)[&strategyId=zzz] // --------------------------------------------------------------------------- +/** + * Query parameters for GET /api/memory. Exactly one of `namespace` (exact match) or + * `namespacePath` (hierarchical path prefix) must be provided. + */ +export type ListMemoryRecordsQuery = { + memoryName: string; + strategyId?: string; +} & ({ namespace: string; namespacePath?: never } | { namespace?: never; namespacePath: string }); + /** Response shape for GET /api/memory */ export interface ListMemoryRecordsResponse { success: boolean; @@ -340,13 +349,15 @@ export interface MemoryRecordResponse { // POST /api/memory/search // --------------------------------------------------------------------------- -/** Request body for POST /api/memory/search */ -export interface RetrieveMemoryRecordsRequest { +/** + * Request body for POST /api/memory/search. Exactly one of `namespace` (exact match) or + * `namespacePath` (hierarchical path prefix) must be provided. + */ +export type RetrieveMemoryRecordsRequest = { memoryName: string; - namespace: string; searchQuery: string; strategyId?: string; -} +} & ({ namespace: string; namespacePath?: never } | { namespace?: never; namespacePath: string }); /** Response shape for POST /api/memory/search */ export interface RetrieveMemoryRecordsResponse {