Skip to content

Commit 522851d

Browse files
committed
feat: add support for LTM metadata
1 parent d92fac2 commit 522851d

15 files changed

Lines changed: 851 additions & 265 deletions

File tree

docs/memory.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,43 @@ Each strategy can have optional configuration:
210210
| `namespaces` | No | **Deprecated alias for `namespaceTemplates`.** Accepted for backward compatibility. |
211211
| `reflectionNamespaces` | EPISODIC only | **Deprecated alias for `reflectionNamespaceTemplates`.** Accepted for backward compatibility. |
212212

213+
## Indexed Metadata Keys
214+
215+
Indexed keys declare metadata fields on a memory that can be used to filter long-term memory records on retrieval. Up to
216+
10 keys per memory.
217+
218+
```bash
219+
agentcore add memory \
220+
--name SupportMemory \
221+
--strategies SEMANTIC \
222+
--indexed-key priority:NUMBER \
223+
--indexed-key agent_type:STRING \
224+
--indexed-key tags:STRINGLIST
225+
```
226+
227+
In `agentcore.json`:
228+
229+
```json
230+
{
231+
"name": "SupportMemory",
232+
"strategies": [{ "type": "SEMANTIC" }],
233+
"indexedKeys": [
234+
{ "key": "priority", "type": "NUMBER" },
235+
{ "key": "agent_type", "type": "STRING" },
236+
{ "key": "tags", "type": "STRINGLIST" }
237+
]
238+
}
239+
```
240+
241+
| Type | Description |
242+
| ------------ | --------------------- |
243+
| `STRING` | Single string value |
244+
| `STRINGLIST` | List of string values |
245+
| `NUMBER` | Numeric value |
246+
247+
Indexed keys require at least one long-term memory strategy. They can only be added to an existing memory — once
248+
declared, an indexed key cannot be removed.
249+
213250
## Event Expiry
214251

215252
Memory events expire after a configurable duration (7-365 days, default 30):

package-lock.json

Lines changed: 181 additions & 215 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
"@aws-sdk/client-bedrock": "^3.1012.0",
7979
"@aws-sdk/client-bedrock-agent": "^3.1012.0",
8080
"@aws-sdk/client-bedrock-agentcore": "^3.1020.0",
81-
"@aws-sdk/client-bedrock-agentcore-control": "^3.1039.0",
81+
"@aws-sdk/client-bedrock-agentcore-control": "^3.1048.0",
8282
"@aws-sdk/client-bedrock-runtime": "^3.893.0",
8383
"@aws-sdk/client-cloudformation": "^3.893.0",
8484
"@aws-sdk/client-cloudwatch-logs": "^3.893.0",

src/cli/aws/agentcore-control.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,7 @@ export interface MemoryDetail {
371371
namespaceTemplates?: string[];
372372
reflectionNamespaceTemplates?: string[];
373373
}[];
374+
indexedKeys?: { key: string; type: string }[];
374375
tags?: Record<string, string>;
375376
encryptionKeyArn?: string;
376377
executionRoleArn?: string;
@@ -408,6 +409,14 @@ export async function getMemoryDetail(options: GetMemoryOptions): Promise<Memory
408409

409410
const tags = await fetchTags(client, memory.arn, 'memory');
410411

