diff --git a/docs/configuration.md b/docs/configuration.md index 6e42c101a..05f580107 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -244,7 +244,7 @@ Strategy configuration: "type": "SEMANTIC", "name": "custom_semantic", "description": "Custom semantic memory", - "namespaces": ["/users/facts", "/users/preferences"] + "namespaceTemplates": ["/users/facts", "/users/preferences"] } ``` diff --git a/docs/memory.md b/docs/memory.md index ccfeb8c61..cd429ce57 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -196,17 +196,19 @@ Each strategy can have optional configuration: "type": "SEMANTIC", "name": "custom_semantic", "description": "Custom semantic memory", - "namespaces": ["/users/facts", "/users/preferences"] + "namespaceTemplates": ["/users/facts", "/users/preferences"] } ``` -| Field | Required | Description | -| ---------------------- | ------------- | --------------------------------------------------------------------------- | -| `type` | Yes | Strategy type | -| `name` | No | Custom name (defaults to `-`) | -| `description` | No | Strategy description | -| `namespaces` | No | Array of namespace paths for scoping | -| `reflectionNamespaces` | EPISODIC only | Namespaces for cross-episode reflections (must be a prefix of `namespaces`) | +| Field | Required | Description | +| ------------------------------ | ------------- | --------------------------------------------------------------------------------------------- | +| `type` | Yes | Strategy type | +| `name` | No | Custom name (defaults to `-`) | +| `description` | No | Strategy description | +| `namespaceTemplates` | No | Array of namespace templates for scoping | +| `reflectionNamespaceTemplates` | EPISODIC only | Templates for cross-episode reflections (must be a prefix of `namespaceTemplates`) | +| `namespaces` | No | **Deprecated alias for `namespaceTemplates`.** Accepted for backward compatibility. | +| `reflectionNamespaces` | EPISODIC only | **Deprecated alias for `reflectionNamespaceTemplates`.** Accepted for backward compatibility. | ## Event Expiry diff --git a/e2e-tests/fixtures/import/setup_memory_full.py b/e2e-tests/fixtures/import/setup_memory_full.py index 277179cfb..1f4a0253c 100644 --- a/e2e-tests/fixtures/import/setup_memory_full.py +++ b/e2e-tests/fixtures/import/setup_memory_full.py @@ -31,7 +31,7 @@ def main(): "semanticMemoryStrategy": { "name": "bugbash_semantic", "description": "Semantic strategy for bugbash testing", - "namespaces": ["default"], + "namespaceTemplates": ["default"], } }, { @@ -68,7 +68,7 @@ def main(): print(f" eventExpiryDuration: 30") print(f" executionRoleArn: {role_arn}") print(" strategies:") - print(" - type: SEMANTIC, name: bugbash_semantic, namespaces: [default]") + print(" - type: SEMANTIC, name: bugbash_semantic, namespaceTemplates: [default]") print(" - type: SUMMARIZATION, name: bugbash_summary") print(" - type: USER_PREFERENCE, name: bugbash_userpref") print(" tags: {env: bugbash, team: agentcore-cli}") diff --git a/integ-tests/add-remove-resources.test.ts b/integ-tests/add-remove-resources.test.ts index a89c761dd..441a03796 100644 --- a/integ-tests/add-remove-resources.test.ts +++ b/integ-tests/add-remove-resources.test.ts @@ -45,7 +45,7 @@ describe('integration: add and remove resources', () => { telemetry.assertMetricEmitted({ command: 'add.memory', exit_reason: 'success' }); }); - it('adds a memory with EPISODIC strategy and verifies reflectionNamespaces', async () => { + it('adds a memory with EPISODIC strategy and verifies reflectionNamespaceTemplates', async () => { const episodicMemName = `EpiMem${Date.now().toString().slice(-6)}`; const result = await runCLI( ['add', 'memory', '--name', episodicMemName, '--strategies', 'EPISODIC', '--json'], @@ -57,19 +57,19 @@ describe('integration: add and remove resources', () => { const json = JSON.parse(result.stdout); expect(json.success).toBe(true); - // Verify EPISODIC in config with reflectionNamespaces + // Verify EPISODIC in config with reflectionNamespaceTemplates const config = await readProjectConfig(project.projectPath); const memories = config.memories as { name: string; - strategies: { type: string; reflectionNamespaces?: string[] }[]; + strategies: { type: string; reflectionNamespaceTemplates?: string[] }[]; }[]; const mem = memories.find(m => m.name === episodicMemName); expect(mem, 'Memory should exist').toBeTruthy(); const episodic = mem!.strategies.find(s => s.type === 'EPISODIC'); expect(episodic, 'EPISODIC strategy should exist').toBeTruthy(); - expect(episodic!.reflectionNamespaces, 'Should have reflectionNamespaces').toBeDefined(); - expect(episodic!.reflectionNamespaces!.length).toBeGreaterThan(0); + expect(episodic!.reflectionNamespaceTemplates, 'Should have reflectionNamespaceTemplates').toBeDefined(); + expect(episodic!.reflectionNamespaceTemplates!.length).toBeGreaterThan(0); // Verify telemetry telemetry.assertMetricEmitted({ diff --git a/integ-tests/create-memory.test.ts b/integ-tests/create-memory.test.ts index ac80f1ba4..6079441f0 100644 --- a/integ-tests/create-memory.test.ts +++ b/integ-tests/create-memory.test.ts @@ -80,7 +80,7 @@ describe.skipIf(!prereqs.npm || !prereqs.git)('integration: create with memory o // longAndShortTerm should have strategies defined const memory = memories![0]!; - const strategies = memory.strategies as { type: string; reflectionNamespaces?: string[] }[] | undefined; + const strategies = memory.strategies as { type: string; reflectionNamespaceTemplates?: string[] }[] | undefined; expect(strategies, 'memory should have strategies').toBeDefined(); expect(strategies!.length).toBe(4); @@ -91,10 +91,10 @@ describe.skipIf(!prereqs.npm || !prereqs.git)('integration: create with memory o expect(types).toContain('SUMMARIZATION'); expect(types).toContain('EPISODIC'); - // Verify EPISODIC has reflectionNamespaces + // Verify EPISODIC has reflectionNamespaceTemplates const episodic = strategies!.find(s => s.type === 'EPISODIC'); expect(episodic, 'EPISODIC strategy should exist').toBeTruthy(); - expect(episodic!.reflectionNamespaces, 'EPISODIC should have reflectionNamespaces').toBeDefined(); - expect(episodic!.reflectionNamespaces!.length).toBeGreaterThan(0); + expect(episodic!.reflectionNamespaceTemplates, 'EPISODIC should have reflectionNamespaceTemplates').toBeDefined(); + expect(episodic!.reflectionNamespaceTemplates!.length).toBeGreaterThan(0); }); }); diff --git a/integ-tests/tui/add-memory-episodic.test.ts b/integ-tests/tui/add-memory-episodic.test.ts index c2caad335..d4540abbf 100644 --- a/integ-tests/tui/add-memory-episodic.test.ts +++ b/integ-tests/tui/add-memory-episodic.test.ts @@ -3,7 +3,7 @@ * * Drives the "Add Memory" wizard through the TUI to verify that when a user * selects the EPISODIC strategy, it is correctly persisted in agentcore.json - * with both namespaces and reflectionNamespaces. + * with both namespaceTemplates and reflectionNamespaceTemplates. * * Exercises: * - Navigation from HelpScreen -> Add Resource -> Memory @@ -11,7 +11,7 @@ * - Expiry selection (default 30 days) * - Strategy multi-select including EPISODIC * - Confirm review screen - * - Verification that agentcore.json contains EPISODIC with reflectionNamespaces + * - Verification that agentcore.json contains EPISODIC with reflectionNamespaceTemplates */ import { TuiSession, WaitForTimeoutError } from '../../src/tui-harness/index.js'; import { createMinimalProjectDir } from './helpers.js'; @@ -177,14 +177,14 @@ describe('Add Memory with EPISODIC Strategy', () => { expect(found).toBe(true); }); - it('Step 9: agentcore.json contains EPISODIC with reflectionNamespaces', async () => { + it('Step 9: agentcore.json contains EPISODIC with reflectionNamespaceTemplates', async () => { const configPath = join(projectDir.dir, 'agentcore', 'agentcore.json'); const raw = await readFileAsync(configPath, 'utf-8'); const config = JSON.parse(raw); const memories = config.memories as { name: string; - strategies: { type: string; namespaces?: string[]; reflectionNamespaces?: string[] }[]; + strategies: { type: string; namespaceTemplates?: string[]; reflectionNamespaceTemplates?: string[] }[]; }[]; expect(memories.length).toBeGreaterThan(0); @@ -198,12 +198,12 @@ describe('Add Memory with EPISODIC Strategy', () => { expect(types).toContain('USER_PREFERENCE'); expect(types).toContain('EPISODIC'); - // Verify EPISODIC has namespaces AND reflectionNamespaces + // Verify EPISODIC has namespaceTemplates AND reflectionNamespaceTemplates const episodic = memory!.strategies.find(s => s.type === 'EPISODIC'); expect(episodic, 'EPISODIC strategy should exist').toBeTruthy(); - expect(episodic!.namespaces, 'EPISODIC should have namespaces').toBeDefined(); - expect(episodic!.namespaces!.length).toBeGreaterThan(0); - expect(episodic!.reflectionNamespaces, 'EPISODIC should have reflectionNamespaces').toBeDefined(); - expect(episodic!.reflectionNamespaces!.length).toBeGreaterThan(0); + expect(episodic!.namespaceTemplates, 'EPISODIC should have namespaceTemplates').toBeDefined(); + expect(episodic!.namespaceTemplates!.length).toBeGreaterThan(0); + expect(episodic!.reflectionNamespaceTemplates, 'EPISODIC should have reflectionNamespaceTemplates').toBeDefined(); + expect(episodic!.reflectionNamespaceTemplates!.length).toBeGreaterThan(0); }); }); diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 520b4a57d..53a0dff70 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -1949,7 +1949,7 @@ dependencies = [ {{#if (eq modelProvider "Anthropic")}}"anthropic >= 0.30.0", {{/if}}"a2a-sdk[all] >= 0.2.0, < 1.0.0", "aws-opentelemetry-distro", - "bedrock-agentcore[a2a] >= 1.0.3", + "bedrock-agentcore[a2a] >= 1.9.1", "botocore[crt] >= 1.35.0", {{#if (eq modelProvider "Gemini")}}"google-genai >= 1.0.0", {{/if}}{{#if (eq modelProvider "OpenAI")}}"openai >= 1.0.0", @@ -2786,7 +2786,7 @@ dependencies = [ {{/if}}"ag-ui-strands >= 0.1.7", "ag-ui-protocol >= 0.1.10", "aws-opentelemetry-distro", - "bedrock-agentcore >= 1.0.3", + "bedrock-agentcore >= 1.9.1", "botocore[crt] >= 1.35.0", "fastapi >= 0.115.12", {{#if (eq modelProvider "Gemini")}}"google-genai >= 1.0.0", @@ -5167,7 +5167,7 @@ requires-python = ">=3.10" dependencies = [ {{#if (eq modelProvider "Anthropic")}}"anthropic >= 0.30.0", {{/if}}"aws-opentelemetry-distro", - "bedrock-agentcore >= 1.0.3", + "bedrock-agentcore >= 1.9.1", "botocore[crt] >= 1.35.0", {{#if (eq modelProvider "Gemini")}}"google-genai >= 1.0.0", {{/if}}"mcp >= 1.19.0", diff --git a/src/assets/python/a2a/strands/base/pyproject.toml b/src/assets/python/a2a/strands/base/pyproject.toml index 37f69c01a..fc69f0030 100644 --- a/src/assets/python/a2a/strands/base/pyproject.toml +++ b/src/assets/python/a2a/strands/base/pyproject.toml @@ -12,7 +12,7 @@ dependencies = [ {{#if (eq modelProvider "Anthropic")}}"anthropic >= 0.30.0", {{/if}}"a2a-sdk[all] >= 0.2.0, < 1.0.0", "aws-opentelemetry-distro", - "bedrock-agentcore[a2a] >= 1.0.3", + "bedrock-agentcore[a2a] >= 1.9.1", "botocore[crt] >= 1.35.0", {{#if (eq modelProvider "Gemini")}}"google-genai >= 1.0.0", {{/if}}{{#if (eq modelProvider "OpenAI")}}"openai >= 1.0.0", diff --git a/src/assets/python/agui/strands/base/pyproject.toml b/src/assets/python/agui/strands/base/pyproject.toml index 12e62e3fc..7e467f6c3 100644 --- a/src/assets/python/agui/strands/base/pyproject.toml +++ b/src/assets/python/agui/strands/base/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ {{/if}}"ag-ui-strands >= 0.1.7", "ag-ui-protocol >= 0.1.10", "aws-opentelemetry-distro", - "bedrock-agentcore >= 1.0.3", + "bedrock-agentcore >= 1.9.1", "botocore[crt] >= 1.35.0", "fastapi >= 0.115.12", {{#if (eq modelProvider "Gemini")}}"google-genai >= 1.0.0", diff --git a/src/assets/python/http/strands/base/pyproject.toml b/src/assets/python/http/strands/base/pyproject.toml index d21745479..9f548f6fd 100644 --- a/src/assets/python/http/strands/base/pyproject.toml +++ b/src/assets/python/http/strands/base/pyproject.toml @@ -11,7 +11,7 @@ requires-python = ">=3.10" dependencies = [ {{#if (eq modelProvider "Anthropic")}}"anthropic >= 0.30.0", {{/if}}"aws-opentelemetry-distro", - "bedrock-agentcore >= 1.0.3", + "bedrock-agentcore >= 1.9.1", "botocore[crt] >= 1.35.0", {{#if (eq modelProvider "Gemini")}}"google-genai >= 1.0.0", {{/if}}"mcp >= 1.19.0", diff --git a/src/cli/aws/agentcore-control.ts b/src/cli/aws/agentcore-control.ts index ef463cef4..3d982dcb6 100644 --- a/src/cli/aws/agentcore-control.ts +++ b/src/cli/aws/agentcore-control.ts @@ -370,8 +370,8 @@ export interface MemoryDetail { type: string; name?: string; description?: string; - namespaces?: string[]; - reflectionNamespaces?: string[]; + namespaceTemplates?: string[]; + reflectionNamespaceTemplates?: string[]; }[]; tags?: Record; encryptionKeyArn?: string; @@ -424,13 +424,17 @@ export async function getMemoryDetail(options: GetMemoryOptions): Promise 0 && { reflectionNamespaces: episodicNamespaces }), + ...(namespaceTemplates && namespaceTemplates.length > 0 && { namespaceTemplates }), + ...(reflectionTemplates && + reflectionTemplates.length > 0 && { reflectionNamespaceTemplates: reflectionTemplates }), }; }), }; diff --git a/src/cli/commands/add/__tests__/add-memory.test.ts b/src/cli/commands/add/__tests__/add-memory.test.ts index 3a518f793..42a19a40a 100644 --- a/src/cli/commands/add/__tests__/add-memory.test.ts +++ b/src/cli/commands/add/__tests__/add-memory.test.ts @@ -138,20 +138,20 @@ describe('add memory command', () => { const memory = projectSpec.memories.find((m: { name: string }) => m.name === memoryName); const semantic = memory?.strategies?.find((s: { type: string }) => s.type === 'SEMANTIC'); - expect(semantic?.namespaces).toEqual(['/users/{actorId}/facts']); + expect(semantic?.namespaceTemplates).toEqual(['/users/{actorId}/facts']); const userPref = memory?.strategies?.find((s: { type: string }) => s.type === 'USER_PREFERENCE'); - expect(userPref?.namespaces).toEqual(['/users/{actorId}/preferences']); + expect(userPref?.namespaceTemplates).toEqual(['/users/{actorId}/preferences']); const summarization = memory?.strategies?.find((s: { type: string }) => s.type === 'SUMMARIZATION'); - expect(summarization?.namespaces).toEqual(['/summaries/{actorId}/{sessionId}']); + expect(summarization?.namespaceTemplates).toEqual(['/summaries/{actorId}/{sessionId}']); const episodic = memory?.strategies?.find((s: { type: string }) => s.type === 'EPISODIC'); - expect(episodic?.namespaces).toEqual(['/episodes/{actorId}/{sessionId}']); - expect(episodic?.reflectionNamespaces).toEqual(['/episodes/{actorId}']); + expect(episodic?.namespaceTemplates).toEqual(['/episodes/{actorId}/{sessionId}']); + expect(episodic?.reflectionNamespaceTemplates).toEqual(['/episodes/{actorId}']); }); - it('creates memory with EPISODIC strategy including default namespaces and reflectionNamespaces', async () => { + it('creates memory with EPISODIC strategy including default namespaceTemplates and reflectionNamespaceTemplates', async () => { const memoryName = `epi${Date.now()}`; const result = await runCLI( ['add', 'memory', '--name', memoryName, '--strategies', 'EPISODIC', '--json'], @@ -162,8 +162,8 @@ describe('add memory command', () => { const memory = projectSpec.memories.find((m: { name: string }) => m.name === memoryName); const episodic = memory?.strategies?.find((s: { type: string }) => s.type === 'EPISODIC'); expect(episodic).toBeTruthy(); - expect(episodic?.namespaces).toEqual(['/episodes/{actorId}/{sessionId}']); - expect(episodic?.reflectionNamespaces).toEqual(['/episodes/{actorId}']); + expect(episodic?.namespaceTemplates).toEqual(['/episodes/{actorId}/{sessionId}']); + expect(episodic?.reflectionNamespaceTemplates).toEqual(['/episodes/{actorId}']); }); }); }); diff --git a/src/cli/commands/create/__tests__/create.test.ts b/src/cli/commands/create/__tests__/create.test.ts index cf112f30a..faa4c5082 100644 --- a/src/cli/commands/create/__tests__/create.test.ts +++ b/src/cli/commands/create/__tests__/create.test.ts @@ -145,18 +145,18 @@ describe('create command', () => { const memory = projectSpec.memories[0]; const semantic = memory?.strategies?.find((s: { type: string }) => s.type === 'SEMANTIC'); - expect(semantic?.namespaces).toEqual(['/users/{actorId}/facts']); + expect(semantic?.namespaceTemplates).toEqual(['/users/{actorId}/facts']); const userPref = memory?.strategies?.find((s: { type: string }) => s.type === 'USER_PREFERENCE'); - expect(userPref?.namespaces).toEqual(['/users/{actorId}/preferences']); + expect(userPref?.namespaceTemplates).toEqual(['/users/{actorId}/preferences']); const summarization = memory?.strategies?.find((s: { type: string }) => s.type === 'SUMMARIZATION'); - expect(summarization?.namespaces).toEqual(['/summaries/{actorId}/{sessionId}']); + expect(summarization?.namespaceTemplates).toEqual(['/summaries/{actorId}/{sessionId}']); const episodic = memory?.strategies?.find((s: { type: string }) => s.type === 'EPISODIC'); expect(episodic, 'EPISODIC strategy should exist in longAndShortTerm').toBeTruthy(); - expect(episodic?.namespaces).toEqual(['/episodes/{actorId}/{sessionId}']); - expect(episodic?.reflectionNamespaces).toEqual(['/episodes/{actorId}']); + expect(episodic?.namespaceTemplates).toEqual(['/episodes/{actorId}/{sessionId}']); + expect(episodic?.reflectionNamespaceTemplates).toEqual(['/episodes/{actorId}']); }); it('uses --project-name for project and --name for agent resource', async () => { diff --git a/src/cli/commands/dev/browser-mode.ts b/src/cli/commands/dev/browser-mode.ts index 77342e7f1..46a39d05c 100644 --- a/src/cli/commands/dev/browser-mode.ts +++ b/src/cli/commands/dev/browser-mode.ts @@ -65,26 +65,40 @@ async function resolveDeployedHandlers( `Memory browsing enabled for ${memories.length} deployed memory(ies): ${memories.map(m => m.name).join(', ')}` ); +<<<<<<< HEAD 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` }; return listMemoryRecords({ +======= + 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` }; + const res = await listMemoryRecords({ +>>>>>>> origin/main region: memory.region, memoryId: memory.memoryId, - namespace, - memoryStrategyId: strategyId, + memoryStrategyId: args.strategyId, + ...(args.namespace ? { namespace: args.namespace } : { namespacePath: args.namespacePath! }), }); }; +<<<<<<< HEAD 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` }; return retrieveMemoryRecords({ +======= + 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` }; + const res = await retrieveMemoryRecords({ +>>>>>>> origin/main region: memory.region, memoryId: memory.memoryId, - namespace, - searchQuery, - memoryStrategyId: strategyId, + searchQuery: args.searchQuery, + memoryStrategyId: args.strategyId, + ...(args.namespace ? { namespace: args.namespace } : { namespacePath: args.namespacePath! }), }); }; } @@ -289,15 +303,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/commands/import/import-memory.ts b/src/cli/commands/import/import-memory.ts index 2362d1353..f37ffd39e 100644 --- a/src/cli/commands/import/import-memory.ts +++ b/src/cli/commands/import/import-memory.ts @@ -42,14 +42,16 @@ function filterInternalNamespaces(namespaces: string[]): string[] { function toMemorySpec(memory: MemoryDetail, localName: string): Memory { const strategies: Memory['strategies'] = memory.strategies.map(s => { const mappedType = mapStrategyType(s.type); - const filteredNamespaces = s.namespaces ? filterInternalNamespaces(s.namespaces) : []; + const filteredTemplates = s.namespaceTemplates ? filterInternalNamespaces(s.namespaceTemplates) : []; return { type: mappedType as Memory['strategies'][number]['type'], ...(s.name && { name: s.name }), ...(s.description && { description: s.description }), - ...(filteredNamespaces.length > 0 && { namespaces: filteredNamespaces }), - ...(s.reflectionNamespaces && - s.reflectionNamespaces.length > 0 && { reflectionNamespaces: s.reflectionNamespaces }), + ...(filteredTemplates.length > 0 && { namespaceTemplates: filteredTemplates }), + ...(s.reflectionNamespaceTemplates && + s.reflectionNamespaceTemplates.length > 0 && { + reflectionNamespaceTemplates: s.reflectionNamespaceTemplates, + }), }; }); diff --git a/src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts b/src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts index d30ae23fc..8fdcf9e82 100644 --- a/src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts +++ b/src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts @@ -46,10 +46,10 @@ describe('mapGenerateInputToMemories', () => { expect(types).toContain('EPISODIC'); }); - it('includes default namespaces for strategies', () => { + it('includes default namespace templates for strategies', () => { const result = mapGenerateInputToMemories('longAndShortTerm', 'Proj'); const semantic = result[0]!.strategies.find(s => s.type === 'SEMANTIC'); - expect(semantic?.namespaces).toEqual(['/users/{actorId}/facts']); + expect(semantic?.namespaceTemplates).toEqual(['/users/{actorId}/facts']); }); it('uses project name in memory name', () => { diff --git a/src/cli/operations/agent/generate/schema-mapper.ts b/src/cli/operations/agent/generate/schema-mapper.ts index cc7be2a53..523c89a61 100644 --- a/src/cli/operations/agent/generate/schema-mapper.ts +++ b/src/cli/operations/agent/generate/schema-mapper.ts @@ -11,9 +11,9 @@ import type { } from '../../../../schema'; import { DEFAULT_ENTRYPOINT_BY_LANGUAGE, - DEFAULT_EPISODIC_REFLECTION_NAMESPACES, + DEFAULT_EPISODIC_REFLECTION_NAMESPACE_TEMPLATES, DEFAULT_RUNTIME_BY_LANGUAGE, - DEFAULT_STRATEGY_NAMESPACES, + DEFAULT_STRATEGY_NAMESPACE_TEMPLATES, } from '../../../../schema'; import { GatewayPrimitive } from '../../../primitives/GatewayPrimitive'; import { buildAuthorizerConfigFromJwtConfig } from '../../../primitives/auth-utils'; @@ -74,11 +74,11 @@ export function mapGenerateInputToMemories(memory: MemoryOption, projectName: st if (memory === 'longAndShortTerm') { const strategyTypes: MemoryStrategyType[] = ['SEMANTIC', 'USER_PREFERENCE', 'SUMMARIZATION', 'EPISODIC']; for (const type of strategyTypes) { - const defaultNamespaces = DEFAULT_STRATEGY_NAMESPACES[type]; + const defaultTemplates = DEFAULT_STRATEGY_NAMESPACE_TEMPLATES[type]; strategies.push({ type, - ...(defaultNamespaces && { namespaces: defaultNamespaces }), - ...(type === 'EPISODIC' && { reflectionNamespaces: DEFAULT_EPISODIC_REFLECTION_NAMESPACES }), + ...(defaultTemplates && { namespaceTemplates: defaultTemplates }), + ...(type === 'EPISODIC' && { reflectionNamespaceTemplates: DEFAULT_EPISODIC_REFLECTION_NAMESPACE_TEMPLATES }), }); } } 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 24c8e5b05..06e772997 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/pyproject-generator.ts b/src/cli/operations/agent/import/pyproject-generator.ts index f59a0e1fe..18d56cd3c 100644 --- a/src/cli/operations/agent/import/pyproject-generator.ts +++ b/src/cli/operations/agent/import/pyproject-generator.ts @@ -6,7 +6,7 @@ import type { ImportedFeatures } from './base-translator'; const BASE_DEPS = [ 'aws-opentelemetry-distro', - 'bedrock-agentcore >= 1.0.3', + 'bedrock-agentcore >= 1.9.1', 'botocore[crt] >= 1.35.0', 'boto3>=1.38.0', ]; @@ -22,7 +22,7 @@ const LANGGRAPH_DEPS = [ 'tiktoken==0.11.0', ]; -const MEMORY_DEPS = ['bedrock-agentcore[memory] >= 1.0.3']; +const MEMORY_DEPS = ['bedrock-agentcore[memory] >= 1.9.1']; export function generatePyprojectToml(agentName: string, framework: SDKFramework, features: ImportedFeatures): string { const deps = [...BASE_DEPS]; diff --git a/src/cli/operations/agent/import/strands-translator.ts b/src/cli/operations/agent/import/strands-translator.ts index ccea17932..fe6084f48 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/README.md b/src/cli/operations/dev/web-ui/README.md index c33f0ea0c..51370aad5 100644 --- a/src/cli/operations/dev/web-ui/README.md +++ b/src/cli/operations/dev/web-ui/README.md @@ -209,9 +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. +Lists memory records for a given memory, filtered by either an exact namespace or a namespace path prefix. 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: @@ -223,10 +227,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/__tests__/memory.test.ts b/src/cli/operations/dev/web-ui/__tests__/memory.test.ts new file mode 100644 index 000000000..ff1e01cd7 --- /dev/null +++ b/src/cli/operations/dev/web-ui/__tests__/memory.test.ts @@ -0,0 +1,264 @@ +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); + const err = JSON.parse(res._body).error; + expect(err).toContain('mutually exclusive'); + expect(err).toContain('request fields'); + 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); + const err = JSON.parse(res._body).error; + expect(err).toContain("either 'namespace' or 'namespacePath'"); + expect(err).toContain('request field'); + 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/api-types.ts b/src/cli/operations/dev/web-ui/api-types.ts index 61e0c80a2..1e718a013 100644 --- a/src/cli/operations/dev/web-ui/api-types.ts +++ b/src/cli/operations/dev/web-ui/api-types.ts @@ -170,8 +170,8 @@ export interface ResourceMemory { /** Memory strategy with namespace patterns */ export interface ResourceMemoryStrategy { type: string; - /** Namespace patterns, e.g. "/users/{actorId}/facts", "/summaries/{actorId}/{sessionId}" */ - namespaces: string[]; + /** Namespace templates, e.g. "/users/{actorId}/facts", "/summaries/{actorId}/{sessionId}" */ + namespaceTemplates: string[]; } /** Credential details in the resources response */ @@ -366,9 +366,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; @@ -392,13 +401,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 { diff --git a/src/cli/operations/dev/web-ui/handlers/memory.ts b/src/cli/operations/dev/web-ui/handlers/memory.ts index 5a01c9139..988322641 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,27 @@ 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' request fields 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' request field is required", + }) + ); return; } @@ -112,7 +159,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/handlers/resources.ts b/src/cli/operations/dev/web-ui/handlers/resources.ts index 73eae009e..d700862a9 100644 --- a/src/cli/operations/dev/web-ui/handlers/resources.ts +++ b/src/cli/operations/dev/web-ui/handlers/resources.ts @@ -149,7 +149,7 @@ export async function handleResources(ctx: RouteContext, res: ServerResponse, or name: m.name, strategies: m.strategies.map(s => ({ type: s.type, - namespaces: s.namespaces ?? [], + namespaceTemplates: s.namespaceTemplates ?? s.namespaces ?? [], })), expiryDays: m.eventExpiryDuration, deploymentStatus: statusByTypeAndName.get(`memory:${m.name}`), diff --git a/src/cli/operations/dev/web-ui/web-server.ts b/src/cli/operations/dev/web-ui/web-server.ts index 6b1c421ee..26efd54d0 100644 --- a/src/cli/operations/dev/web-ui/web-server.ts +++ b/src/cli/operations/dev/web-ui/web-server.ts @@ -104,25 +104,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/__tests__/create-memory.test.ts b/src/cli/operations/memory/__tests__/create-memory.test.ts index 09c827ac0..be0c0d54b 100644 --- a/src/cli/operations/memory/__tests__/create-memory.test.ts +++ b/src/cli/operations/memory/__tests__/create-memory.test.ts @@ -73,7 +73,7 @@ describe('add', () => { expect(addedMemory).toBeDefined(); expect(addedMemory.eventExpiryDuration).toBe(60); expect(addedMemory.strategies[0]!.type).toBe('SEMANTIC'); - expect(addedMemory.strategies[0]!.namespaces).toEqual(['/users/{actorId}/facts']); + expect(addedMemory.strategies[0]!.namespaceTemplates).toEqual(['/users/{actorId}/facts']); }); it('rejects invalid strategy type', async () => { diff --git a/src/cli/operations/memory/__tests__/list-memory-records.test.ts b/src/cli/operations/memory/__tests__/list-memory-records.test.ts new file mode 100644 index 000000000..a8561283c --- /dev/null +++ b/src/cli/operations/memory/__tests__/list-memory-records.test.ts @@ -0,0 +1,170 @@ +import { type ListMemoryRecordsOptions, listMemoryRecords } from '../list-memory-records'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { mockSend, capturedInput } = vi.hoisted(() => { + const captured: { value: unknown } = { value: null }; + return { + mockSend: vi.fn(), + capturedInput: captured, + }; +}); + +vi.mock('@aws-sdk/client-bedrock-agentcore', () => ({ + BedrockAgentCoreClient: class { + send = mockSend; + }, + ListMemoryRecordsCommand: class { + constructor(public input: unknown) { + capturedInput.value = input; + } + }, +})); + +vi.mock('../../../aws', () => ({ + getCredentialProvider: vi.fn().mockReturnValue({}), +})); + +describe('listMemoryRecords', () => { + afterEach(() => { + vi.clearAllMocks(); + capturedInput.value = null; + }); + + it('rejects when both namespace and namespacePath are provided', async () => { + // Bypassing the discriminated union to simulate a caller from JS or the web UI. + const options = { + region: 'us-west-2', + memoryId: 'mem-1', + namespace: '/a/', + namespacePath: '/b/', + } as unknown as ListMemoryRecordsOptions; + + const result = await listMemoryRecords(options); + + expect(result.success).toBe(false); + expect((result as { error: Error }).error.message).toContain('mutually exclusive'); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it('rejects when neither namespace nor namespacePath is provided', async () => { + const options = { region: 'us-west-2', memoryId: 'mem-1' } as unknown as ListMemoryRecordsOptions; + + const result = await listMemoryRecords(options); + + expect(result.success).toBe(false); + expect((result as { error: Error }).error.message).toContain('Either'); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it('rejects when namespace is an empty string (treated as not provided)', async () => { + const options = { region: 'us-west-2', memoryId: 'mem-1', namespace: '' } as unknown as ListMemoryRecordsOptions; + + const result = await listMemoryRecords(options); + + expect(result.success).toBe(false); + expect((result as { error: Error }).error.message).toContain('Either'); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it('forwards namespace to the SDK when only namespace is provided', async () => { + mockSend.mockResolvedValueOnce({ memoryRecordSummaries: [], nextToken: undefined }); + + const result = await listMemoryRecords({ + region: 'us-west-2', + memoryId: 'mem-1', + namespace: '/users/42/facts', + }); + + expect(result.success).toBe(true); + expect(capturedInput.value).toMatchObject({ + memoryId: 'mem-1', + namespace: '/users/42/facts', + maxResults: 50, + }); + expect((capturedInput.value as { namespacePath?: string }).namespacePath).toBeUndefined(); + }); + + it('forwards namespacePath to the SDK when only namespacePath is provided', async () => { + mockSend.mockResolvedValueOnce({ memoryRecordSummaries: [], nextToken: undefined }); + + const result = await listMemoryRecords({ + region: 'us-west-2', + memoryId: 'mem-1', + namespacePath: '/users/42/', + }); + + expect(result.success).toBe(true); + expect(capturedInput.value).toMatchObject({ + memoryId: 'mem-1', + namespacePath: '/users/42/', + }); + expect((capturedInput.value as { namespace?: string }).namespace).toBeUndefined(); + }); + + it('parses memory record summaries into the result shape', async () => { + const createdAt = new Date('2026-05-13T00:00:00Z'); + mockSend.mockResolvedValueOnce({ + memoryRecordSummaries: [ + { + memoryRecordId: 'rec-1', + content: { text: 'hello' }, + memoryStrategyId: 'strat-1', + namespaces: ['/users/42/facts'], + createdAt, + score: 0.87, + metadata: { source: { stringValue: 'chat' } }, + }, + ], + nextToken: 'next', + }); + + const result = await listMemoryRecords({ + region: 'us-west-2', + memoryId: 'mem-1', + namespace: '/users/42/facts', + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.nextToken).toBe('next'); + expect(result.records).toEqual([ + { + memoryRecordId: 'rec-1', + content: 'hello', + memoryStrategyId: 'strat-1', + namespaces: ['/users/42/facts'], + createdAt: createdAt.toISOString(), + score: 0.87, + metadata: { source: 'chat' }, + }, + ]); + }); + + it('maps ResourceNotFoundException to a user-friendly error', async () => { + const err = new Error('not found'); + err.name = 'ResourceNotFoundException'; + mockSend.mockRejectedValueOnce(err); + + const result = await listMemoryRecords({ + region: 'us-west-2', + memoryId: 'missing', + namespace: '/a/', + }); + + expect(result.success).toBe(false); + expect((result as { error: Error }).error.message).toContain("Memory 'missing' not found"); + }); + + it('returns the SDK error message for other failures', async () => { + mockSend.mockRejectedValueOnce(new Error('network down')); + + const result = await listMemoryRecords({ + region: 'us-west-2', + memoryId: 'mem-1', + namespace: '/a/', + }); + + expect(result.success).toBe(false); + expect((result as { error: Error }).error.message).toBe('network down'); + }); +}); diff --git a/src/cli/operations/memory/__tests__/retrieve-memory-records.test.ts b/src/cli/operations/memory/__tests__/retrieve-memory-records.test.ts new file mode 100644 index 000000000..078308e3d --- /dev/null +++ b/src/cli/operations/memory/__tests__/retrieve-memory-records.test.ts @@ -0,0 +1,185 @@ +import { type RetrieveMemoryRecordsOptions, retrieveMemoryRecords } from '../retrieve-memory-records'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { mockSend, capturedInput } = vi.hoisted(() => { + const captured: { value: unknown } = { value: null }; + return { + mockSend: vi.fn(), + capturedInput: captured, + }; +}); + +vi.mock('@aws-sdk/client-bedrock-agentcore', () => ({ + BedrockAgentCoreClient: class { + send = mockSend; + }, + RetrieveMemoryRecordsCommand: class { + constructor(public input: unknown) { + capturedInput.value = input; + } + }, +})); + +vi.mock('../../../aws', () => ({ + getCredentialProvider: vi.fn().mockReturnValue({}), +})); + +describe('retrieveMemoryRecords', () => { + afterEach(() => { + vi.clearAllMocks(); + capturedInput.value = null; + }); + + it('rejects when both namespace and namespacePath are provided', async () => { + const options = { + region: 'us-west-2', + memoryId: 'mem-1', + namespace: '/a/', + namespacePath: '/b/', + searchQuery: 'q', + } as unknown as RetrieveMemoryRecordsOptions; + + const result = await retrieveMemoryRecords(options); + + expect(result.success).toBe(false); + expect((result as { error: Error }).error.message).toContain('mutually exclusive'); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it('rejects when neither namespace nor namespacePath is provided', async () => { + const options = { + region: 'us-west-2', + memoryId: 'mem-1', + searchQuery: 'q', + } as unknown as RetrieveMemoryRecordsOptions; + + const result = await retrieveMemoryRecords(options); + + expect(result.success).toBe(false); + expect((result as { error: Error }).error.message).toContain('Either'); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it('rejects when namespace is an empty string (treated as not provided)', async () => { + const options = { + region: 'us-west-2', + memoryId: 'mem-1', + namespace: '', + searchQuery: 'q', + } as unknown as RetrieveMemoryRecordsOptions; + + const result = await retrieveMemoryRecords(options); + + expect(result.success).toBe(false); + expect((result as { error: Error }).error.message).toContain('Either'); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it('forwards namespace to the SDK when only namespace is provided', async () => { + mockSend.mockResolvedValueOnce({ memoryRecordSummaries: [], nextToken: undefined }); + + const result = await retrieveMemoryRecords({ + region: 'us-west-2', + memoryId: 'mem-1', + namespace: '/users/42/facts', + searchQuery: 'preferences', + topK: 5, + }); + + expect(result.success).toBe(true); + expect(capturedInput.value).toMatchObject({ + memoryId: 'mem-1', + namespace: '/users/42/facts', + searchCriteria: { searchQuery: 'preferences', topK: 5 }, + }); + expect((capturedInput.value as { namespacePath?: string }).namespacePath).toBeUndefined(); + }); + + it('forwards namespacePath to the SDK when only namespacePath is provided', async () => { + mockSend.mockResolvedValueOnce({ memoryRecordSummaries: [], nextToken: undefined }); + + const result = await retrieveMemoryRecords({ + region: 'us-west-2', + memoryId: 'mem-1', + namespacePath: '/users/42/', + searchQuery: 'preferences', + }); + + expect(result.success).toBe(true); + expect(capturedInput.value).toMatchObject({ + memoryId: 'mem-1', + namespacePath: '/users/42/', + searchCriteria: { searchQuery: 'preferences' }, + }); + expect((capturedInput.value as { namespace?: string }).namespace).toBeUndefined(); + }); + + it('parses memory record summaries into the result shape', async () => { + const createdAt = new Date('2026-05-13T00:00:00Z'); + mockSend.mockResolvedValueOnce({ + memoryRecordSummaries: [ + { + memoryRecordId: 'rec-1', + content: { text: 'found you' }, + memoryStrategyId: 'strat-1', + namespaces: ['/users/42/facts'], + createdAt, + score: 0.5, + metadata: { origin: { stringValue: 'search' } }, + }, + ], + nextToken: undefined, + }); + + const result = await retrieveMemoryRecords({ + region: 'us-west-2', + memoryId: 'mem-1', + namespacePath: '/users/42/', + searchQuery: 'q', + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.records).toEqual([ + { + memoryRecordId: 'rec-1', + content: 'found you', + memoryStrategyId: 'strat-1', + namespaces: ['/users/42/facts'], + createdAt: createdAt.toISOString(), + score: 0.5, + metadata: { origin: 'search' }, + }, + ]); + }); + + it('maps ResourceNotFoundException to a user-friendly error', async () => { + const err = new Error('not found'); + err.name = 'ResourceNotFoundException'; + mockSend.mockRejectedValueOnce(err); + + const result = await retrieveMemoryRecords({ + region: 'us-west-2', + memoryId: 'missing', + namespace: '/a/', + searchQuery: 'q', + }); + + expect(result.success).toBe(false); + expect((result as { error: Error }).error.message).toContain("Memory 'missing' not found"); + }); + + it('returns the SDK error message for other failures', async () => { + mockSend.mockRejectedValueOnce(new Error('network down')); + + const result = await retrieveMemoryRecords({ + region: 'us-west-2', + memoryId: 'mem-1', + namespace: '/a/', + searchQuery: 'q', + }); + + expect(result.success).toBe(false); + expect((result as { error: Error }).error.message).toBe('network down'); + }); +}); diff --git a/src/cli/operations/memory/list-memory-records.ts b/src/cli/operations/memory/list-memory-records.ts index 5d0c53828..eb4a29d78 100644 --- a/src/cli/operations/memory/list-memory-records.ts +++ b/src/cli/operations/memory/list-memory-records.ts @@ -1,5 +1,12 @@ +<<<<<<< HEAD import { createAgentCoreClient } from '../../aws'; import { ListMemoryRecordsCommand } from '@aws-sdk/client-bedrock-agentcore'; +======= +import { ResourceNotFoundError, ValidationError, toError } from '../../../lib'; +import type { Result } from '../../../lib/result'; +import { getCredentialProvider } from '../../aws'; +import { BedrockAgentCoreClient, ListMemoryRecordsCommand } from '@aws-sdk/client-bedrock-agentcore'; +>>>>>>> origin/main export interface MemoryRecordEntry { memoryRecordId: string; @@ -11,27 +18,55 @@ 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; } +<<<<<<< HEAD export interface ListMemoryRecordsResult { success: boolean; records?: MemoryRecordEntry[]; nextToken?: string; error?: 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 type ListMemoryRecordsResult = Result<{ records: MemoryRecordEntry[]; nextToken?: string }>; +>>>>>>> origin/main /** * 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). Treats empty-string as "not provided" + if (namespace && namespacePath) { + return { success: false, error: new ValidationError("'namespace' and 'namespacePath' are mutually exclusive.") }; + } + if (!namespace && !namespacePath) { + return { success: false, error: new ValidationError("Either 'namespace' or 'namespacePath' must be provided.") }; + } const client = createAgentCoreClient(region); @@ -39,7 +74,7 @@ export async function listMemoryRecords(options: ListMemoryRecordsOptions): Prom const response = await client.send( new ListMemoryRecordsCommand({ memoryId, - namespace, + ...(namespace ? { 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 720a45287..426e8fe6e 100644 --- a/src/cli/operations/memory/retrieve-memory-records.ts +++ b/src/cli/operations/memory/retrieve-memory-records.ts @@ -1,11 +1,20 @@ +<<<<<<< HEAD import { createAgentCoreClient } from '../../aws'; +======= +import { ResourceNotFoundError, ValidationError, toError } from '../../../lib'; +import type { Result } from '../../../lib/result'; +import { getCredentialProvider } from '../../aws'; +>>>>>>> origin/main import type { MemoryRecordEntry } from './list-memory-records'; import { 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,20 +22,46 @@ export interface RetrieveMemoryRecordsOptions { nextToken?: string; } +<<<<<<< HEAD export interface RetrieveMemoryRecordsResult { success: boolean; records?: MemoryRecordEntry[]; nextToken?: string; error?: 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 type RetrieveMemoryRecordsResult = Result<{ records: MemoryRecordEntry[]; nextToken?: string }>; +>>>>>>> origin/main /** * 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. Treats + // empty-string as "not provided" + if (namespace && namespacePath) { + return { success: false, error: new ValidationError("'namespace' and 'namespacePath' are mutually exclusive.") }; + } + if (!namespace && !namespacePath) { + return { success: false, error: new ValidationError("Either 'namespace' or 'namespacePath' must be provided.") }; + } const client = createAgentCoreClient(region); @@ -34,7 +69,7 @@ export async function retrieveMemoryRecords( const response = await client.send( new RetrieveMemoryRecordsCommand({ memoryId, - namespace, + ...(namespace ? { namespace } : { namespacePath }), searchCriteria: { searchQuery, memoryStrategyId, diff --git a/src/cli/primitives/MemoryPrimitive.tsx b/src/cli/primitives/MemoryPrimitive.tsx index f91aef5af..9db25bede 100644 --- a/src/cli/primitives/MemoryPrimitive.tsx +++ b/src/cli/primitives/MemoryPrimitive.tsx @@ -7,8 +7,8 @@ import type { StreamDeliveryResources, } from '../../schema'; import { - DEFAULT_EPISODIC_REFLECTION_NAMESPACES, - DEFAULT_STRATEGY_NAMESPACES, + DEFAULT_EPISODIC_REFLECTION_NAMESPACE_TEMPLATES, + DEFAULT_STRATEGY_NAMESPACE_TEMPLATES, MemorySchema, MemoryStrategyTypeSchema, StreamContentLevelSchema, @@ -287,13 +287,13 @@ export class MemoryPrimitive extends BasePrimitive { - const defaultNamespaces = DEFAULT_STRATEGY_NAMESPACES[s.type]; + const defaultTemplates = DEFAULT_STRATEGY_NAMESPACE_TEMPLATES[s.type]; return { type: s.type, - ...(defaultNamespaces && { namespaces: defaultNamespaces }), - ...(s.type === 'EPISODIC' && { reflectionNamespaces: DEFAULT_EPISODIC_REFLECTION_NAMESPACES }), + ...(defaultTemplates && { namespaceTemplates: defaultTemplates }), + ...(s.type === 'EPISODIC' && { reflectionNamespaceTemplates: DEFAULT_EPISODIC_REFLECTION_NAMESPACE_TEMPLATES }), }; }); diff --git a/src/schema/llm-compacted/agentcore.ts b/src/schema/llm-compacted/agentcore.ts index c86c14cb7..112b122ee 100644 --- a/src/schema/llm-compacted/agentcore.ts +++ b/src/schema/llm-compacted/agentcore.ts @@ -103,8 +103,12 @@ interface MemoryStrategy { type: MemoryStrategyType; name?: string; // @regex ^[a-zA-Z][a-zA-Z0-9_]{0,47}$ @max 48 description?: string; + namespaceTemplates?: string[]; + reflectionNamespaceTemplates?: string[]; // EPISODIC only: templates for cross-episode reflections + /** @deprecated Use namespaceTemplates instead. */ namespaces?: string[]; - reflectionNamespaces?: string[]; // EPISODIC only: namespaces for cross-episode reflections + /** @deprecated Use reflectionNamespaceTemplates instead. */ + reflectionNamespaces?: string[]; } // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index 903db1f19..bc2f169ce 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -21,7 +21,9 @@ import { HarnessNameSchema } from './primitives/harness'; import { HttpGatewaySchema } from './primitives/http-gateway'; import { DEFAULT_EPISODIC_REFLECTION_NAMESPACES, + DEFAULT_EPISODIC_REFLECTION_NAMESPACE_TEMPLATES, DEFAULT_STRATEGY_NAMESPACES, + DEFAULT_STRATEGY_NAMESPACE_TEMPLATES, MemoryStrategySchema, MemoryStrategyTypeSchema, } from './primitives/memory'; @@ -33,7 +35,9 @@ import { z } from 'zod'; // Re-export for convenience export { + DEFAULT_EPISODIC_REFLECTION_NAMESPACE_TEMPLATES, DEFAULT_EPISODIC_REFLECTION_NAMESPACES, + DEFAULT_STRATEGY_NAMESPACE_TEMPLATES, DEFAULT_STRATEGY_NAMESPACES, MemoryStrategySchema, MemoryStrategyTypeSchema, diff --git a/src/schema/schemas/primitives/__tests__/memory.test.ts b/src/schema/schemas/primitives/__tests__/memory.test.ts index 082c32fa6..22d21d2dc 100644 --- a/src/schema/schemas/primitives/__tests__/memory.test.ts +++ b/src/schema/schemas/primitives/__tests__/memory.test.ts @@ -1,4 +1,11 @@ -import { DEFAULT_STRATEGY_NAMESPACES, MemoryStrategySchema, MemoryStrategyTypeSchema } from '../memory'; +import { + DEFAULT_EPISODIC_REFLECTION_NAMESPACES, + DEFAULT_EPISODIC_REFLECTION_NAMESPACE_TEMPLATES, + DEFAULT_STRATEGY_NAMESPACES, + DEFAULT_STRATEGY_NAMESPACE_TEMPLATES, + MemoryStrategySchema, + MemoryStrategyTypeSchema, +} from '../memory'; import { describe, expect, it } from 'vitest'; describe('MemoryStrategyTypeSchema', () => { @@ -26,11 +33,31 @@ describe('MemoryStrategySchema', () => { type: 'SEMANTIC', name: 'myStrategy', description: 'A description', + namespaceTemplates: ['/users/{actorId}/facts'], + }); + expect(result.success).toBe(true); + }); + + it('accepts deprecated namespaces field as backward-compat alias', () => { + const result = MemoryStrategySchema.safeParse({ + type: 'SEMANTIC', namespaces: ['/users/{actorId}/facts'], }); expect(result.success).toBe(true); }); + it('rejects strategy specifying both namespaces and namespaceTemplates', () => { + const result = MemoryStrategySchema.safeParse({ + type: 'SEMANTIC', + namespaces: ['/users/{actorId}/facts'], + namespaceTemplates: ['/users/{actorId}/facts'], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toContain('mutually exclusive'); + } + }); + it('rejects strategy with CUSTOM type', () => { const result = MemoryStrategySchema.safeParse({ type: 'CUSTOM' }); expect(result.success).toBe(false); @@ -46,7 +73,16 @@ describe('MemoryStrategySchema', () => { expect(result.success).toBe(false); }); - it('accepts EPISODIC strategy with reflectionNamespaces', () => { + it('accepts EPISODIC strategy with reflectionNamespaceTemplates', () => { + const result = MemoryStrategySchema.safeParse({ + type: 'EPISODIC', + namespaceTemplates: ['/episodes/{actorId}/{sessionId}'], + reflectionNamespaceTemplates: ['/episodes/{actorId}'], + }); + expect(result.success).toBe(true); + }); + + it('accepts EPISODIC strategy with deprecated reflectionNamespaces alias', () => { const result = MemoryStrategySchema.safeParse({ type: 'EPISODIC', namespaces: ['/episodes/{actorId}/{sessionId}'], @@ -55,74 +91,104 @@ describe('MemoryStrategySchema', () => { expect(result.success).toBe(true); }); - it('rejects EPISODIC strategy without reflectionNamespaces', () => { + it('rejects EPISODIC strategy specifying both reflectionNamespaces and reflectionNamespaceTemplates', () => { const result = MemoryStrategySchema.safeParse({ type: 'EPISODIC', - namespaces: ['/episodes/{actorId}/{sessionId}'], + namespaceTemplates: ['/episodes/{actorId}/{sessionId}'], + reflectionNamespaces: ['/episodes/{actorId}'], + reflectionNamespaceTemplates: ['/episodes/{actorId}'], }); expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('mutually exclusive'))).toBe(true); + } }); - it('rejects EPISODIC strategy with empty reflectionNamespaces', () => { + it('rejects EPISODIC strategy without reflectionNamespaceTemplates', () => { const result = MemoryStrategySchema.safeParse({ type: 'EPISODIC', - namespaces: ['/episodes/{actorId}/{sessionId}'], - reflectionNamespaces: [], + namespaceTemplates: ['/episodes/{actorId}/{sessionId}'], }); expect(result.success).toBe(false); }); - it('allows non-EPISODIC strategies without reflectionNamespaces', () => { + it('rejects EPISODIC strategy with empty reflectionNamespaceTemplates', () => { + const result = MemoryStrategySchema.safeParse({ + type: 'EPISODIC', + namespaceTemplates: ['/episodes/{actorId}/{sessionId}'], + reflectionNamespaceTemplates: [], + }); + expect(result.success).toBe(false); + }); + + it('allows non-EPISODIC strategies without reflectionNamespaceTemplates', () => { const result = MemoryStrategySchema.safeParse({ type: 'SEMANTIC' }); expect(result.success).toBe(true); }); - it('rejects EPISODIC when reflectionNamespaces is not a prefix of namespaces', () => { + it('rejects EPISODIC when reflectionNamespaceTemplates is not a prefix of namespaceTemplates', () => { const result = MemoryStrategySchema.safeParse({ type: 'EPISODIC', - namespaces: ['/episodes/{actorId}/{sessionId}'], - reflectionNamespaces: ['/reflections/{actorId}'], + namespaceTemplates: ['/episodes/{actorId}/{sessionId}'], + reflectionNamespaceTemplates: ['/reflections/{actorId}'], }); expect(result.success).toBe(false); }); - it('accepts EPISODIC when reflectionNamespaces is a prefix of namespaces', () => { + it('accepts EPISODIC when reflectionNamespaceTemplates is a prefix of namespaceTemplates', () => { const result = MemoryStrategySchema.safeParse({ type: 'EPISODIC', - namespaces: ['/episodes/{actorId}/{sessionId}'], - reflectionNamespaces: ['/episodes/{actorId}'], + namespaceTemplates: ['/episodes/{actorId}/{sessionId}'], + reflectionNamespaceTemplates: ['/episodes/{actorId}'], }); expect(result.success).toBe(true); }); - it('accepts EPISODIC when reflectionNamespaces equals namespaces', () => { + it('accepts EPISODIC when reflectionNamespaceTemplates equals namespaceTemplates', () => { const result = MemoryStrategySchema.safeParse({ type: 'EPISODIC', - namespaces: ['/episodes/{actorId}/{sessionId}'], - reflectionNamespaces: ['/episodes/{actorId}/{sessionId}'], + namespaceTemplates: ['/episodes/{actorId}/{sessionId}'], + reflectionNamespaceTemplates: ['/episodes/{actorId}/{sessionId}'], }); expect(result.success).toBe(true); }); + + it('evaluates prefix refinement using deprecated aliases when only they are provided', () => { + const result = MemoryStrategySchema.safeParse({ + type: 'EPISODIC', + namespaces: ['/episodes/{actorId}/{sessionId}'], + reflectionNamespaces: ['/reflections/{actorId}'], + }); + expect(result.success).toBe(false); + }); }); -describe('DEFAULT_STRATEGY_NAMESPACES', () => { - it('has default namespaces for SEMANTIC', () => { - expect(DEFAULT_STRATEGY_NAMESPACES.SEMANTIC).toEqual(['/users/{actorId}/facts']); +describe('DEFAULT_STRATEGY_NAMESPACE_TEMPLATES', () => { + it('has default templates for SEMANTIC', () => { + expect(DEFAULT_STRATEGY_NAMESPACE_TEMPLATES.SEMANTIC).toEqual(['/users/{actorId}/facts']); + }); + + it('has default templates for USER_PREFERENCE', () => { + expect(DEFAULT_STRATEGY_NAMESPACE_TEMPLATES.USER_PREFERENCE).toEqual(['/users/{actorId}/preferences']); + }); + + it('has default templates for SUMMARIZATION', () => { + expect(DEFAULT_STRATEGY_NAMESPACE_TEMPLATES.SUMMARIZATION).toEqual(['/summaries/{actorId}/{sessionId}']); }); - it('has default namespaces for USER_PREFERENCE', () => { - expect(DEFAULT_STRATEGY_NAMESPACES.USER_PREFERENCE).toEqual(['/users/{actorId}/preferences']); + it('has default templates for EPISODIC', () => { + expect(DEFAULT_STRATEGY_NAMESPACE_TEMPLATES.EPISODIC).toEqual(['/episodes/{actorId}/{sessionId}']); }); - it('has default namespaces for SUMMARIZATION', () => { - expect(DEFAULT_STRATEGY_NAMESPACES.SUMMARIZATION).toEqual(['/summaries/{actorId}/{sessionId}']); + it('does not have default templates for CUSTOM (removed)', () => { + expect(DEFAULT_STRATEGY_NAMESPACE_TEMPLATES).not.toHaveProperty('CUSTOM'); }); - it('has default namespaces for EPISODIC', () => { - expect(DEFAULT_STRATEGY_NAMESPACES.EPISODIC).toEqual(['/episodes/{actorId}/{sessionId}']); + it('deprecated alias DEFAULT_STRATEGY_NAMESPACES points to the same object', () => { + expect(DEFAULT_STRATEGY_NAMESPACES).toBe(DEFAULT_STRATEGY_NAMESPACE_TEMPLATES); }); - it('does not have default namespaces for CUSTOM (removed)', () => { - expect(DEFAULT_STRATEGY_NAMESPACES).not.toHaveProperty('CUSTOM'); + it('deprecated alias DEFAULT_EPISODIC_REFLECTION_NAMESPACES points to the same object', () => { + expect(DEFAULT_EPISODIC_REFLECTION_NAMESPACES).toBe(DEFAULT_EPISODIC_REFLECTION_NAMESPACE_TEMPLATES); }); }); diff --git a/src/schema/schemas/primitives/index.ts b/src/schema/schemas/primitives/index.ts index 0a3c808cf..71ec1f65a 100644 --- a/src/schema/schemas/primitives/index.ts +++ b/src/schema/schemas/primitives/index.ts @@ -21,7 +21,9 @@ export { export type { MemoryStrategy, MemoryStrategyType } from './memory'; export { + DEFAULT_EPISODIC_REFLECTION_NAMESPACE_TEMPLATES, DEFAULT_EPISODIC_REFLECTION_NAMESPACES, + DEFAULT_STRATEGY_NAMESPACE_TEMPLATES, DEFAULT_STRATEGY_NAMESPACES, MemoryStrategyNameSchema, MemoryStrategySchema, diff --git a/src/schema/schemas/primitives/memory.ts b/src/schema/schemas/primitives/memory.ts index f63874d5d..5e5a633cb 100644 --- a/src/schema/schemas/primitives/memory.ts +++ b/src/schema/schemas/primitives/memory.ts @@ -17,10 +17,10 @@ export const MemoryStrategyTypeSchema = z.enum(['SEMANTIC', 'SUMMARIZATION', 'US export type MemoryStrategyType = z.infer; /** - * Default namespaces for each memory strategy type. + * Default namespace templates for each memory strategy type. * These match the patterns generated in CLI session.py templates. */ -export const DEFAULT_STRATEGY_NAMESPACES: Partial> = { +export const DEFAULT_STRATEGY_NAMESPACE_TEMPLATES: Partial> = { SEMANTIC: ['/users/{actorId}/facts'], USER_PREFERENCE: ['/users/{actorId}/preferences'], SUMMARIZATION: ['/summaries/{actorId}/{sessionId}'], @@ -28,10 +28,22 @@ export const DEFAULT_STRATEGY_NAMESPACES: Partial !((strategy.namespaces?.length ?? 0) > 0 && (strategy.namespaceTemplates?.length ?? 0) > 0), { + message: + "'namespaces' and 'namespaceTemplates' are mutually exclusive. Prefer 'namespaceTemplates' ('namespaces' is deprecated).", + path: ['namespaceTemplates'], + }) + .refine( + strategy => + !((strategy.reflectionNamespaces?.length ?? 0) > 0 && (strategy.reflectionNamespaceTemplates?.length ?? 0) > 0), + { + message: + "'reflectionNamespaces' and 'reflectionNamespaceTemplates' are mutually exclusive. Prefer 'reflectionNamespaceTemplates' ('reflectionNamespaces' is deprecated).", + path: ['reflectionNamespaceTemplates'], + } + ) .refine( strategy => - strategy.type !== 'EPISODIC' || - (strategy.reflectionNamespaces !== undefined && strategy.reflectionNamespaces.length > 0), + strategy.type === 'EPISODIC' || + (strategy.reflectionNamespaceTemplates === undefined && strategy.reflectionNamespaces === undefined), + { + message: "'reflectionNamespaceTemplates' is only allowed on EPISODIC strategies", + path: ['reflectionNamespaceTemplates'], + } + ) + .refine( + strategy => { + if (strategy.type !== 'EPISODIC') return true; + const reflection = strategy.reflectionNamespaceTemplates ?? strategy.reflectionNamespaces; + return reflection !== undefined && reflection.length > 0; + }, { - message: 'EPISODIC strategy requires reflectionNamespaces', - path: ['reflectionNamespaces'], + message: 'EPISODIC strategy requires reflectionNamespaceTemplates', + path: ['reflectionNamespaceTemplates'], } ) .refine( strategy => { - if (strategy.type !== 'EPISODIC' || !strategy.reflectionNamespaces || !strategy.namespaces) return true; - return strategy.reflectionNamespaces.every(ref => strategy.namespaces!.some(ns => ns.startsWith(ref))); + if (strategy.type !== 'EPISODIC') return true; + const reflection = strategy.reflectionNamespaceTemplates ?? strategy.reflectionNamespaces; + const templates = strategy.namespaceTemplates ?? strategy.namespaces; + if (!reflection || !templates) return true; + return reflection.every(ref => templates.some(ns => ns.startsWith(ref))); }, { - message: 'Each reflectionNamespace must be a prefix of at least one namespace', - path: ['reflectionNamespaces'], + message: 'Each reflectionNamespaceTemplate must be a prefix of at least one namespaceTemplate', + path: ['reflectionNamespaceTemplates'], } );