Skip to content
Open
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
37 changes: 37 additions & 0 deletions docs/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,43 @@ Each strategy can have optional configuration:
| `namespaces` | No | **Deprecated alias for `namespaceTemplates`.** Accepted for backward compatibility. |
| `reflectionNamespaces` | EPISODIC only | **Deprecated alias for `reflectionNamespaceTemplates`.** Accepted for backward compatibility. |

## Indexed Metadata Keys

Indexed keys declare metadata fields on a memory that can be used to filter long-term memory records on retrieval. Up to
10 keys per memory.

```bash
agentcore add memory \
--name SupportMemory \
--strategies SEMANTIC \
--indexed-key priority:NUMBER \
--indexed-key agent_type:STRING \
--indexed-key tags:STRINGLIST
```

In `agentcore.json`:

```json
{
"name": "SupportMemory",
"strategies": [{ "type": "SEMANTIC" }],
"indexedKeys": [
{ "key": "priority", "type": "NUMBER" },
{ "key": "agent_type", "type": "STRING" },
{ "key": "tags", "type": "STRINGLIST" }
]
}
```

| Type | Description |
| ------------ | --------------------- |
| `STRING` | Single string value |
| `STRINGLIST` | List of string values |
| `NUMBER` | Numeric value |

Indexed keys require at least one long-term memory strategy. They can only be added to an existing memory — once
declared, an indexed key cannot be removed.

## Event Expiry

Memory events expire after a configurable duration (7-365 days, default 30):
Expand Down
396 changes: 181 additions & 215 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
"@aws-sdk/client-bedrock": "^3.1012.0",
"@aws-sdk/client-bedrock-agent": "^3.1012.0",
"@aws-sdk/client-bedrock-agentcore": "^3.1020.0",
"@aws-sdk/client-bedrock-agentcore-control": "^3.1039.0",
"@aws-sdk/client-bedrock-agentcore-control": "^3.1048.0",
"@aws-sdk/client-bedrock-runtime": "^3.893.0",
"@aws-sdk/client-cloudformation": "^3.893.0",
"@aws-sdk/client-cloudwatch-logs": "^3.893.0",
Expand Down
10 changes: 10 additions & 0 deletions src/cli/aws/agentcore-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ export interface MemoryDetail {
namespaceTemplates?: string[];
reflectionNamespaceTemplates?: string[];
}[];
indexedKeys?: { key: string; type: string }[];
tags?: Record<string, string>;
encryptionKeyArn?: string;
executionRoleArn?: string;
Expand Down Expand Up @@ -408,6 +409,14 @@ export async function getMemoryDetail(options: GetMemoryOptions): Promise<Memory

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

const indexedKeys = memory.indexedKeys?.flatMap(k => {
if (!k.key || !k.type) {
console.warn(`Warning: Skipping malformed indexed key from API response: ${JSON.stringify(k)}`);
return [];
}
return [{ key: k.key, type: k.type }];
});

