Skip to content

Commit 44b5c2c

Browse files
authored
feat: add support for LTM metadata (#1281)
* feat: add support for LTM metadata * fix: TUI UX bug, linter failures and missing telemetry
1 parent 4c5077c commit 44b5c2c

17 files changed

Lines changed: 880 additions & 266 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):

npm-shrinkwrap.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
@@ -79,7 +79,7 @@
7979
"@aws-sdk/client-bedrock": "^3.1012.0",
8080
"@aws-sdk/client-bedrock-agent": "^3.1012.0",
8181
"@aws-sdk/client-bedrock-agentcore": "^3.1020.0",
82-
"@aws-sdk/client-bedrock-agentcore-control": "^3.1039.0",
82+
"@aws-sdk/client-bedrock-agentcore-control": "^3.1048.0",
8383
"@aws-sdk/client-bedrock-runtime": "^3.893.0",
8484
"@aws-sdk/client-cloudformation": "^3.893.0",
8585
"@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/aws/policy-generation.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
StartPolicyGenerationCommand,
77
waitUntilPolicyGenerationCompleted,
88
} from '@aws-sdk/client-bedrock-agentcore-control';
9-
import { WaiterState } from '@smithy/util-waiter';
9+
import { WaiterState } from '@smithy/core/client';
1010

1111
export interface StartPolicyGenerationOptions {
1212
policyEngineId: string;

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';
@@ -726,6 +727,37 @@ export function validateAddMemoryOptions(options: AddMemoryOptions): ValidationR
726727
}
727728
}
728729

730+
if (options.indexedKey && options.indexedKey.length > 0) {
731+
const ltmStrategies = (options.strategies ?? '')
732+
.split(',')
733+
.map(s => s.trim().toUpperCase())
734+
.filter(Boolean);
735+
if (ltmStrategies.length === 0) {
736+
return {
737+
valid: false,
738+
error:
739+
'--indexed-key requires at least one long-term memory strategy (--strategies). Indexed keys filter long-term memory records on retrieval.',
740+
};
741+
}
742+
743+
if (options.indexedKey.length > MAX_INDEXED_KEYS) {
744+
return { valid: false, error: `Maximum ${MAX_INDEXED_KEYS} indexed keys allowed` };
745+
}
746+
747+
const seenKeys = new Set<string>();
748+
for (const raw of options.indexedKey) {
749+
const result = parseIndexedKeyArg(raw);
750+
if (!result.ok) {
751+
return { valid: false, error: result.error };
752+
}
753+
const { key } = result.value;
754+
if (seenKeys.has(key)) {
755+
return { valid: false, error: `Duplicate indexed key: "${key}"` };
756+
}
757+
seenKeys.add(key);
758+
}
759+
}
760+
729761
if (options.streamDeliveryResources && (options.dataStreamArn || options.contentLevel || options.deliveryType)) {
730762
return {
731763
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 { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js';
@@ -56,10 +57,26 @@ function toMemorySpec(memory: MemoryDetail, localName: string): Memory {
5657
};
5758
});
5859

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

0 commit comments

Comments
 (0)