From 4914e318ef3233bec32fc36c684440cf691fa771 Mon Sep 17 00:00:00 2001 From: hkobew Date: Tue, 10 Mar 2026 11:30:38 -0400 Subject: [PATCH] feat: add TUI wizard streaming steps and docs for memory record streaming --- docs/memory.md | 67 ++++++ .../commands/add/__tests__/validate.test.ts | 65 ++++++ src/cli/commands/add/types.ts | 4 + src/cli/commands/add/validate.ts | 31 +++ src/cli/primitives/MemoryPrimitive.tsx | 207 +++++++++++++----- src/cli/tui/hooks/useCreateMemory.ts | 3 + .../tui/screens/memory/AddMemoryScreen.tsx | 83 ++++++- src/cli/tui/screens/memory/types.ts | 18 +- .../tui/screens/memory/useAddMemoryWizard.ts | 79 ++++++- .../__tests__/agentcore-project.test.ts | 96 ++++++++ src/schema/schemas/agentcore-project.ts | 29 +++ 11 files changed, 602 insertions(+), 80 deletions(-) diff --git a/docs/memory.md b/docs/memory.md index 580dc070..b63a6635 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -198,6 +198,73 @@ Memory events expire after a configurable duration (7-365 days, default 30): } ``` +## Memory Record Streaming + +Memory record streaming delivers real-time events when memory records are created, updated, or deleted. Events are +pushed to a delivery target in your account, enabling event-driven architectures without polling. + +### Enabling Streaming + +Via the interactive wizard: + +```bash +agentcore add memory +# Select "Yes" when prompted for streaming, then provide the data stream ARN and content level +``` + +Via CLI flags: + +```bash +agentcore add memory \ + --name MyMemory \ + --strategies SEMANTIC \ + --data-stream-arn arn:aws:kinesis:us-west-2:123456789012:stream/my-stream \ + --stream-content-level FULL_CONTENT +``` + +For advanced configurations (e.g. multiple delivery targets), pass the full JSON: + +```bash +agentcore add memory \ + --name MyMemory \ + --strategies SEMANTIC \ + --stream-delivery-resources '{"resources":[{"kinesis":{"dataStreamArn":"arn:aws:kinesis:us-west-2:123456789012:stream/my-stream","contentConfigurations":[{"type":"MEMORY_RECORDS","level":"FULL_CONTENT"}]}}]}' +``` + +### Configuration + +```json +{ + "type": "AgentCoreMemory", + "name": "MyMemory", + "eventExpiryDuration": 30, + "strategies": [{ "type": "SEMANTIC" }], + "streamDeliveryResources": { + "resources": [ + { + "kinesis": { + "dataStreamArn": "arn:aws:kinesis:us-west-2:123456789012:stream/my-stream", + "contentConfigurations": [{ "type": "MEMORY_RECORDS", "level": "FULL_CONTENT" }] + } + } + ] + } +} +``` + +### Content Level + +| Level | Description | +| --------------- | ---------------------------------------------------------- | +| `FULL_CONTENT` | Events include memory record text and all metadata | +| `METADATA_ONLY` | Events include only metadata (IDs, timestamps, namespaces) | + +The CDK construct automatically grants the memory execution role permission to publish to the configured delivery +target. + +For more details, see the +[Memory Record Streaming documentation](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/memory-record-streaming.html). + ## Using Memory in Code The memory ID is available via environment variable: diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index 7c23f71b..6c4e6795 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -738,6 +738,71 @@ describe('validate', () => { valid: true, }); }); + + // Streaming validation + it('accepts valid streaming options', () => { + expect( + validateAddMemoryOptions({ + ...validMemoryOptions, + dataStreamArn: 'arn:aws:kinesis:us-west-2:123456789012:stream/test', + contentLevel: 'FULL_CONTENT', + }) + ).toEqual({ valid: true }); + }); + + it('accepts dataStreamArn without contentLevel (defaults to FULL_CONTENT)', () => { + expect( + validateAddMemoryOptions({ + ...validMemoryOptions, + dataStreamArn: 'arn:aws:kinesis:us-west-2:123456789012:stream/test', + }) + ).toEqual({ valid: true }); + }); + + it('rejects contentLevel without dataStreamArn', () => { + const result = validateAddMemoryOptions({ ...validMemoryOptions, contentLevel: 'FULL_CONTENT' }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--data-stream-arn is required'); + }); + + it('rejects invalid contentLevel', () => { + const result = validateAddMemoryOptions({ + ...validMemoryOptions, + dataStreamArn: 'arn:aws:kinesis:us-west-2:123456789012:stream/test', + contentLevel: 'INVALID', + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid content level'); + }); + + it('rejects invalid deliveryType', () => { + const result = validateAddMemoryOptions({ ...validMemoryOptions, deliveryType: 'sqs' }); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid delivery type'); + }); + + it('accepts valid deliveryType', () => { + expect(validateAddMemoryOptions({ ...validMemoryOptions, deliveryType: 'kinesis' })).toEqual({ valid: true }); + }); + + it('rejects dataStreamArn not starting with arn:', () => { + const result = validateAddMemoryOptions({ + ...validMemoryOptions, + dataStreamArn: 'not-an-arn', + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('valid ARN'); + }); + + it('rejects combining streamDeliveryResources with flat flags', () => { + const result = validateAddMemoryOptions({ + ...validMemoryOptions, + dataStreamArn: 'arn:aws:kinesis:us-west-2:123456789012:stream/test', + streamDeliveryResources: '{"resources":[]}', + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('cannot be combined'); + }); }); describe('validateAddIdentityOptions', () => { diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index 17a9facc..70f5fefd 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -80,6 +80,10 @@ export interface AddMemoryOptions { name?: string; strategies?: string; expiry?: number; + deliveryType?: string; + dataStreamArn?: string; + contentLevel?: string; + streamDeliveryResources?: string; json?: boolean; } diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index 0e524f77..105f56bb 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -28,6 +28,8 @@ export interface ValidationResult { const MEMORY_OPTIONS = ['none', 'shortTerm', 'longAndShortTerm'] as const; const OIDC_WELL_KNOWN_SUFFIX = '/.well-known/openid-configuration'; const VALID_STRATEGIES = ['SEMANTIC', 'SUMMARIZATION', 'USER_PREFERENCE']; +const VALID_STREAM_CONTENT_LEVELS = ['FULL_CONTENT', 'METADATA_ONLY']; +const VALID_DELIVERY_TYPES = ['kinesis']; /** * Validate that a credential name exists in the project spec. @@ -447,6 +449,35 @@ export function validateAddMemoryOptions(options: AddMemoryOptions): ValidationR } } + if (options.streamDeliveryResources && (options.dataStreamArn || options.contentLevel)) { + return { + valid: false, + error: '--stream-delivery-resources cannot be combined with --data-stream-arn or --stream-content-level', + }; + } + + if (options.contentLevel && !options.dataStreamArn) { + return { valid: false, error: '--data-stream-arn is required when --stream-content-level is set' }; + } + + if (options.dataStreamArn && !options.dataStreamArn.startsWith('arn:')) { + return { valid: false, error: '--data-stream-arn must be a valid ARN (starts with arn:)' }; + } + + if (options.deliveryType && !VALID_DELIVERY_TYPES.includes(options.deliveryType)) { + return { + valid: false, + error: `Invalid delivery type. Must be one of: ${VALID_DELIVERY_TYPES.join(', ')}`, + }; + } + + if (options.contentLevel && !VALID_STREAM_CONTENT_LEVELS.includes(options.contentLevel)) { + return { + valid: false, + error: `Invalid content level. Must be one of: ${VALID_STREAM_CONTENT_LEVELS.join(', ')}`, + }; + } + return { valid: true }; } diff --git a/src/cli/primitives/MemoryPrimitive.tsx b/src/cli/primitives/MemoryPrimitive.tsx index df8dce93..2a56d476 100644 --- a/src/cli/primitives/MemoryPrimitive.tsx +++ b/src/cli/primitives/MemoryPrimitive.tsx @@ -1,6 +1,17 @@ import { findConfigRoot } from '../../lib'; -import type { Memory, MemoryStrategy, MemoryStrategyType } from '../../schema'; -import { DEFAULT_STRATEGY_NAMESPACES, MemorySchema } from '../../schema'; +import type { + Memory, + MemoryStrategy, + MemoryStrategyType, + StreamContentLevel, + StreamDeliveryResources, +} from '../../schema'; +import { + DEFAULT_STRATEGY_NAMESPACES, + MemorySchema, + StreamContentLevelSchema, + StreamDeliveryResourcesSchema, +} from '../../schema'; import { validateAddMemoryOptions } from '../commands/add/validate'; import { getErrorMessage } from '../errors'; import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; @@ -16,6 +27,12 @@ export interface AddMemoryOptions { name: string; strategies?: string; expiry?: number; + deliveryType?: string; + // Flat flags for the simple single-stream case + dataStreamArn?: string; + contentLevel?: string; + // Raw JSON for advanced/multi-target configurations. Takes precedence over flat flags. + streamDeliveryResources?: string; } /** @@ -42,10 +59,21 @@ export class MemoryPrimitive extends BasePrimitive ({ type: type as MemoryStrategyType })) : []; + const streamDeliveryResources = options.streamDeliveryResources + ? this.parseStreamDeliveryResources(options.streamDeliveryResources) + : options.dataStreamArn + ? this.buildStreamDeliveryResources({ + deliveryType: options.deliveryType ?? 'kinesis', + dataStreamArn: options.dataStreamArn, + contentLevel: StreamContentLevelSchema.parse(options.contentLevel ?? 'FULL_CONTENT'), + }) + : undefined; + const memory = await this.createMemory({ name: options.name, eventExpiryDuration: options.expiry ?? DEFAULT_EVENT_EXPIRY, strategies, + streamDeliveryResources, }); return { success: true, memoryName: memory.name }; @@ -129,73 +157,102 @@ export class MemoryPrimitive extends BasePrimitive', 'Event expiry duration in days (default: 30) [non-interactive]') + .option('--delivery-type ', 'Delivery target type (default: kinesis) [non-interactive]') + .option('--data-stream-arn ', 'Kinesis data stream ARN for memory record streaming [non-interactive]') + .option( + '--stream-content-level ', + 'Stream content level: FULL_CONTENT or METADATA_ONLY (default: FULL_CONTENT) [non-interactive]' + ) + .option( + '--stream-delivery-resources ', + 'Stream delivery config as JSON string (advanced, overrides flat flags) [non-interactive]' + ) .option('--json', 'Output as JSON [non-interactive]') - .action(async (cliOptions: { name?: string; strategies?: string; expiry?: string; json?: boolean }) => { - try { - if (!findConfigRoot()) { - console.error('No agentcore project found. Run `agentcore create` first.'); - process.exit(1); - } + .action( + async (cliOptions: { + name?: string; + strategies?: string; + expiry?: string; + deliveryType?: string; + dataStreamArn?: string; + streamContentLevel?: string; + streamDeliveryResources?: string; + json?: boolean; + }) => { + try { + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + + if (cliOptions.name || cliOptions.json) { + // CLI mode + const expiry = cliOptions.expiry ? parseInt(cliOptions.expiry, 10) : undefined; + const validation = validateAddMemoryOptions({ + name: cliOptions.name, + strategies: cliOptions.strategies, + expiry, + deliveryType: cliOptions.deliveryType, + dataStreamArn: cliOptions.dataStreamArn, + contentLevel: cliOptions.streamContentLevel, + streamDeliveryResources: cliOptions.streamDeliveryResources, + }); - if (cliOptions.name || cliOptions.json) { - // CLI mode - const expiry = cliOptions.expiry ? parseInt(cliOptions.expiry, 10) : undefined; - const validation = validateAddMemoryOptions({ - name: cliOptions.name, - strategies: cliOptions.strategies, - expiry, - }); + if (!validation.valid) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: validation.error })); + } else { + console.error(validation.error); + } + process.exit(1); + } + + const result = await this.add({ + name: cliOptions.name!, + strategies: cliOptions.strategies, + expiry, + deliveryType: cliOptions.deliveryType, + dataStreamArn: cliOptions.dataStreamArn, + contentLevel: cliOptions.streamContentLevel, + streamDeliveryResources: cliOptions.streamDeliveryResources, + }); - if (!validation.valid) { if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error: validation.error })); + console.log(JSON.stringify(result)); + } else if (result.success) { + console.log(`Added memory '${result.memoryName}'`); } else { - console.error(validation.error); + console.error(result.error); } - process.exit(1); + process.exit(result.success ? 0 : 1); + } else { + // TUI fallback — dynamic imports to avoid pulling ink (async) into registry + const [{ render }, { default: React }, { AddFlow }] = await Promise.all([ + import('ink'), + import('react'), + import('../tui/screens/add/AddFlow'), + ]); + const { clear, unmount } = render( + React.createElement(AddFlow, { + isInteractive: false, + onExit: () => { + clear(); + unmount(); + process.exit(0); + }, + }) + ); } - - const result = await this.add({ - name: cliOptions.name!, - strategies: cliOptions.strategies, - expiry, - }); - + } catch (error) { if (cliOptions.json) { - console.log(JSON.stringify(result)); - } else if (result.success) { - console.log(`Added memory '${result.memoryName}'`); + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); } else { - console.error(result.error); + console.error(getErrorMessage(error)); } - process.exit(result.success ? 0 : 1); - } else { - // TUI fallback — dynamic imports to avoid pulling ink (async) into registry - const [{ render }, { default: React }, { AddFlow }] = await Promise.all([ - import('ink'), - import('react'), - import('../tui/screens/add/AddFlow'), - ]); - const { clear, unmount } = render( - React.createElement(AddFlow, { - isInteractive: false, - onExit: () => { - clear(); - unmount(); - process.exit(0); - }, - }) - ); - } - } catch (error) { - if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); - } else { - console.error(getErrorMessage(error)); + process.exit(1); } - process.exit(1); } - }); + ); this.registerRemoveSubcommand(removeCmd); } @@ -211,6 +268,7 @@ export class MemoryPrimitive extends BasePrimitive { const project = await this.readProjectSpec(); @@ -231,6 +289,7 @@ export class MemoryPrimitive extends BasePrimitive { @@ -27,6 +28,8 @@ export function useCreateMemory() { name: config.name, expiry: config.eventExpiryDuration, strategies: strategiesStr || undefined, + dataStreamArn: config.streaming?.dataStreamArn, + contentLevel: config.streaming?.contentLevel, }); if (!addResult.success) { throw new Error(addResult.error ?? 'Failed to create memory'); diff --git a/src/cli/tui/screens/memory/AddMemoryScreen.tsx b/src/cli/tui/screens/memory/AddMemoryScreen.tsx index 16375df1..405df30b 100644 --- a/src/cli/tui/screens/memory/AddMemoryScreen.tsx +++ b/src/cli/tui/screens/memory/AddMemoryScreen.tsx @@ -1,4 +1,4 @@ -import type { MemoryStrategyType } from '../../../../schema'; +import type { MemoryStrategyType, StreamContentLevel } from '../../../../schema'; import { AgentNameSchema } from '../../../../schema'; import { ConfirmReview, @@ -14,7 +14,7 @@ import { HELP_TEXT } from '../../constants'; import { useListNavigation, useMultiSelectNavigation } from '../../hooks'; import { generateUniqueName } from '../../utils'; import type { AddMemoryConfig } from './types'; -import { EVENT_EXPIRY_OPTIONS, MEMORY_STEP_LABELS, MEMORY_STRATEGY_OPTIONS } from './types'; +import { CONTENT_LEVEL_OPTIONS, EVENT_EXPIRY_OPTIONS, MEMORY_STEP_LABELS, MEMORY_STRATEGY_OPTIONS } from './types'; import { useAddMemoryWizard } from './useAddMemoryWizard'; import React, { useMemo } from 'react'; @@ -24,6 +24,11 @@ interface AddMemoryScreenProps { existingMemoryNames: string[]; } +const STREAMING_OPTIONS: SelectableItem[] = [ + { id: 'yes', title: 'Yes', description: 'Stream memory record events to a delivery target (e.g. Kinesis)' }, + { id: 'no', title: 'No', description: 'No streaming' }, +]; + export function AddMemoryScreen({ onComplete, onExit, existingMemoryNames }: AddMemoryScreenProps) { const wizard = useAddMemoryWizard(); @@ -37,9 +42,17 @@ export function AddMemoryScreen({ onComplete, onExit, existingMemoryNames }: Add [] ); + const contentLevelItems: SelectableItem[] = useMemo( + () => CONTENT_LEVEL_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + const isNameStep = wizard.step === 'name'; const isExpiryStep = wizard.step === 'expiry'; const isStrategiesStep = wizard.step === 'strategies'; + const isStreamingStep = wizard.step === 'streaming'; + const isStreamArnStep = wizard.step === 'streamArn'; + const isContentLevelStep = wizard.step === 'contentLevel'; const isConfirmStep = wizard.step === 'confirm'; const expiryNav = useListNavigation({ @@ -58,6 +71,20 @@ export function AddMemoryScreen({ onComplete, onExit, existingMemoryNames }: Add requireSelection: false, }); + const streamingNav = useListNavigation({ + items: STREAMING_OPTIONS, + onSelect: item => wizard.setStreamingEnabled(item.id === 'yes'), + onExit: () => wizard.goBack(), + isActive: isStreamingStep, + }); + + const contentLevelNav = useListNavigation({ + items: contentLevelItems, + onSelect: item => wizard.setContentLevel(item.id as StreamContentLevel), + onExit: () => wizard.goBack(), + isActive: isContentLevelStep, + }); + useListNavigation({ items: [{ id: 'confirm', title: 'Confirm' }], onSelect: () => onComplete(wizard.config), @@ -67,7 +94,7 @@ export function AddMemoryScreen({ onComplete, onExit, existingMemoryNames }: Add const helpText = isStrategiesStep ? 'Space toggle · Enter confirm · Esc back' - : isExpiryStep + : isExpiryStep || isStreamingStep || isContentLevelStep ? HELP_TEXT.NAVIGATE_SELECT : isConfirmStep ? HELP_TEXT.CONFIRM_CANCEL @@ -75,6 +102,21 @@ export function AddMemoryScreen({ onComplete, onExit, existingMemoryNames }: Add const headerContent = ; + const confirmFields = useMemo( + () => [ + { label: 'Name', value: wizard.config.name }, + { label: 'Event Expiry', value: `${wizard.config.eventExpiryDuration} days` }, + { label: 'Strategies', value: wizard.config.strategies.map(s => s.type).join(', ') || 'None' }, + ...(wizard.config.streaming + ? [ + { label: 'Stream ARN', value: wizard.config.streaming.dataStreamArn }, + { label: 'Content Level', value: wizard.config.streaming.contentLevel }, + ] + : [{ label: 'Streaming', value: 'Disabled' }]), + ], + [wizard.config] + ); + return ( @@ -109,15 +151,36 @@ export function AddMemoryScreen({ onComplete, onExit, existingMemoryNames }: Add /> )} - {isConfirmStep && ( - s.type).join(', ') || 'None' }, - ]} + {isStreamingStep && ( + + )} + + {isStreamArnStep && ( + wizard.goBack()} + customValidation={value => value.startsWith('arn:') || 'Must be a valid ARN (starts with arn:)'} /> )} + + {isContentLevelStep && ( + + )} + + {isConfirmStep && } ); diff --git a/src/cli/tui/screens/memory/types.ts b/src/cli/tui/screens/memory/types.ts index b7ca0f71..08fc3009 100644 --- a/src/cli/tui/screens/memory/types.ts +++ b/src/cli/tui/screens/memory/types.ts @@ -1,26 +1,35 @@ -import type { MemoryStrategyType } from '../../../../schema'; +import type { MemoryStrategyType, StreamContentLevel } from '../../../../schema'; import { MemoryStrategyTypeSchema } from '../../../../schema'; // ───────────────────────────────────────────────────────────────────────────── // Memory Flow Types // ───────────────────────────────────────────────────────────────────────────── -export type AddMemoryStep = 'name' | 'expiry' | 'strategies' | 'confirm'; +export type AddMemoryStep = 'name' | 'expiry' | 'strategies' | 'streaming' | 'streamArn' | 'contentLevel' | 'confirm'; export interface AddMemoryStrategyConfig { type: MemoryStrategyType; } +export interface AddMemoryStreamingConfig { + dataStreamArn: string; + contentLevel: StreamContentLevel; +} + export interface AddMemoryConfig { name: string; eventExpiryDuration: number; strategies: AddMemoryStrategyConfig[]; + streaming?: AddMemoryStreamingConfig; } export const MEMORY_STEP_LABELS: Record = { name: 'Name', expiry: 'Expiry', strategies: 'Strategies', + streaming: 'Streaming', + streamArn: 'Stream ARN', + contentLevel: 'Content Level', confirm: 'Confirm', }; @@ -48,6 +57,11 @@ export const EVENT_EXPIRY_OPTIONS = [ { id: 365, title: '365 days', description: 'Maximum retention' }, ] as const; +export const CONTENT_LEVEL_OPTIONS = [ + { id: 'FULL_CONTENT', title: 'Full content', description: 'Include memory record text in stream events' }, + { id: 'METADATA_ONLY', title: 'Metadata only', description: 'Only include metadata (IDs, timestamps, namespaces)' }, +] as const; + // ───────────────────────────────────────────────────────────────────────────── // Defaults // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/cli/tui/screens/memory/useAddMemoryWizard.ts b/src/cli/tui/screens/memory/useAddMemoryWizard.ts index 98a0ad44..aca78e06 100644 --- a/src/cli/tui/screens/memory/useAddMemoryWizard.ts +++ b/src/cli/tui/screens/memory/useAddMemoryWizard.ts @@ -1,9 +1,12 @@ -import type { MemoryStrategyType } from '../../../../schema'; +import type { MemoryStrategyType, StreamContentLevel } from '../../../../schema'; import type { AddMemoryConfig, AddMemoryStep, AddMemoryStrategyConfig } from './types'; import { DEFAULT_EVENT_EXPIRY } from './types'; -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; -const ALL_STEPS: AddMemoryStep[] = ['name', 'expiry', 'strategies', 'confirm']; +const BASE_STEPS = ['name', 'expiry', 'strategies', 'streaming'] as const; +const STREAMING_STEPS = ['streamArn', 'contentLevel'] as const; +const FIRST_STREAMING_STEP = STREAMING_STEPS[0]; +const CONFIRM_STEP = 'confirm' as const; function getDefaultConfig(): AddMemoryConfig { return { @@ -16,18 +19,27 @@ function getDefaultConfig(): AddMemoryConfig { export function useAddMemoryWizard() { const [config, setConfig] = useState(getDefaultConfig); const [step, setStep] = useState('name'); + const [enableStreaming, setEnableStreaming] = useState(false); - const currentIndex = ALL_STEPS.indexOf(step); + const allSteps = useMemo( + () => (enableStreaming ? [...BASE_STEPS, ...STREAMING_STEPS, CONFIRM_STEP] : [...BASE_STEPS, CONFIRM_STEP]), + [enableStreaming] + ); + const currentIndex = allSteps.indexOf(step); const goBack = useCallback(() => { - const prevStep = ALL_STEPS[currentIndex - 1]; + const idx = allSteps.indexOf(step); + const prevStep = allSteps[idx - 1]; if (prevStep) setStep(prevStep); - }, [currentIndex]); + }, [allSteps, step]); - const nextStep = useCallback((currentStep: AddMemoryStep): AddMemoryStep | undefined => { - const idx = ALL_STEPS.indexOf(currentStep); - return ALL_STEPS[idx + 1]; - }, []); + const nextStep = useCallback( + (currentStep: AddMemoryStep): AddMemoryStep | undefined => { + const idx = allSteps.indexOf(currentStep); + return allSteps[idx + 1]; + }, + [allSteps] + ); const setName = useCallback( (name: string) => { @@ -57,20 +69,65 @@ export function useAddMemoryWizard() { [nextStep] ); + const setStreamingEnabled = useCallback((enabled: boolean) => { + setEnableStreaming(enabled); + if (enabled) { + // Can't use nextStep() here — allSteps hasn't updated yet since + // setEnableStreaming is queued. Hardcode the known next step. + setStep(FIRST_STREAMING_STEP); + } else { + setConfig(c => ({ ...c, streaming: undefined })); + setStep(CONFIRM_STEP); + } + }, []); + + const setStreamArn = useCallback( + (dataStreamArn: string) => { + setConfig(c => ({ + ...c, + streaming: { dataStreamArn, contentLevel: c.streaming?.contentLevel ?? 'FULL_CONTENT' }, + })); + const next = nextStep(FIRST_STREAMING_STEP); + if (next) setStep(next); + }, + [nextStep] + ); + + const setContentLevel = useCallback( + (contentLevel: StreamContentLevel) => { + setConfig(c => { + if (!c.streaming?.dataStreamArn) { + throw new Error('Cannot set content level without a data stream ARN'); + } + return { + ...c, + streaming: { dataStreamArn: c.streaming.dataStreamArn, contentLevel }, + }; + }); + const next = nextStep('contentLevel'); + if (next) setStep(next); + }, + [nextStep] + ); + const reset = useCallback(() => { setConfig(getDefaultConfig()); setStep('name'); + setEnableStreaming(false); }, []); return { config, step, - steps: ALL_STEPS, + steps: allSteps, currentIndex, goBack, setName, setExpiry, setStrategyTypes, + setStreamingEnabled, + setStreamArn, + setContentLevel, reset, }; } diff --git a/src/schema/schemas/__tests__/agentcore-project.test.ts b/src/schema/schemas/__tests__/agentcore-project.test.ts index f6beaf74..59f4dc7c 100644 --- a/src/schema/schemas/__tests__/agentcore-project.test.ts +++ b/src/schema/schemas/__tests__/agentcore-project.test.ts @@ -219,6 +219,102 @@ describe('MemorySchema', () => { }); expect(result.success).toBe(false); }); + + it('accepts memory with streamDeliveryResources', () => { + const result = MemorySchema.safeParse({ + type: 'AgentCoreMemory', + name: 'StreamMemory', + eventExpiryDuration: 30, + strategies: [{ type: 'SEMANTIC' }], + streamDeliveryResources: { + resources: [ + { + kinesis: { + dataStreamArn: 'arn:aws:kinesis:us-west-2:123456789012:stream/test', + contentConfigurations: [{ type: 'MEMORY_RECORDS', level: 'FULL_CONTENT' }], + }, + }, + ], + }, + }); + expect(result.success).toBe(true); + }); + + it('accepts memory without streamDeliveryResources', () => { + const result = MemorySchema.safeParse({ + type: 'AgentCoreMemory', + name: 'NoStream', + eventExpiryDuration: 30, + strategies: [], + }); + expect(result.success).toBe(true); + expect(result.data?.streamDeliveryResources).toBeUndefined(); + }); + + it('rejects streamDeliveryResources with empty resources array', () => { + const result = MemorySchema.safeParse({ + type: 'AgentCoreMemory', + name: 'Test', + eventExpiryDuration: 30, + strategies: [], + streamDeliveryResources: { resources: [] }, + }); + expect(result.success).toBe(false); + }); + + it('rejects streamDeliveryResources with empty contentConfigurations', () => { + const result = MemorySchema.safeParse({ + type: 'AgentCoreMemory', + name: 'Test', + eventExpiryDuration: 30, + strategies: [], + streamDeliveryResources: { + resources: [ + { + kinesis: { dataStreamArn: 'arn:aws:kinesis:us-west-2:123456789012:stream/test', contentConfigurations: [] }, + }, + ], + }, + }); + expect(result.success).toBe(false); + }); + + it('rejects streamDeliveryResources with empty dataStreamArn', () => { + const result = MemorySchema.safeParse({ + type: 'AgentCoreMemory', + name: 'Test', + eventExpiryDuration: 30, + strategies: [], + streamDeliveryResources: { + resources: [ + { + kinesis: { dataStreamArn: '', contentConfigurations: [{ type: 'MEMORY_RECORDS', level: 'FULL_CONTENT' }] }, + }, + ], + }, + }); + expect(result.success).toBe(false); + }); + + it('rejects invalid content level in streamDeliveryResources', () => { + const result = MemorySchema.safeParse({ + type: 'AgentCoreMemory', + name: 'Test', + eventExpiryDuration: 30, + strategies: [], + streamDeliveryResources: { + resources: [ + { + kinesis: { + dataStreamArn: 'arn:test', + contentConfigurations: [{ type: 'MEMORY_RECORDS', level: 'INVALID' }], + }, + }, + ], + }, + }); + expect(result.success).toBe(false); + }); }); describe('CredentialNameSchema', () => { diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index fda34160..c78f54ae 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -48,6 +48,34 @@ export const MemoryNameSchema = z 'Must begin with a letter and contain only alphanumeric characters and underscores (max 48 chars)' ); +export const StreamContentLevelSchema = z.enum(['FULL_CONTENT', 'METADATA_ONLY']); +export type StreamContentLevel = z.infer; + +// TODO: kinesis is currently the only supported delivery type. When additional types +// (e.g. S3, EventBridge) are added, this should become a discriminated union. +// Non-kinesis resources will produce a Zod error about the missing kinesis field. +export const StreamDeliveryResourcesSchema = z.object({ + resources: z + .array( + z.object({ + kinesis: z.object({ + dataStreamArn: z.string().min(1), + contentConfigurations: z + .array( + z.object({ + type: z.literal('MEMORY_RECORDS'), + level: StreamContentLevelSchema, + }) + ) + .min(1), + }), + }) + ) + .min(1), +}); + +export type StreamDeliveryResources = z.infer; + export const MemorySchema = z.object({ type: MemoryTypeSchema, name: MemoryNameSchema, @@ -63,6 +91,7 @@ export const MemorySchema = z.object({ type => `Duplicate memory strategy type: ${type}` ) ), + streamDeliveryResources: StreamDeliveryResourcesSchema.optional(), }); export type Memory = z.infer;