Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"@aws-cdk/aws-bedrock-agentcore-alpha": "2.238.0-alpha.0",
"@aws-cdk/aws-bedrock-alpha": "2.238.0-alpha.0",
"@aws-cdk/mixins-preview": "2.238.0-alpha.0",
"@aws-sdk/client-bedrock-agentcore": "^3.1021.0",
"@aws-sdk/client-bedrock-agentcore": "^3.1046.0",
"@aws-sdk/client-bedrock-runtime": "^3.1021.0",
"@aws-sdk/client-ecs": "^3.1021.0",
"@aws-sdk/client-dynamodb": "^3.1021.0",
Expand Down
30 changes: 15 additions & 15 deletions cdk/src/handlers/shared/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ function processMemoryRecords(
records: MemoryRecordSummary[],
out: string[],
repo: string,
namespace: string,
namespacePath: string,
recordType: string,
): void {
for (const record of records) {
Expand All @@ -129,7 +129,7 @@ function processMemoryRecords(
// CloudWatch alarms can detect spikes (genuine tampering or write bugs).
logger.warn('Memory record hash mismatch (expected for extracted records)', {
repo,
namespace,
namespace_path: namespacePath,
record_type: recordType,
expected_hash: record.metadata?.content_sha256?.stringValue ?? '(none)',
actual_hash: hashContent(sanitized),
Expand Down Expand Up @@ -164,7 +164,10 @@ function getClient(): BedrockAgentCoreClient {
*
* Namespaces match the templates configured on the extraction strategies:
* - Semantic: `/{actorId}/knowledge/` (actorId = repo)
* - Episodic: `/{actorId}/episodes/` (prefix matches all sessions)
* - Episodic: `/{actorId}/episodes/` (covers all sessions and reflections)
*
* Both calls use `namespacePath` for hierarchical retrieval — episodic per-task
* records live at `/{actorId}/episodes/{sessionId}/`, which is below the read path.
*
* Results are trimmed to a 2000-token budget (knowledge is prioritized before episodes;
* entries beyond the budget are dropped).
Expand All @@ -183,14 +186,11 @@ export async function loadMemoryContext(
try {
const client = getClient();

// Namespaces derived from the strategy templates configured in agent-memory.ts:
// Semantic: /{actorId}/knowledge/
// Episodic: /{actorId}/episodes/{sessionId}/
// Events are written with actorId = repo (e.g. "krokoko/agent-plugins"),
// so extracted records land at /{repo}/knowledge/ and /{repo}/episodes/{taskId}/.
// Reads use these paths as namespace prefixes.
const semanticNamespace = `/${repo}/knowledge/`;
const episodicNamespace = `/${repo}/episodes/`;
// Namespace paths derived from the strategy templates configured in agent-memory.ts.
// Events are written with actorId = repo, so extracted records land at
// /{repo}/knowledge/ and /{repo}/episodes/{taskId}/.
const semanticNamespacePath = `/${repo}/knowledge/`;
const episodicNamespacePath = `/${repo}/episodes/`;

// Run semantic and episodic searches in parallel
// eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism
Expand All @@ -199,7 +199,7 @@ export async function loadMemoryContext(
taskDescription
? client.send(new RetrieveMemoryRecordsCommand({
memoryId,
namespace: semanticNamespace,
namespacePath: semanticNamespacePath,
searchCriteria: {
searchQuery: taskDescription,
topK: 5,
Expand All @@ -212,7 +212,7 @@ export async function loadMemoryContext(
// Episodic search — recent task episodes (prefix matches all sessions)
client.send(new RetrieveMemoryRecordsCommand({
memoryId,
namespace: episodicNamespace,
namespacePath: episodicNamespacePath,
searchCriteria: {
searchQuery: 'recent task episodes',
topK: 3,
Expand All @@ -227,11 +227,11 @@ export async function loadMemoryContext(
const pastEpisodes: string[] = [];

if (semanticResult?.memoryRecordSummaries) {
processMemoryRecords(semanticResult.memoryRecordSummaries, repoKnowledge, repo, semanticNamespace, 'repo_knowledge');
processMemoryRecords(semanticResult.memoryRecordSummaries, repoKnowledge, repo, semanticNamespacePath, 'repo_knowledge');
}

if (episodicResult?.memoryRecordSummaries) {
processMemoryRecords(episodicResult.memoryRecordSummaries, pastEpisodes, repo, episodicNamespace, 'past_episode');
processMemoryRecords(episodicResult.memoryRecordSummaries, pastEpisodes, repo, episodicNamespacePath, 'past_episode');
}

if (repoKnowledge.length === 0 && pastEpisodes.length === 0) {
Expand Down
24 changes: 17 additions & 7 deletions cdk/test/handlers/shared/memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,32 +69,42 @@ describe('loadMemoryContext', () => {
expect(result!.repo_knowledge[0]).toContain('Jest');
});

test('uses repo-based namespaces for queries', async () => {
test('uses namespacePath (hierarchical retrieval) for both queries', async () => {
const { RetrieveMemoryRecordsCommand } = jest.requireMock('@aws-sdk/client-bedrock-agentcore');
mockAgentCoreSend
.mockResolvedValueOnce({ memoryRecordSummaries: [] })
.mockResolvedValueOnce({ memoryRecordSummaries: [] });

await loadMemoryContext('mem-123', 'owner/repo', 'Fix the build');

// Semantic search uses /{repo}/knowledge/ namespace
// Semantic search uses /{repo}/knowledge/ as namespacePath. The legacy
// `namespace` field switched from prefix-match to exact-match in the
// AgentCore Memory API; namespacePath preserves the hierarchical (prefix)
// semantics this code depends on for episodic per-task records nested
// under /{repo}/episodes/{sessionId}/.
expect(RetrieveMemoryRecordsCommand).toHaveBeenCalledWith(
expect.objectContaining({
namespace: '/owner/repo/knowledge/',
namespacePath: '/owner/repo/knowledge/',
searchCriteria: expect.objectContaining({
searchQuery: 'Fix the build',
}),
}),
);
// Episodic search uses /{repo}/episodes/ namespace prefix
// Episodic search uses /{repo}/episodes/ namespacePath to scoop up records
// under all task sessions plus the cross-task reflection records.
expect(RetrieveMemoryRecordsCommand).toHaveBeenCalledWith(
expect.objectContaining({
namespace: '/owner/repo/episodes/',
namespacePath: '/owner/repo/episodes/',
searchCriteria: expect.objectContaining({
searchQuery: 'recent task episodes',
}),
}),
);
// Confirm the legacy `namespace` field is NOT being passed — we don't
// want to send both fields (the API rejects that) or the wrong one.
expect(RetrieveMemoryRecordsCommand).not.toHaveBeenCalledWith(
expect.objectContaining({ namespace: expect.anything() }),
);
});

test('returns undefined when no results are found', async () => {
Expand Down Expand Up @@ -203,7 +213,7 @@ describe('loadMemoryContext', () => {
expect.stringContaining('hash mismatch'),
expect.objectContaining({
repo: 'owner/repo',
namespace: '/owner/repo/knowledge/',
namespace_path: '/owner/repo/knowledge/',
record_type: 'repo_knowledge',
expected_hash: wrongHash,
source_type: 'agent_learning',
Expand Down Expand Up @@ -235,7 +245,7 @@ describe('loadMemoryContext', () => {
expect.stringContaining('hash mismatch'),
expect.objectContaining({
repo: 'owner/repo',
namespace: '/owner/repo/episodes/',
namespace_path: '/owner/repo/episodes/',
record_type: 'past_episode',
expected_hash: wrongHash,
source_type: 'agent_episode',
Expand Down
3 changes: 2 additions & 1 deletion docs/design/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ Per-user preferences extracted from task descriptions (explicit) and review patt
| Component | Strategy | Namespace | Read | Write |
|---|---|---|---|---|
| Repo knowledge | Semantic (`SemanticKnowledge`) | `/{actorId}/knowledge/` | Task start | Task end |
| Task episodes | Episodic (`TaskEpisodes`) | `/{actorId}/episodes/{sessionId}/` | Task start (prefix match) | Task end |
| Task episodes | Episodic (`TaskEpisodes`) | `/{actorId}/episodes/{sessionId}/` | Task start | Task end |
| Review feedback | Custom (planned) | `/{actorId}/review-rules/` | Task start | PR review webhook |
| User preferences | User preference (planned) | `users/{username}` | Task start | Extracted from patterns |
| Self-feedback | Semantic (`SemanticKnowledge`) | `/{actorId}/knowledge/` | Task start | Task end |
Expand All @@ -126,6 +126,7 @@ Namespace conventions:
- `{actorId}` and `{sessionId}` are the only valid AgentCore template variables. Templates are set on extraction strategies at resource creation.
- `actorId = "owner/repo"` for all writes. `sessionId = taskId` for episodic partitioning.
- Changing namespace templates requires recreating the Memory resource (breaking infrastructure change).
- Reads use the `namespacePath` field on [`RetrieveMemoryRecords`](https://docs.aws.amazon.com/bedrock-agentcore/latest/APIReference/API_RetrieveMemoryRecords.html) for hierarchical retrieval — episodic records live one level below the parent path so a hierarchical query is required to surface them.

## Memory consolidation

Expand Down
3 changes: 2 additions & 1 deletion docs/src/content/docs/architecture/Memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ Per-user preferences extracted from task descriptions (explicit) and review patt
| Component | Strategy | Namespace | Read | Write |
|---|---|---|---|---|
| Repo knowledge | Semantic (`SemanticKnowledge`) | `/{actorId}/knowledge/` | Task start | Task end |
| Task episodes | Episodic (`TaskEpisodes`) | `/{actorId}/episodes/{sessionId}/` | Task start (prefix match) | Task end |
| Task episodes | Episodic (`TaskEpisodes`) | `/{actorId}/episodes/{sessionId}/` | Task start | Task end |
| Review feedback | Custom (planned) | `/{actorId}/review-rules/` | Task start | PR review webhook |
| User preferences | User preference (planned) | `users/{username}` | Task start | Extracted from patterns |
| Self-feedback | Semantic (`SemanticKnowledge`) | `/{actorId}/knowledge/` | Task start | Task end |
Expand All @@ -130,6 +130,7 @@ Namespace conventions:
- `{actorId}` and `{sessionId}` are the only valid AgentCore template variables. Templates are set on extraction strategies at resource creation.
- `actorId = "owner/repo"` for all writes. `sessionId = taskId` for episodic partitioning.
- Changing namespace templates requires recreating the Memory resource (breaking infrastructure change).
- Reads use the `namespacePath` field on [`RetrieveMemoryRecords`](https://docs.aws.amazon.com/bedrock-agentcore/latest/APIReference/API_RetrieveMemoryRecords.html) for hierarchical retrieval — episodic records live one level below the parent path so a hierarchical query is required to surface them.

## Memory consolidation

Expand Down
Loading