return {
memoryId: memory.id,
memoryArn: memory.arn,
Expand All @@ -418,6 +427,7 @@ export async function getMemoryDetail(options: GetMemoryOptions): Promise<Memory
tags,
encryptionKeyArn: memory.encryptionKeyArn,
executionRoleArn: memory.memoryExecutionRoleArn,
...(indexedKeys && indexedKeys.length > 0 && { indexedKeys }),
strategies: (memory.strategies ?? []).map(s => {
if (!s.type) {
throw new Error(`Memory ${options.memoryId} has a strategy with missing required field: type`);
Expand Down
2 changes: 1 addition & 1 deletion src/cli/aws/policy-generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
StartPolicyGenerationCommand,
waitUntilPolicyGenerationCompleted,
} from '@aws-sdk/client-bedrock-agentcore-control';
import { WaiterState } from '@smithy/util-waiter';
import { WaiterState } from '@smithy/core/client';

export interface StartPolicyGenerationOptions {
policyEngineId: string;
Expand Down
112 changes: 112 additions & 0 deletions src/cli/commands/add/__tests__/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1126,6 +1126,118 @@ describe('validate', () => {
expect(result.valid).toBe(false);
expect(result.error).toContain('does not match the expected schema');
});

// Indexed keys: requires LTM strategy
it('rejects --indexed-key without any LTM strategy', () => {
const result = validateAddMemoryOptions({
...validMemoryOptions,
strategies: undefined,
indexedKey: ['priority:NUMBER'],
});
expect(result.valid).toBe(false);
expect(result.error).toContain('requires at least one long-term memory strategy');
});

it('accepts --indexed-key with an LTM strategy', () => {
expect(
validateAddMemoryOptions({
...validMemoryOptions,
strategies: 'SEMANTIC',
indexedKey: ['priority:NUMBER'],
})
).toEqual({ valid: true });
});

it('rejects more than 10 indexed keys', () => {
const eleven = Array.from({ length: 11 }, (_, i) => `k${i}:STRING`);
const result = validateAddMemoryOptions({
...validMemoryOptions,
strategies: 'SEMANTIC',
indexedKey: eleven,
});
expect(result.valid).toBe(false);
expect(result.error).toContain('Maximum 10 indexed keys');
});

it('accepts exactly 10 indexed keys (boundary)', () => {
const ten = Array.from({ length: 10 }, (_, i) => `k${i}:STRING`);
expect(validateAddMemoryOptions({ ...validMemoryOptions, strategies: 'SEMANTIC', indexedKey: ten })).toEqual({
valid: true,
});
});

it('rejects an empty key (":STRING")', () => {
const result = validateAddMemoryOptions({
...validMemoryOptions,
strategies: 'SEMANTIC',
indexedKey: [':STRING'],
});
expect(result.valid).toBe(false);
expect(result.error).toContain('Key name cannot be empty');
});

it('rejects a key longer than 128 characters', () => {
const longKey = 'a'.repeat(129);
const result = validateAddMemoryOptions({
...validMemoryOptions,
strategies: 'SEMANTIC',
indexedKey: [`${longKey}:STRING`],
});
expect(result.valid).toBe(false);
expect(result.error).toContain('exceeds maximum length');
});

it('rejects an invalid type token', () => {
const result = validateAddMemoryOptions({
...validMemoryOptions,
strategies: 'SEMANTIC',
indexedKey: ['priority:INTEGER'],
});
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid type');
});

it('rejects duplicate keys', () => {
const result = validateAddMemoryOptions({
...validMemoryOptions,
strategies: 'SEMANTIC',
indexedKey: ['priority:NUMBER', 'priority:STRING'],
});
expect(result.valid).toBe(false);
expect(result.error).toContain('Duplicate indexed key');
});

it('rejects whitespace-only key', () => {
const result = validateAddMemoryOptions({
...validMemoryOptions,
strategies: 'SEMANTIC',
indexedKey: [' :STRING'],
});
expect(result.valid).toBe(false);
expect(result.error).toContain('whitespace');
});

it('rejects malformed entry without colon', () => {
const result = validateAddMemoryOptions({
...validMemoryOptions,
strategies: 'SEMANTIC',
indexedKey: ['priority'],
});
expect(result.valid).toBe(false);
expect(result.error).toContain('Expected key:TYPE');
});

it.each([
['user.email:STRING'],
['tag/v2:STRINGLIST'],
['kebab-case:STRING'],
['x-custom:STRING'],
['has:colons:in:key:NUMBER'],
])('accepts punctuation-rich key %s', raw => {
expect(validateAddMemoryOptions({ ...validMemoryOptions, strategies: 'SEMANTIC', indexedKey: [raw] })).toEqual({
valid: true,
});
});
});

describe('validateAddCredentialOptions', () => {
Expand Down
1 change: 1 addition & 0 deletions src/cli/commands/add/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export interface AddMemoryOptions {
dataStreamArn?: string;
contentLevel?: string;
streamDeliveryResources?: string;
indexedKey?: string[];
json?: boolean;
}

Expand Down
32 changes: 32 additions & 0 deletions src/cli/commands/add/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from '../../../schema';
import { ARN_VALIDATION_MESSAGE, isValidArn } from '../shared/arn-utils';
import { validateHeaderAllowlist } from '../shared/header-utils';
import { MAX_INDEXED_KEYS, parseIndexedKeyArg } from '../shared/indexed-key-parser';
import { parseAndValidateLifecycleOptions } from '../shared/lifecycle-utils';
import { validateVpcOptions } from '../shared/vpc-utils';
import { validateJwtAuthorizerOptions } from './auth-options';
Expand Down Expand Up @@ -721,6 +722,37 @@ export function validateAddMemoryOptions(options: AddMemoryOptions): ValidationR
}
}

if (options.indexedKey && options.indexedKey.length > 0) {
const ltmStrategies = (options.strategies ?? '')
.split(',')
.map(s => s.trim().toUpperCase())
.filter(Boolean);
if (ltmStrategies.length === 0) {
return {
valid: false,
error:
'--indexed-key requires at least one long-term memory strategy (--strategies). Indexed keys filter long-term memory records on retrieval.',
};
}

if (options.indexedKey.length > MAX_INDEXED_KEYS) {
return { valid: false, error: `Maximum ${MAX_INDEXED_KEYS} indexed keys allowed` };
}

const seenKeys = new Set<string>();
for (const raw of options.indexedKey) {
const result = parseIndexedKeyArg(raw);
if (!result.ok) {
return { valid: false, error: result.error };
}
const { key } = result.value;
if (seenKeys.has(key)) {
return { valid: false, error: `Duplicate indexed key: "${key}"` };
}
seenKeys.add(key);
}
}

if (options.streamDeliveryResources && (options.dataStreamArn || options.contentLevel || options.deliveryType)) {
return {
valid: false,
Expand Down
17 changes: 17 additions & 0 deletions src/cli/commands/import/import-memory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Memory } from '../../../schema';
import { IndexedKeyTypeSchema } from '../../../schema';
import type { MemoryDetail, MemorySummary } from '../../aws/agentcore-control';
import { getMemoryDetail, listAllMemories } from '../../aws/agentcore-control';
import { ANSI } from './constants';
Expand Down Expand Up @@ -55,10 +56,26 @@ function toMemorySpec(memory: MemoryDetail, localName: string): Memory {
};
});

// Validate each indexed key's type against our enum. Drop
// entries whose type is not one we recognize with a warning
const indexedKeys: Memory['indexedKeys'] = memory.indexedKeys
?.flatMap(k => {
const parsedType = IndexedKeyTypeSchema.safeParse(k.type);
if (!parsedType.success) {
console.warn(
`${ANSI.yellow}[warn]${ANSI.reset} Skipping indexed key "${k.key}" with unrecognised type "${k.type}".`
);
return [];
}
return [{ key: k.key, type: parsedType.data }];
})
.filter(Boolean);

return {
name: localName,
eventExpiryDuration: Math.max(3, Math.min(365, memory.eventExpiryDuration)),
strategies,
...(indexedKeys && indexedKeys.length > 0 && { indexedKeys }),
...(memory.tags && Object.keys(memory.tags).length > 0 && { tags: memory.tags }),
...(memory.encryptionKeyArn && { encryptionKeyArn: memory.encryptionKeyArn }),
...(memory.executionRoleArn && { executionRoleArn: memory.executionRoleArn }),
Expand Down
86 changes: 86 additions & 0 deletions src/cli/commands/shared/indexed-key-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { IndexedKey, IndexedKeyType } from '../../../schema';
import {
INDEXED_KEY_NAME_PATTERN,
INDEXED_KEY_NAME_PATTERN_MESSAGE,
IndexedKeyTypeSchema,
MAX_INDEXED_KEYS,
MAX_INDEXED_KEY_NAME_LENGTH,
} from '../../../schema';

export { INDEXED_KEY_NAME_PATTERN, MAX_INDEXED_KEYS };
export const MAX_KEY_NAME_LENGTH = MAX_INDEXED_KEY_NAME_LENGTH;
export const VALID_INDEXED_KEY_TYPES: readonly IndexedKeyType[] = ['STRING', 'STRINGLIST', 'NUMBER'];

/**
* Validate an indexed key name. Returns `true` when valid, or an error message.
* Shared between the schema-side regex (via constants) and TUI inline validation.
*/
export function validateIndexedKeyName(value: string, existingNames: readonly string[] = []): true | string {
if (!INDEXED_KEY_NAME_PATTERN.test(value)) {
return INDEXED_KEY_NAME_PATTERN_MESSAGE;
}
if (value.trim().length === 0) {
return 'Key cannot be only whitespace';
}
if (value.length > MAX_INDEXED_KEY_NAME_LENGTH) {
return `Maximum ${MAX_INDEXED_KEY_NAME_LENGTH} characters`;
}
if (existingNames.includes(value)) {
return 'Key already defined';
}
return true;
}

export interface IndexedKeyParseError {
ok: false;
error: string;
}

export interface IndexedKeyParseSuccess {
ok: true;
value: IndexedKey;
}

export type IndexedKeyParseResult = IndexedKeyParseError | IndexedKeyParseSuccess;

/**
* Parse a single `key:TYPE` argument into a validated IndexedKey.
*
* Splits on the *last* `:` so that key names may contain `:` (the AgentCore
* service accepts `:` in indexed key names; type tokens never do).
*/
export function parseIndexedKeyArg(raw: string): IndexedKeyParseResult {
const colonIdx = raw.lastIndexOf(':');
if (colonIdx === -1) {
return { ok: false, error: `Invalid indexed key format: "${raw}". Expected key:TYPE (e.g. priority:NUMBER)` };
}
const key = raw.slice(0, colonIdx);
const typeToken = raw.slice(colonIdx + 1).toUpperCase();

if (!key) {
return { ok: false, error: `Invalid indexed key format: "${raw}". Key name cannot be empty` };
}
if (key.length > MAX_KEY_NAME_LENGTH) {
return {
ok: false,
error: `Indexed key name "${key}" exceeds maximum length of ${MAX_KEY_NAME_LENGTH} characters`,
};
}
if (!INDEXED_KEY_NAME_PATTERN.test(key)) {
return {
ok: false,
error: `Invalid indexed key name "${key}". Must contain only alphanumeric characters, whitespace, or the symbols . _ : / = + @ -`,
};
}
if (key.trim().length === 0) {
return { ok: false, error: `Invalid indexed key name "${key}". Key cannot be only whitespace` };
}
const parsedType = IndexedKeyTypeSchema.safeParse(typeToken);
if (!parsedType.success) {
return {
ok: false,
error: `Invalid type "${typeToken}" for indexed key "${key}". Must be one of: ${VALID_INDEXED_KEY_TYPES.join(', ')}`,
};
}
return { ok: true, value: { key, type: parsedType.data } };
}
Loading
Loading