412+
const indexedKeys = memory.indexedKeys?.flatMap(k => {
413+
if (!k.key || !k.type) {
414+
console.warn(`Warning: Skipping malformed indexed key from API response: ${JSON.stringify(k)}`);
415+
return [];
416+
}
417+
return [{ key: k.key, type: k.type }];
418+
});
419+
411420
return {
412421
memoryId: memory.id,
413422
memoryArn: memory.arn,
@@ -418,6 +427,7 @@ export async function getMemoryDetail(options: GetMemoryOptions): Promise<Memory
418427
tags,
419428
encryptionKeyArn: memory.encryptionKeyArn,
420429
executionRoleArn: memory.memoryExecutionRoleArn,
430+
...(indexedKeys && indexedKeys.length > 0 && { indexedKeys }),
421431
strategies: (memory.strategies ?? []).map(s => {
422432
if (!s.type) {
423433
throw new Error(`Memory ${options.memoryId} has a strategy with missing required field: type`);

src/cli/commands/add/__tests__/validate.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1126,6 +1126,118 @@ describe('validate', () => {
11261126
expect(result.valid).toBe(false);
11271127
expect(result.error).toContain('does not match the expected schema');
11281128
});
1129+
1130+
// Indexed keys: requires LTM strategy
1131+
it('rejects --indexed-key without any LTM strategy', () => {
1132+
const result = validateAddMemoryOptions({
1133+
...validMemoryOptions,
1134+
strategies: undefined,
1135+
indexedKey: ['priority:NUMBER'],
1136+
});
1137+
expect(result.valid).toBe(false);
1138+
expect(result.error).toContain('requires at least one long-term memory strategy');
1139+
});
1140+
1141+
it('accepts --indexed-key with an LTM strategy', () => {
1142+
expect(
1143+
validateAddMemoryOptions({
1144+
...validMemoryOptions,
1145+
strategies: 'SEMANTIC',
1146+
indexedKey: ['priority:NUMBER'],
1147+
})
1148+
).toEqual({ valid: true });
1149+
});
1150+
1151+
it('rejects more than 10 indexed keys', () => {
1152+
const eleven = Array.from({ length: 11 }, (_, i) => `k${i}:STRING`);
1153+
const result = validateAddMemoryOptions({
1154+
...validMemoryOptions,
1155+
strategies: 'SEMANTIC',
1156+
indexedKey: eleven,
1157+
});
1158+
expect(result.valid).toBe(false);
1159+
expect(result.error).toContain('Maximum 10 indexed keys');
1160+
});
1161+
1162+
it('accepts exactly 10 indexed keys (boundary)', () => {
1163+
const ten = Array.from({ length: 10 }, (_, i) => `k${i}:STRING`);
1164+
expect(validateAddMemoryOptions({ ...validMemoryOptions, strategies: 'SEMANTIC', indexedKey: ten })).toEqual({
1165+
valid: true,
1166+
});
1167+
});
1168+
1169+
it('rejects an empty key (":STRING")', () => {
1170+
const result = validateAddMemoryOptions({
1171+
...validMemoryOptions,
1172+
strategies: 'SEMANTIC',
1173+
indexedKey: [':STRING'],
1174+
});
1175+
expect(result.valid).toBe(false);
1176+
expect(result.error).toContain('Key name cannot be empty');
1177+
});
1178+
1179+
it('rejects a key longer than 128 characters', () => {
1180+
const longKey = 'a'.repeat(129);
1181+
const result = validateAddMemoryOptions({
1182+
...validMemoryOptions,
1183+
strategies: 'SEMANTIC',
1184+
indexedKey: [`${longKey}:STRING`],
1185+
});
1186+
expect(result.valid).toBe(false);
1187+
expect(result.error).toContain('exceeds maximum length');
1188+
});
1189+
1190+
it('rejects an invalid type token', () => {
1191+
const result = validateAddMemoryOptions({
1192+
...validMemoryOptions,
1193+
strategies: 'SEMANTIC',
1194+
indexedKey: ['priority:INTEGER'],
1195+
});
1196+
expect(result.valid).toBe(false);
1197+
expect(result.error).toContain('Invalid type');
1198+
});
1199+
1200+
it('rejects duplicate keys', () => {
1201+
const result = validateAddMemoryOptions({
1202+
...validMemoryOptions,
1203+
strategies: 'SEMANTIC',
1204+
indexedKey: ['priority:NUMBER', 'priority:STRING'],
1205+
});
1206+
expect(result.valid).toBe(false);
1207+
expect(result.error).toContain('Duplicate indexed key');
1208+
});
1209+
1210+
it('rejects whitespace-only key', () => {
1211+
const result = validateAddMemoryOptions({
1212+
...validMemoryOptions,
1213+
strategies: 'SEMANTIC',
1214+
indexedKey: [' :STRING'],
1215+
});
1216+
expect(result.valid).toBe(false);
1217+
expect(result.error).toContain('whitespace');
1218+
});
1219+
1220+
it('rejects malformed entry without colon', () => {
1221+
const result = validateAddMemoryOptions({
1222+
...validMemoryOptions,
1223+
strategies: 'SEMANTIC',
1224+
indexedKey: ['priority'],
1225+
});
1226+
expect(result.valid).toBe(false);
1227+
expect(result.error).toContain('Expected key:TYPE');
1228+
});
1229+
1230+
it.each([
1231+
['user.email:STRING'],
1232+
['tag/v2:STRINGLIST'],
1233+
['kebab-case:STRING'],
1234+
['x-custom:STRING'],
1235+
['has:colons:in:key:NUMBER'],
1236+
])('accepts punctuation-rich key %s', raw => {
1237+
expect(validateAddMemoryOptions({ ...validMemoryOptions, strategies: 'SEMANTIC', indexedKey: [raw] })).toEqual({
1238+
valid: true,
1239+
});
1240+
});
11291241
});
11301242

11311243
describe('validateAddCredentialOptions', () => {

src/cli/commands/add/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export interface AddMemoryOptions {
9696
dataStreamArn?: string;
9797
contentLevel?: string;
9898
streamDeliveryResources?: string;
99+
indexedKey?: string[];
99100
json?: boolean;
100101
}
101102

src/cli/commands/add/validate.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from '../../../schema';
2020
import { ARN_VALIDATION_MESSAGE, isValidArn } from '../shared/arn-utils';
2121
import { validateHeaderAllowlist } from '../shared/header-utils';
22+
import { MAX_INDEXED_KEYS, parseIndexedKeyArg } from '../shared/indexed-key-parser';
2223
import { parseAndValidateLifecycleOptions } from '../shared/lifecycle-utils';
2324
import { validateVpcOptions } from '../shared/vpc-utils';
2425
import { validateJwtAuthorizerOptions } from './auth-options';
@@ -721,6 +722,37 @@ export function validateAddMemoryOptions(options: AddMemoryOptions): ValidationR
721722
}
722723
}
723724

725+
if (options.indexedKey && options.indexedKey.length > 0) {
726+
const ltmStrategies = (options.strategies ?? '')
727+
.split(',')
728+
.map(s => s.trim().toUpperCase())
729+
.filter(Boolean);
730+
if (ltmStrategies.length === 0) {
731+
return {
732+
valid: false,
733+
error:
734+
'--indexed-key requires at least one long-term memory strategy (--strategies). Indexed keys filter long-term memory records on retrieval.',
735+
};
736+
}
737+
738+
if (options.indexedKey.length > MAX_INDEXED_KEYS) {
739+
return { valid: false, error: `Maximum ${MAX_INDEXED_KEYS} indexed keys allowed` };
740+
}
741+
742+
const seenKeys = new Set<string>();
743+
for (const raw of options.indexedKey) {
744+
const result = parseIndexedKeyArg(raw);
745+
if (!result.ok) {
746+
return { valid: false, error: result.error };
747+
}
748+
const { key } = result.value;
749+
if (seenKeys.has(key)) {
750+
return { valid: false, error: `Duplicate indexed key: "${key}"` };
751+
}
752+
seenKeys.add(key);
753+
}
754+
}
755+
724756
if (options.streamDeliveryResources && (options.dataStreamArn || options.contentLevel || options.deliveryType)) {
725757
return {
726758
valid: false,

src/cli/commands/import/import-memory.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Memory } from '../../../schema';
2+
import { IndexedKeyTypeSchema } from '../../../schema';
23
import type { MemoryDetail, MemorySummary } from '../../aws/agentcore-control';
34
import { getMemoryDetail, listAllMemories } from '../../aws/agentcore-control';
45
import { ANSI } from './constants';
@@ -55,10 +56,26 @@ function toMemorySpec(memory: MemoryDetail, localName: string): Memory {
5556
};
5657
});
5758

59+
// Validate each indexed key's type against our enum. Drop
60+
// entries whose type is not one we recognize with a warning
61+
const indexedKeys: Memory['indexedKeys'] = memory.indexedKeys
62+
?.flatMap(k => {
63+
const parsedType = IndexedKeyTypeSchema.safeParse(k.type);
64+
if (!parsedType.success) {
65+
console.warn(
66+
`${ANSI.yellow}[warn]${ANSI.reset} Skipping indexed key "${k.key}" with unrecognised type "${k.type}".`
67+
);
68+
return [];
69+
}
70+
return [{ key: k.key, type: parsedType.data }];
71+
})
72+
.filter(Boolean);
73+
5874
return {
5975
name: localName,
6076
eventExpiryDuration: Math.max(3, Math.min(365, memory.eventExpiryDuration)),
6177
strategies,
78+
...(indexedKeys && indexedKeys.length > 0 && { indexedKeys }),
6279
...(memory.tags && Object.keys(memory.tags).length > 0 && { tags: memory.tags }),
6380
...(memory.encryptionKeyArn && { encryptionKeyArn: memory.encryptionKeyArn }),
6481
...(memory.executionRoleArn && { executionRoleArn: memory.executionRoleArn }),
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type { IndexedKey, IndexedKeyType } from '../../../schema';
2+
import { IndexedKeyTypeSchema } from '../../../schema';
3+
4+
export const INDEXED_KEY_NAME_PATTERN = /^[a-zA-Z0-9\s._:/=+@-]+$/;
5+
export const MAX_INDEXED_KEYS = 10;
6+
export const MAX_KEY_NAME_LENGTH = 128;
7+
export const VALID_INDEXED_KEY_TYPES: readonly IndexedKeyType[] = ['STRING', 'STRINGLIST', 'NUMBER'];
8+
9+
export interface IndexedKeyParseError {
10+
ok: false;
11+
error: string;
12+
}
13+
14+
export interface IndexedKeyParseSuccess {
15+
ok: true;
16+
value: IndexedKey;
17+
}
18+
19+
export type IndexedKeyParseResult = IndexedKeyParseError | IndexedKeyParseSuccess;
20+
21+
/**
22+
* Parse a single `key:TYPE` argument into a validated IndexedKey.
23+
*
24+
* Splits on the *last* `:` so that key names may contain `:` (the AgentCore
25+
* service accepts `:` in indexed key names; type tokens never do).
26+
*/
27+
export function parseIndexedKeyArg(raw: string): IndexedKeyParseResult {
28+
const colonIdx = raw.lastIndexOf(':');
29+
if (colonIdx === -1) {
30+
return { ok: false, error: `Invalid indexed key format: "${raw}". Expected key:TYPE (e.g. priority:NUMBER)` };
31+
}
32+
const key = raw.slice(0, colonIdx);
33+
const typeToken = raw.slice(colonIdx + 1).toUpperCase();
34+
35+
if (!key) {
36+
return { ok: false, error: `Invalid indexed key format: "${raw}". Key name cannot be empty` };
37+
}
38+
if (key.length > MAX_KEY_NAME_LENGTH) {
39+
return {
40+
ok: false,
41+
error: `Indexed key name "${key}" exceeds maximum length of ${MAX_KEY_NAME_LENGTH} characters`,
42+
};
43+
}
44+
if (!INDEXED_KEY_NAME_PATTERN.test(key)) {
45+
return {
46+
ok: false,
47+
error: `Invalid indexed key name "${key}". Must contain only alphanumeric characters, whitespace, or the symbols . _ : / = + @ -`,
48+
};
49+
}
50+
if (key.trim().length === 0) {
51+
return { ok: false, error: `Invalid indexed key name "${key}". Key cannot be only whitespace` };
52+
}
53+
const parsedType = IndexedKeyTypeSchema.safeParse(typeToken);
54+
if (!parsedType.success) {
55+
return {
56+
ok: false,
57+
error: `Invalid type "${typeToken}" for indexed key "${key}". Must be one of: ${VALID_INDEXED_KEY_TYPES.join(', ')}`,
58+
};
59+
}
60+
return { ok: true, value: { key, type: parsedType.data } };
61+
}

0 commit comments

Comments
 (0)